Skip to main content

abbaye/
git_browse.rs

1use std::path::Path;
2
3use gix::bstr::ByteSlice;
4use miette::{IntoDiagnostic, Result, miette};
5use serde::Serialize;
6use tera::{Context, Tera};
7
8use crate::config::OutputFormat;
9use crate::git_ui::{
10    TEMPLATE_GIT_BLOB_GEMTEXT, TEMPLATE_GIT_BLOB_HTML, TEMPLATE_GIT_TREE_GEMTEXT,
11    TEMPLATE_GIT_TREE_HTML,
12};
13
14// ── Types ──────────────────────────────────────────────────────────────────────
15
16#[derive(Serialize)]
17struct Crumb {
18    name: String,
19    url: Option<String>,
20}
21
22#[derive(Serialize)]
23#[serde(rename_all = "lowercase")]
24enum TreeEntryKind {
25    Tree,
26    Blob,
27}
28
29#[derive(Serialize)]
30struct TreeEntry {
31    name: String,
32    kind: TreeEntryKind,
33    url: String,
34    raw_url: Option<String>,
35}
36
37// ── Entry point ────────────────────────────────────────────────────────────────
38
39/// Build the full static tree browser for every revision in `revisions`.
40#[allow(clippy::too_many_arguments)]
41pub(crate) fn build_browse_pages(
42    revisions: &[(String, gix::ObjectId)],
43    browse_dir: &Path,
44    repo_path: &Path,
45    theme_path: &Path,
46    project_name: &str,
47    lang: &Option<String>,
48    clone_url: &Option<String>,
49    formats: &[OutputFormat],
50    git_ui_path: &str,
51    prefix_empty: bool,
52    ref_map: &[(&str, &str)],
53) -> Result<()> {
54    use syntect::highlighting::ThemeSet;
55    use syntect::parsing::SyntaxSet;
56
57    let ss = SyntaxSet::load_defaults_newlines();
58    let ts = ThemeSet::load_defaults();
59    let theme = &ts.themes["InspiredGitHub"];
60
61    let mut tera = Tera::default();
62    crate::site::register_format_templates(
63        &mut tera,
64        theme_path,
65        formats,
66        &[
67            (
68                "git_tree",
69                TEMPLATE_GIT_TREE_HTML,
70                TEMPLATE_GIT_TREE_GEMTEXT,
71            ),
72            (
73                "git_blob",
74                TEMPLATE_GIT_BLOB_HTML,
75                TEMPLATE_GIT_BLOB_GEMTEXT,
76            ),
77        ],
78    )?;
79
80    for (hash, oid) in revisions {
81        let rev_dir = browse_dir.join(hash);
82        std::fs::create_dir_all(&rev_dir).into_diagnostic()?;
83
84        let repo = gix::open(repo_path).into_diagnostic()?;
85        let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit();
86        let decoded = commit_obj.decode().into_diagnostic()?;
87        let tree_id = decoded.tree();
88
89        walk_tree_dir(
90            repo_path,
91            &repo,
92            tree_id,
93            "",
94            hash,
95            &rev_dir,
96            &tera,
97            project_name,
98            lang,
99            clone_url,
100            &ss,
101            theme,
102            formats,
103            git_ui_path,
104            prefix_empty,
105        )?;
106    }
107
108    // Create stable ref-based symlinks so browse/<ref>/foo points at the same
109    // content as browse/<hash>/foo.  Ref names containing '/' are flattened
110    // to '-' to avoid directory conflicts with repo paths.
111    for (ref_name, commit_hash) in ref_map {
112        let target = browse_dir.join(ref_name);
113        if target.exists() {
114            std::fs::remove_file(&target).ok();
115            std::fs::remove_dir(&target).ok();
116        }
117        let _ = std::os::unix::fs::symlink(commit_hash, &target);
118    }
119
120    Ok(())
121}
122
123// ── Tree walker ────────────────────────────────────────────────────────────────
124
125#[allow(clippy::too_many_arguments)]
126fn walk_tree_dir(
127    repo_path: &Path,
128    repo: &gix::Repository,
129    tree_id: gix::ObjectId,
130    dir_path: &str,
131    commit_hash: &str,
132    rev_dir: &Path,
133    tera: &Tera,
134    project_name: &str,
135    lang: &Option<String>,
136    clone_url: &Option<String>,
137    ss: &syntect::parsing::SyntaxSet,
138    theme: &syntect::highlighting::Theme,
139    formats: &[OutputFormat],
140    git_ui_path: &str,
141    prefix_empty: bool,
142) -> Result<()> {
143    let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree();
144    let decoded = tree_obj.decode().into_diagnostic()?;
145
146    let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count();
147
148    let page_dir = if dir_path.is_empty() {
149        rev_dir.to_path_buf()
150    } else {
151        dir_path
152            .split('/')
153            .filter(|s| !s.is_empty())
154            .fold(rev_dir.to_path_buf(), |p, c| p.join(c))
155    };
156    std::fs::create_dir_all(&page_dir).into_diagnostic()?;
157
158    let mut entries: Vec<TreeEntry> = Vec::new();
159    let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new();
160    let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new();
161
162    for entry in decoded.entries.iter() {
163        let name = entry.filename.to_str_lossy().into_owned();
164        let oid: gix::ObjectId = entry.oid.to_owned();
165
166        if entry.mode.is_tree() {
167            entries.push(TreeEntry {
168                url: format!("{name}/index.html"),
169                name: name.clone(),
170                kind: TreeEntryKind::Tree,
171                raw_url: None,
172            });
173            subdirs.push((name, oid));
174        } else {
175            entries.push(TreeEntry {
176                url: format!("{name}.html"),
177                name: name.clone(),
178                kind: TreeEntryKind::Blob,
179                raw_url: Some(name.clone()),
180            });
181            if !entry.mode.is_commit() {
182                blobs.push((name, oid));
183            }
184        }
185    }
186
187    entries.sort_by(|a, b| {
188        let a_tree = matches!(a.kind, TreeEntryKind::Tree);
189        let b_tree = matches!(b.kind, TreeEntryKind::Tree);
190        b_tree.cmp(&a_tree).then(a.name.cmp(&b.name))
191    });
192
193    let prefix_depth = if prefix_empty { 0 } else { 1 };
194    let root_path = "../".repeat(prefix_depth + depth + 2);
195    let commit_url = format!(
196        "{}commit/{commit_hash}.html",
197        "../".repeat(prefix_depth + depth + 1)
198    );
199    let breadcrumbs = make_crumbs(dir_path, false, None);
200
201    for format in formats {
202        let suffix = format.extension();
203        let tmpl_name = format!("git_tree.{suffix}");
204        let ext = format.extension();
205
206        let mut ctx = Context::new();
207        ctx.insert("project_name", project_name);
208        ctx.insert("lang", lang);
209        ctx.insert("clone_url", clone_url);
210        ctx.insert("commit_hash", commit_hash);
211        ctx.insert("commit_hash_short", &commit_hash[..7]);
212        ctx.insert("commit_url", &commit_url);
213        ctx.insert("dir_path", dir_path);
214        ctx.insert("entries", &entries);
215        ctx.insert("breadcrumbs", &breadcrumbs);
216        ctx.insert("root_path", &root_path);
217        ctx.insert("git_ui_path", git_ui_path);
218
219        let content = tera
220            .render(&tmpl_name, &ctx)
221            .map_err(|e| miette::miette!("{e}"))?;
222        std::fs::write(page_dir.join(format!("index.{ext}")), content).into_diagnostic()?;
223    }
224
225    for (name, oid) in subdirs {
226        let child_path = if dir_path.is_empty() {
227            name
228        } else {
229            format!("{dir_path}/{name}")
230        };
231        walk_tree_dir(
232            repo_path,
233            repo,
234            oid,
235            &child_path,
236            commit_hash,
237            rev_dir,
238            tera,
239            project_name,
240            lang,
241            clone_url,
242            ss,
243            theme,
244            formats,
245            git_ui_path,
246            prefix_empty,
247        )?;
248    }
249
250    for (name, oid) in blobs {
251        let file_path = if dir_path.is_empty() {
252            name.clone()
253        } else {
254            format!("{dir_path}/{name}")
255        };
256        render_blob_page(
257            repo_path,
258            &name,
259            &file_path,
260            oid,
261            commit_hash,
262            depth,
263            &page_dir,
264            tera,
265            project_name,
266            lang,
267            clone_url,
268            ss,
269            theme,
270            formats,
271            git_ui_path,
272            prefix_empty,
273        )?;
274    }
275
276    Ok(())
277}
278
279// ── Blob page ──────────────────────────────────────────────────────────────────
280
281#[allow(clippy::too_many_arguments)]
282fn render_blob_page(
283    repo_path: &Path,
284    filename: &str,
285    file_path: &str,
286    oid: gix::ObjectId,
287    commit_hash: &str,
288    depth: usize,
289    page_dir: &Path,
290    tera: &Tera,
291    project_name: &str,
292    lang: &Option<String>,
293    clone_url: &Option<String>,
294    ss: &syntect::parsing::SyntaxSet,
295    theme: &syntect::highlighting::Theme,
296    formats: &[OutputFormat],
297    git_ui_path: &str,
298    prefix_empty: bool,
299) -> Result<()> {
300    const MAX_BLOB_BYTES: usize = 1024 * 1024;
301
302    let output = std::process::Command::new("git")
303        .current_dir(repo_path)
304        .args(["cat-file", "blob", &oid.to_string()])
305        .output()
306        .into_diagnostic()?;
307
308    if !output.status.success() {
309        return Err(miette!("failed to read blob object: {}", oid));
310    }
311
312    let data = output.stdout;
313
314    let is_binary = data[..data.len().min(8192)].contains(&0u8);
315    let too_large = data.len() > MAX_BLOB_BYTES;
316
317    // Write raw blob content so the file can be downloaded directly.
318    if !data.is_empty() {
319        std::fs::write(page_dir.join(filename), &data).into_diagnostic()?;
320    }
321
322    let text = String::from_utf8_lossy(&data);
323    let content_plain: Option<String> = if is_binary || too_large || data.is_empty() {
324        None
325    } else {
326        Some(text.to_string())
327    };
328    let content_html: Option<String> = if is_binary || too_large || data.is_empty() {
329        None
330    } else {
331        let ext = std::path::Path::new(filename)
332            .extension()
333            .and_then(|s| s.to_str())
334            .unwrap_or("");
335        let syntax = ss
336            .find_syntax_by_extension(ext)
337            .or_else(|| {
338                text.lines()
339                    .next()
340                    .and_then(|l| ss.find_syntax_by_first_line(l))
341            })
342            .unwrap_or_else(|| ss.find_syntax_plain_text());
343        Some(
344            syntect::html::highlighted_html_for_string(&text, ss, syntax, theme)
345                .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))),
346        )
347    };
348
349    let prefix_depth = if prefix_empty { 0 } else { 1 };
350    let root_path = "../".repeat(prefix_depth + depth + 2);
351    let commit_url = format!(
352        "{}commit/{commit_hash}.html",
353        "../".repeat(prefix_depth + depth + 1)
354    );
355    let breadcrumbs = make_crumbs(
356        std::path::Path::new(file_path)
357            .parent()
358            .and_then(|p| p.to_str())
359            .unwrap_or(""),
360        true,
361        Some(filename),
362    );
363
364    for format in formats {
365        let suffix = format.extension();
366        let tmpl_name = format!("git_blob.{suffix}");
367        let ext = format.extension();
368
369        let mut ctx = Context::new();
370        ctx.insert("project_name", project_name);
371        ctx.insert("lang", lang);
372        ctx.insert("clone_url", clone_url);
373        ctx.insert("commit_hash", commit_hash);
374        ctx.insert("commit_hash_short", &commit_hash[..7]);
375        ctx.insert("commit_url", &commit_url);
376        ctx.insert("file_path", file_path);
377        ctx.insert("filename", filename);
378        ctx.insert("breadcrumbs", &breadcrumbs);
379        ctx.insert("content_html", &content_html);
380        ctx.insert("content_plain", &content_plain);
381        ctx.insert("is_binary", &is_binary);
382        ctx.insert("too_large", &too_large);
383        ctx.insert("size", &data.len());
384        ctx.insert("root_path", &root_path);
385        ctx.insert("git_ui_path", git_ui_path);
386        ctx.insert("raw_url", filename);
387
388        let content = tera
389            .render(&tmpl_name, &ctx)
390            .map_err(|e| miette::miette!("{e}"))?;
391        std::fs::write(page_dir.join(format!("{filename}.{ext}")), content).into_diagnostic()?;
392    }
393
394    Ok(())
395}
396
397// ── Helpers ────────────────────────────────────────────────────────────────────
398
399fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> {
400    let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect();
401    let depth = parts.len();
402    let mut crumbs = Vec::new();
403
404    let root_url = if depth == 0 && !is_blob {
405        None
406    } else {
407        Some(format!("{}index.html", "../".repeat(depth)))
408    };
409    crumbs.push(Crumb {
410        name: "~".to_string(),
411        url: root_url,
412    });
413
414    for (i, &part) in parts.iter().enumerate() {
415        let is_last_and_tree = i == depth - 1 && !is_blob;
416        let url = if is_last_and_tree {
417            None
418        } else {
419            let levels_up = depth - i - 1;
420            Some(format!("{}index.html", "../".repeat(levels_up)))
421        };
422        crumbs.push(Crumb {
423            name: part.to_string(),
424            url,
425        });
426    }
427
428    if is_blob {
429        if let Some(name) = filename {
430            crumbs.push(Crumb {
431                name: name.to_string(),
432                url: None,
433            });
434        }
435    }
436
437    crumbs
438}
439
440fn escape_html(s: &str) -> String {
441    s.replace('&', "&amp;")
442        .replace('<', "&lt;")
443        .replace('>', "&gt;")
444}