zuka
zuka/src/git/discover.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/git/discover.rs
RSdiscover.rs17.4 KBDownload
1// Repository discovery: the read side of the product.
2//
3// git moves code; this is how an agent finds out what is in a repository without
4// cloning it — what branches exist, what a directory holds, what a file says, what
5// changed between two points, who last touched a line, where a string appears.
6//
7// Every function returns a `serde_json::Value` rather than an HTTP response, so the
8// REST handler and the MCP tool call the *same* code and cannot drift (SPEC §1.1).
9// Nothing here writes.
10
11use crate::error::{Error, Result};
12use crate::git::exec::Git;
13use crate::git::validate;
14use serde_json::{json, Value};
15use std::path::Path;
16
17/// Bounds on a single read. Supplied by the caller from `Config`.
18#[derive(Debug, Clone, Copy)]
19pub struct Limits {
20 pub max_blob_bytes: u64,
21 pub log_walk_budget: usize,
22}
23
24/// A blob plus the metadata a caller needs to cache or address it again.
25pub struct Blob {
26 pub bytes: Vec<u8>,
27 pub sha: String,
28 /// True when the request named an immutable object id rather than a branch.
29 pub immutable: bool,
30}
31
32// ── refs ───────────────────────────────────────────────────────────────────
33
34pub fn refs(git_dir: &Path, kind: Option<&str>) -> Result<Value> {
35 let out = git(
36 git_dir,
37 &[
38 "for-each-ref",
39 // A literal 0x1f, not "%x1f": `for-each-ref` does not expand `%xNN`
40 // the way `git log --format` does and would emit it verbatim.
41 "--format=%(refname)\u{1f}%(objectname)\u{1f}%(creatordate:unix)\u{1f}%(subject)",
42 "refs/heads/",
43 "refs/tags/",
44 "refs/notes/",
45 ],
46 )?;
47
48 let items: Vec<Value> = String::from_utf8_lossy(&out)
49 .lines()
50 .filter_map(|line| {
51 let mut parts = line.split('\x1f');
52 let name = parts.next()?.to_string();
53 let sha = parts.next()?.to_string();
54 let at: u64 = parts.next()?.parse().unwrap_or(0);
55 let subject = parts.next().unwrap_or("").to_string();
56
57 let ref_kind = match name.split('/').nth(1) {
58 Some("tags") => "tag",
59 Some("notes") => "note",
60 _ => "branch",
61 };
62 if let Some(wanted) = kind {
63 if wanted != ref_kind {
64 return None;
65 }
66 }
67 Some(json!({
68 "name": name, "sha": sha, "type": ref_kind,
69 "updated_at": at, "subject": subject,
70 }))
71 })
72 .collect();
73
74 Ok(page(items))
75}
76
77// ── tree and blobs ─────────────────────────────────────────────────────────
78
79pub fn tree(git_dir: &Path, reference: &str, path: Option<&str>) -> Result<Value> {
80 // A pathspec against the ref rather than a `<ref>:<path>` tree-ish: the latter
81 // reports names relative to the subtree, so a caller could not use an entry's
82 // `path` to address it again.
83 resolve(git_dir, reference)?;
84
85 let mut args = vec!["ls-tree", "--long", "--full-name", "-z", reference];
86 let with_slash;
87 if let Some(p) = path {
88 with_slash = format!("{p}/");
89 args.push("--");
90 args.push(&with_slash);
91 }
92
93 let out = git(git_dir, &args)?;
94 let items: Vec<Value> = String::from_utf8_lossy(&out)
95 .split('\0')
96 .filter(|record| !record.is_empty())
97 .filter_map(|record| {
98 let (meta, entry_path) = record.split_once('\t')?;
99 let fields: Vec<&str> = meta.split_whitespace().collect();
100 let [mode, kind, sha, size @ ..] = fields.as_slice() else {
101 return None;
102 };
103 let entry_kind = match *kind {
104 "tree" => "dir",
105 "commit" => "submodule",
106 _ if *mode == "120000" => "symlink",
107 _ => "file",
108 };
109 Some(json!({
110 "path": entry_path,
111 "name": entry_path.rsplit('/').next().unwrap_or(entry_path),
112 "type": entry_kind,
113 "mode": mode,
114 "sha": sha,
115 "size": size.first().and_then(|s| s.parse::<u64>().ok()),
116 }))
117 })
118 .collect();
119
120 Ok(page(items))
121}
122
123/// Read a blob.
124///
125/// One `ls-tree` gives mode, type, sha and size together, so this is two spawns
126/// rather than the five an earlier version used (`cat-file -t`, `ls-tree`,
127/// `cat-file -s`, `cat-file blob`, `rev-parse`).
128///
129/// Symlinks are refused rather than followed: serving a link's target as content
130/// would let a tree entry pointing at `/etc/passwd` read a host file.
131pub fn blob(git_dir: &Path, reference: &str, path: &str, limits: Limits) -> Result<Blob> {
132 resolve(git_dir, reference)?;
133
134 let out = git(
135 git_dir,
136 &[
137 "ls-tree",
138 "--long",
139 "--full-name",
140 "-z",
141 reference,
142 "--",
143 path,
144 ],
145 )?;
146 let record = String::from_utf8_lossy(&out);
147 let record = record
148 .split('\0')
149 .find(|r| !r.is_empty())
150 .ok_or(Error::NotFound("file"))?;
151
152 let (meta, _) = record.split_once('\t').ok_or(Error::NotFound("file"))?;
153 let fields: Vec<&str> = meta.split_whitespace().collect();
154 let [mode, kind, sha, size @ ..] = fields.as_slice() else {
155 return Err(Error::NotFound("file"));
156 };
157
158 if *kind != "blob" {
159 return Err(Error::NotFound("file"));
160 }
161 if *mode == "120000" {
162 return Err(Error::invalid(
163 "invalid-path",
164 "symlinks are not served; read the link's target directly",
165 ));
166 }
167
168 let bytes: u64 = size.first().and_then(|s| s.parse().ok()).unwrap_or(0);
169 if bytes > limits.max_blob_bytes {
170 return Err(Error::PayloadTooLarge {
171 limit: limits.max_blob_bytes,
172 });
173 }
174
175 Ok(Blob {
176 bytes: git(git_dir, &["cat-file", "blob", sha])?,
177 sha: (*sha).to_string(),
178 immutable: is_object_id(reference),
179 })
180}
181
182/// Resolve a revision, reporting an unknown one as absent rather than as a fault.
183///
184/// `rev-parse --verify --quiet` exits 1 for "no such revision", which is the only
185/// exit code this layer treats as an answer. Commands like `cat-file -t` exit 128
186/// for the same condition, so they cannot be used as the existence gate.
187fn resolve(git_dir: &Path, reference: &str) -> Result<String> {
188 let out = Git::at(git_dir)
189 .query(&["rev-parse", "--verify", "--quiet", reference])?
190 .ok_or(Error::NotFound("revision"))?;
191 Ok(String::from_utf8_lossy(&out).trim().to_string())
192}
193
194// ── history ────────────────────────────────────────────────────────────────
195
196const LOG_FORMAT: &str = "--format=%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%B%x1e";
197
198pub fn log(
199 git_dir: &Path,
200 reference: &str,
201 limit: usize,
202 path: Option<&str>,
203 limits: Limits,
204) -> Result<Value> {
205 resolve(git_dir, reference)?;
206
207 let count = format!("-{limit}");
208 let mut args = vec!["log", &count, LOG_FORMAT, reference];
209
210 let truncated = match path {
211 Some(p) => {
212 args.push("--");
213 args.push(p);
214 walked_to_budget(git_dir, reference, p, limits.log_walk_budget)
215 }
216 None => false,
217 };
218
219 let out = git(git_dir, &args)?;
220 Ok(json!({ "items": parse_log(&out), "truncated": truncated }))
221}
222
223pub fn commit(git_dir: &Path, sha: &str) -> Result<Value> {
224 resolve(git_dir, sha)?;
225 let out = git(git_dir, &["log", "-1", LOG_FORMAT, sha])?;
226 let found = parse_log(&out).pop().ok_or(Error::NotFound("commit"))?;
227
228 let stat = git(git_dir, &["show", "--name-status", "--format=", sha])?;
229 Ok(json!({ "commit": found, "changed": name_status(&stat) }))
230}
231
232/// Diff two points, as `git diff base...head` does — from their merge base, so the
233/// answer is "what head added", not "how the two differ".
234pub fn compare(git_dir: &Path, base: &str, head: &str) -> Result<Value> {
235 resolve(git_dir, base)?;
236 resolve(git_dir, head)?;
237 let range = format!("{base}...{head}");
238
239 let merge_base = git(git_dir, &["merge-base", base, head])
240 .ok()
241 .map(|o| String::from_utf8_lossy(&o).trim().to_string())
242 .filter(|s| !s.is_empty());
243
244 let commits = parse_log(&git(git_dir, &["log", LOG_FORMAT, &range])?);
245 let stat = git(git_dir, &["diff", "--name-status", &range])?;
246 let numstat = git(git_dir, &["diff", "--numstat", &range])?;
247
248 let mut added = 0u64;
249 let mut removed = 0u64;
250 for line in String::from_utf8_lossy(&numstat).lines() {
251 let mut fields = line.split('\t');
252 added += fields
253 .next()
254 .and_then(|v| v.parse::<u64>().ok())
255 .unwrap_or(0);
256 removed += fields
257 .next()
258 .and_then(|v| v.parse::<u64>().ok())
259 .unwrap_or(0);
260 }
261
262 Ok(json!({
263 "base": base,
264 "head": head,
265 "merge_base": merge_base,
266 "commits": commits,
267 "changed": name_status(&stat),
268 "additions": added,
269 "deletions": removed,
270 }))
271}
272
273/// Who last changed each line of a file.
274pub fn blame(git_dir: &Path, reference: &str, path: &str) -> Result<Value> {
275 resolve(git_dir, reference)?;
276 let out = git(git_dir, &["blame", "--porcelain", reference, "--", path])?;
277
278 let text = String::from_utf8_lossy(&out);
279 let mut items = Vec::new();
280 let mut sha = String::new();
281 let mut author = String::new();
282 let mut at: u64 = 0;
283 let mut line_no: u64 = 0;
284
285 for line in text.lines() {
286 if let Some(rest) = line.strip_prefix("author ") {
287 author = rest.to_string();
288 } else if let Some(rest) = line.strip_prefix("author-time ") {
289 at = rest.trim().parse().unwrap_or(0);
290 } else if let Some(content) = line.strip_prefix('\t') {
291 items.push(json!({
292 "line": line_no, "sha": sha, "author": author,
293 "authored_at": at, "content": content,
294 }));
295 } else {
296 // A header line is `<sha> <orig-line> <final-line> [<count>]`.
297 let mut fields = line.split(' ');
298 if let (Some(candidate), Some(_), Some(final_line)) =
299 (fields.next(), fields.next(), fields.next())
300 {
301 if is_object_id(candidate) {
302 sha = candidate.to_string();
303 line_no = final_line.parse().unwrap_or(0);
304 }
305 }
306 }
307 }
308
309 Ok(json!({ "path": path, "ref": reference, "items": items }))
310}
311
312/// Search file contents.
313///
314/// The pattern is passed after `-e` and the paths after `--`, so neither can be read
315/// as a flag: without `-e`, a query of `--and` or `-P` changes what git grep does.
316pub fn search(
317 git_dir: &Path,
318 reference: &str,
319 query: &str,
320 path: Option<&str>,
321 limit: usize,
322) -> Result<Value> {
323 if query.is_empty() {
324 return Err(Error::invalid("invalid-query", "q must not be empty"));
325 }
326
327 resolve(git_dir, reference)?;
328
329 let count = format!("--max-count={limit}");
330 let mut args = vec![
331 "grep",
332 "--fixed-strings",
333 "--line-number",
334 "--no-color",
335 "-I", // skip binary files; matching bytes are not useful to read
336 &count,
337 "-e",
338 query,
339 reference,
340 ];
341 if let Some(p) = path {
342 args.push("--");
343 args.push(p);
344 }
345
346 // `git grep` exits 1 for "no matches", which is an answer. A higher exit is a
347 // real failure and must not be reported as "nothing found".
348 let Some(out) = Git::at(git_dir).query(&args)? else {
349 return Ok(json!({ "items": [], "truncated": false }));
350 };
351
352 let items: Vec<Value> = String::from_utf8_lossy(&out)
353 .lines()
354 .filter_map(|line| {
355 // `<ref>:<path>:<line>:<content>`
356 let rest = line.strip_prefix(&format!("{reference}:"))?;
357 let (path, rest) = rest.split_once(':')?;
358 let (number, content) = rest.split_once(':')?;
359 Some(json!({
360 "path": path,
361 "line": number.parse::<u64>().unwrap_or(0),
362 "content": content,
363 }))
364 })
365 .take(limit)
366 .collect();
367
368 let truncated = items.len() >= limit;
369 Ok(json!({ "items": items, "truncated": truncated }))
370}
371
372// ── shared helpers ─────────────────────────────────────────────────────────
373
374/// The list envelope.
375///
376/// There is no cursor. An earlier version emitted `next_cursor: null` on every
377/// list, including truncated ones, which told a client the list had ended when it
378/// had not. `truncated` says what is actually true; when a real cursor exists it
379/// can be added without having lied in the meantime.
380fn page(items: Vec<Value>) -> Value {
381 json!({ "items": items, "truncated": false })
382}
383
384fn name_status(raw: &[u8]) -> Vec<Value> {
385 String::from_utf8_lossy(raw)
386 .lines()
387 .filter_map(|line| {
388 let (status, path) = line.split_once('\t')?;
389 Some(json!({ "status": status, "path": path }))
390 })
391 .collect()
392}
393
394fn parse_log(raw: &[u8]) -> Vec<Value> {
395 String::from_utf8_lossy(raw)
396 .split('\x1e')
397 .filter(|record| !record.trim().is_empty())
398 .filter_map(|record| {
399 let fields: Vec<&str> = record.trim_start_matches('\n').split('\x1f').collect();
400 let [sha, parents, name, email, at, message] = fields.as_slice() else {
401 return None;
402 };
403 Some(json!({
404 "sha": sha,
405 "message": message.trim_end(),
406 "author_name": name,
407 "author_email": email,
408 "authored_at": at.parse::<u64>().unwrap_or(0),
409 "parents": parents.split_whitespace().collect::<Vec<_>>(),
410 }))
411 })
412 .collect()
413}
414
415/// Whether a path-filtered log exhausted its walk budget.
416///
417/// Counted separately because `git log` gives no signal that it stopped early.
418fn walked_to_budget(git_dir: &Path, reference: &str, path: &str, budget: usize) -> bool {
419 let cap = format!("--max-count={}", budget + 1);
420 let Ok(out) = git(
421 git_dir,
422 &["rev-list", &cap, "--count", reference, "--", path],
423 ) else {
424 return false;
425 };
426 String::from_utf8_lossy(&out)
427 .trim()
428 .parse::<usize>()
429 .is_ok_and(|n| n > budget)
430}
431
432pub fn is_object_id(value: &str) -> bool {
433 matches!(value.len(), 40 | 64) && value.bytes().all(|b| b.is_ascii_hexdigit())
434}
435
436/// Validate a revision the caller supplied before it becomes a git argument.
437///
438/// Accepts a full object id or a fully-qualified readable ref. Anything else —
439/// `HEAD`, `main`, `--upload-pack=…` — is refused, so a value cannot be read as a
440/// flag and cannot name something outside `refs/`.
441pub fn revision(value: &str) -> Result<String> {
442 if is_object_id(value) {
443 return Ok(value.to_string());
444 }
445 validate::readable_ref_name(value)?;
446 Ok(value.to_string())
447}
448
449/// Run a read-only git query.
450///
451/// git exits 1 for "no such object" and "no matches", which are answers; anything
452/// higher is a fault. Collapsing both into 404 — as an earlier version did — meant a
453/// corrupt repository reported as empty and nothing paged anyone.
454fn git(git_dir: &Path, args: &[&str]) -> Result<Vec<u8>> {
455 Git::at(git_dir)
456 .query(args)?
457 .ok_or(Error::NotFound("object"))
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn a_revision_may_be_a_full_object_id_or_a_qualified_ref() {
466 revision(&"a".repeat(40)).unwrap();
467 revision(&"f".repeat(64)).unwrap();
468 revision("refs/heads/main").unwrap();
469 revision("refs/tags/v1").unwrap();
470 }
471
472 #[test]
473 fn a_revision_cannot_smuggle_a_flag_or_escape_refs() {
474 for bad in [
475 "--upload-pack=evil",
476 "-n",
477 "HEAD",
478 "main",
479 "refs/heads/../../config",
480 "../../etc/passwd",
481 &"z".repeat(40),
482 ] {
483 assert!(revision(bad).is_err(), "{bad:?} must be refused");
484 }
485 }
486
487 #[test]
488 fn object_ids_are_recognised_at_both_hash_lengths() {
489 assert!(is_object_id(&"0".repeat(40)));
490 assert!(is_object_id(&"0".repeat(64)));
491 assert!(!is_object_id("abc"));
492 assert!(!is_object_id(&"g".repeat(40)));
493 }
494
495 #[test]
496 fn parses_a_log_record_including_a_multiline_message() {
497 let raw = b"abc\x1fp1 p2\x1fAgent\x1fa@b\x1f1700000000\x1fsubject\n\nbody\x1e";
498 let parsed = parse_log(raw);
499 assert_eq!(parsed.len(), 1);
500 assert_eq!(parsed[0]["sha"], "abc");
501 assert_eq!(parsed[0]["message"], "subject\n\nbody");
502 assert_eq!(parsed[0]["authored_at"], 1_700_000_000u64);
503 assert_eq!(parsed[0]["parents"], json!(["p1", "p2"]));
504 }
505
506 #[test]
507 fn an_empty_log_parses_to_nothing_rather_than_a_blank_entry() {
508 assert!(parse_log(b"").is_empty());
509 assert!(parse_log(b"\n").is_empty());
510 }
511
512 #[test]
513 fn name_status_pairs_a_status_with_a_path() {
514 let parsed = name_status(b"M\tsrc/main.rs\nA\tREADME.md\n");
515 assert_eq!(parsed.len(), 2);
516 assert_eq!(parsed[0]["status"], "M");
517 assert_eq!(parsed[1]["path"], "README.md");
518 }
519}