231 lines · 6323 bytes
1 use std::path::Path;
2 use std::process::Command;
3
4 #[derive(Debug, Clone)]
5 pub struct TreeEntry {
6 pub kind: String, // "blob" or "tree"
7 pub name: String,
8 }
9
10 #[derive(Debug, Clone)]
11 pub struct CommitInfo {
12 pub short_hash: String,
13 pub subject: String,
14 pub author: String,
15 pub date: String,
16 }
17
18 /// Reject path/rev components that could be used for command injection.
19 fn sanitize_param(s: &str) -> Result<(), &'static str> {
20 if s.is_empty() {
21 return Err("empty parameter");
22 }
23 if s.contains('\0') || s.contains("..") || s.starts_with('-') {
24 return Err("invalid parameter");
25 }
26 Ok(())
27 }
28
29 fn repo_path(repos_root: &str, owner: &str, repo: &str) -> std::path::PathBuf {
30 Path::new(repos_root).join(owner).join(format!("{repo}.git"))
31 }
32
33 /// Get the default branch name (usually "main").
34 pub fn get_default_branch(repos_root: &str, owner: &str, repo: &str) -> Option<String> {
35 let dir = repo_path(repos_root, owner, repo);
36 let output = Command::new("git")
37 .arg("-C").arg(&dir)
38 .arg("symbolic-ref")
39 .arg("--short")
40 .arg("HEAD")
41 .output()
42 .ok()?;
43 if output.status.success() {
44 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
45 if branch.is_empty() { None } else { Some(branch) }
46 } else {
47 None
48 }
49 }
50
51 /// List tree entries at a given rev and path. Returns entries sorted dirs-first.
52 pub fn list_tree(
53 repos_root: &str,
54 owner: &str,
55 repo: &str,
56 rev: &str,
57 path: &str,
58 ) -> Result<Vec<TreeEntry>, String> {
59 sanitize_param(rev).map_err(|e| e.to_string())?;
60 if !path.is_empty() {
61 for segment in path.split('/') {
62 sanitize_param(segment).map_err(|e| e.to_string())?;
63 }
64 }
65
66 let dir = repo_path(repos_root, owner, repo);
67 let tree_spec = if path.is_empty() {
68 format!("{rev}:")
69 } else {
70 format!("{rev}:{path}")
71 };
72
73 let output = Command::new("git")
74 .arg("-C").arg(&dir)
75 .arg("ls-tree")
76 .arg("--")
77 .arg(&tree_spec)
78 .output()
79 .map_err(|e| e.to_string())?;
80
81 if !output.status.success() {
82 return Err("ls-tree failed".to_string());
83 }
84
85 let text = String::from_utf8_lossy(&output.stdout);
86 let mut dirs = Vec::new();
87 let mut files = Vec::new();
88
89 for line in text.lines() {
90 // Format: "<mode> <type> <hash>\t<name>"
91 let Some((meta, name)) = line.split_once('\t') else { continue };
92 let parts: Vec<&str> = meta.split_whitespace().collect();
93 if parts.len() < 3 { continue; }
94 let kind = parts[1].to_string();
95 let entry = TreeEntry {
96 kind: kind.clone(),
97 name: name.to_string(),
98 };
99 if kind == "tree" {
100 dirs.push(entry);
101 } else {
102 files.push(entry);
103 }
104 }
105
106 dirs.sort_by(|a, b| a.name.cmp(&b.name));
107 files.sort_by(|a, b| a.name.cmp(&b.name));
108 dirs.extend(files);
109 Ok(dirs)
110 }
111
112 /// Get file contents as raw bytes.
113 pub fn get_blob(
114 repos_root: &str,
115 owner: &str,
116 repo: &str,
117 rev: &str,
118 path: &str,
119 ) -> Result<Vec<u8>, String> {
120 sanitize_param(rev).map_err(|e| e.to_string())?;
121 for segment in path.split('/') {
122 sanitize_param(segment).map_err(|e| e.to_string())?;
123 }
124
125 let dir = repo_path(repos_root, owner, repo);
126 let spec = format!("{rev}:{path}");
127
128 let output = Command::new("git")
129 .arg("-C").arg(&dir)
130 .arg("show")
131 .arg(&spec)
132 .output()
133 .map_err(|e| e.to_string())?;
134
135 if !output.status.success() {
136 return Err("blob not found".to_string());
137 }
138 Ok(output.stdout)
139 }
140
141 /// List commits with pagination.
142 pub fn list_commits(
143 repos_root: &str,
144 owner: &str,
145 repo: &str,
146 rev: &str,
147 count: usize,
148 skip: usize,
149 ) -> Result<Vec<CommitInfo>, String> {
150 sanitize_param(rev).map_err(|e| e.to_string())?;
151
152 let dir = repo_path(repos_root, owner, repo);
153 let output = Command::new("git")
154 .arg("-C").arg(&dir)
155 .arg("log")
156 .arg(format!("--max-count={count}"))
157 .arg(format!("--skip={skip}"))
158 .arg("--format=%h%x00%s%x00%an%x00%ar")
159 .arg(rev)
160 .arg("--")
161 .output()
162 .map_err(|e| e.to_string())?;
163
164 // Empty repo / invalid rev - return empty list rather than error
165 if !output.status.success() {
166 return Ok(Vec::new());
167 }
168
169 let text = String::from_utf8_lossy(&output.stdout);
170 let mut commits = Vec::new();
171 for line in text.lines() {
172 let parts: Vec<&str> = line.splitn(4, '\0').collect();
173 if parts.len() < 4 { continue; }
174 commits.push(CommitInfo {
175 short_hash: parts[0].to_string(),
176 subject: parts[1].to_string(),
177 author: parts[2].to_string(),
178 date: parts[3].to_string(),
179 });
180 }
181 Ok(commits)
182 }
183
184 /// Find and render README.md from a tree.
185 pub fn get_readme(
186 repos_root: &str,
187 owner: &str,
188 repo: &str,
189 rev: &str,
190 path: &str,
191 ) -> Option<String> {
192 let entries = list_tree(repos_root, owner, repo, rev, path).ok()?;
193 let readme_name = entries.iter().find(|e| {
194 let lower = e.name.to_lowercase();
195 lower == "readme.md" || lower == "readme"
196 })?;
197
198 let readme_path = if path.is_empty() {
199 readme_name.name.clone()
200 } else {
201 format!("{path}/{}", readme_name.name)
202 };
203
204 let bytes = get_blob(repos_root, owner, repo, rev, &readme_path).ok()?;
205 let text = String::from_utf8(bytes).ok()?;
206
207 if readme_name.name.to_lowercase().ends_with(".md") {
208 Some(render_markdown(&text))
209 } else {
210 // Plain text README — wrap in <pre>
211 Some(format!("<pre>{}</pre>", html_escape(&text)))
212 }
213 }
214
215 fn render_markdown(md: &str) -> String {
216 use pulldown_cmark::{html, Options, Parser};
217 let opts = Options::ENABLE_TABLES
218 | Options::ENABLE_STRIKETHROUGH
219 | Options::ENABLE_TASKLISTS;
220 let parser = Parser::new_ext(md, opts);
221 let mut out = String::new();
222 html::push_html(&mut out, parser);
223 out
224 }
225
226 fn html_escape(s: &str) -> String {
227 s.replace('&', "&amp;")
228 .replace('<', "&lt;")
229 .replace('>', "&gt;")
230 .replace('"', "&quot;")
231 }