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.rs18.8 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 full object id only if it names a commit.
183///
184/// A package URL is a promise about one immutable source tree. `rev-parse` on a
185/// bare object id accepts blobs and trees too; peeling `^{commit}` makes a blob
186/// hash a not-found answer rather than letting a later `ls-tree` fault. Exit 1 is
187/// deliberately the ordinary "not a commit" answer through `Git::query`.
188pub fn commit_id(git_dir: &Path, object_id: &str) -> Result<String> {
189 if !is_object_id(object_id) {
190 return Err(Error::invalid(
191 "invalid-object",
192 "object id must be 40 or 64 hex characters",
193 ));
194 }
195 commit_reference(git_dir, object_id)
196}
197
198/// Peel a validated readable ref to the commit it names.
199///
200/// Used by immutable package release tags. Annotated tags point at a tag object,
201/// so `^{commit}` is essential — it gives the package handler the source commit,
202/// not the wrapper object, and treats a tag to a blob/tree as a not-found answer.
203pub fn commit_reference(git_dir: &Path, reference: &str) -> Result<String> {
204 if !is_object_id(reference) {
205 validate::readable_ref_name(reference)?;
206 }
207 let wanted = format!("{reference}^{{commit}}");
208 let out = Git::at(git_dir)
209 .query(&["rev-parse", "--verify", "--quiet", &wanted])?
210 .ok_or(Error::NotFound("commit"))?;
211 Ok(String::from_utf8_lossy(&out).trim().to_string())
212}
213
214/// Resolve a revision, reporting an unknown one as absent rather than as a fault.
215///
216/// `rev-parse --verify --quiet` exits 1 for "no such revision", which is the only
217/// exit code this layer treats as an answer. Commands like `cat-file -t` exit 128
218/// for the same condition, so they cannot be used as the existence gate.
219fn resolve(git_dir: &Path, reference: &str) -> Result<String> {
220 let out = Git::at(git_dir)
221 .query(&["rev-parse", "--verify", "--quiet", reference])?
222 .ok_or(Error::NotFound("revision"))?;
223 Ok(String::from_utf8_lossy(&out).trim().to_string())
224}
225
226// ── history ────────────────────────────────────────────────────────────────
227
228const LOG_FORMAT: &str = "--format=%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%B%x1e";
229
230pub fn log(
231 git_dir: &Path,
232 reference: &str,
233 limit: usize,
234 path: Option<&str>,
235 limits: Limits,
236) -> Result<Value> {
237 resolve(git_dir, reference)?;
238
239 let count = format!("-{limit}");
240 let mut args = vec!["log", &count, LOG_FORMAT, reference];
241
242 let truncated = match path {
243 Some(p) => {
244 args.push("--");
245 args.push(p);
246 walked_to_budget(git_dir, reference, p, limits.log_walk_budget)
247 }
248 None => false,
249 };
250
251 let out = git(git_dir, &args)?;
252 Ok(json!({ "items": parse_log(&out), "truncated": truncated }))
253}
254
255pub fn commit(git_dir: &Path, sha: &str) -> Result<Value> {
256 resolve(git_dir, sha)?;
257 let out = git(git_dir, &["log", "-1", LOG_FORMAT, sha])?;
258 let found = parse_log(&out).pop().ok_or(Error::NotFound("commit"))?;
259
260 let stat = git(git_dir, &["show", "--name-status", "--format=", sha])?;
261 Ok(json!({ "commit": found, "changed": name_status(&stat) }))
262}
263
264/// Diff two points, as `git diff base...head` does — from their merge base, so the
265/// answer is "what head added", not "how the two differ".
266pub fn compare(git_dir: &Path, base: &str, head: &str) -> Result<Value> {
267 resolve(git_dir, base)?;
268 resolve(git_dir, head)?;
269 let range = format!("{base}...{head}");
270
271 let merge_base = git(git_dir, &["merge-base", base, head])
272 .ok()
273 .map(|o| String::from_utf8_lossy(&o).trim().to_string())
274 .filter(|s| !s.is_empty());
275
276 let commits = parse_log(&git(git_dir, &["log", LOG_FORMAT, &range])?);
277 let stat = git(git_dir, &["diff", "--name-status", &range])?;
278 let numstat = git(git_dir, &["diff", "--numstat", &range])?;
279
280 let mut added = 0u64;
281 let mut removed = 0u64;
282 for line in String::from_utf8_lossy(&numstat).lines() {
283 let mut fields = line.split('\t');
284 added += fields
285 .next()
286 .and_then(|v| v.parse::<u64>().ok())
287 .unwrap_or(0);
288 removed += fields
289 .next()
290 .and_then(|v| v.parse::<u64>().ok())
291 .unwrap_or(0);
292 }
293
294 Ok(json!({
295 "base": base,
296 "head": head,
297 "merge_base": merge_base,
298 "commits": commits,
299 "changed": name_status(&stat),
300 "additions": added,
301 "deletions": removed,
302 }))
303}
304
305/// Who last changed each line of a file.
306pub fn blame(git_dir: &Path, reference: &str, path: &str) -> Result<Value> {
307 resolve(git_dir, reference)?;
308 let out = git(git_dir, &["blame", "--porcelain", reference, "--", path])?;
309
310 let text = String::from_utf8_lossy(&out);
311 let mut items = Vec::new();
312 let mut sha = String::new();
313 let mut author = String::new();
314 let mut at: u64 = 0;
315 let mut line_no: u64 = 0;
316
317 for line in text.lines() {
318 if let Some(rest) = line.strip_prefix("author ") {
319 author = rest.to_string();
320 } else if let Some(rest) = line.strip_prefix("author-time ") {
321 at = rest.trim().parse().unwrap_or(0);
322 } else if let Some(content) = line.strip_prefix('\t') {
323 items.push(json!({
324 "line": line_no, "sha": sha, "author": author,
325 "authored_at": at, "content": content,
326 }));
327 } else {
328 // A header line is `<sha> <orig-line> <final-line> [<count>]`.
329 let mut fields = line.split(' ');
330 if let (Some(candidate), Some(_), Some(final_line)) =
331 (fields.next(), fields.next(), fields.next())
332 {
333 if is_object_id(candidate) {
334 sha = candidate.to_string();
335 line_no = final_line.parse().unwrap_or(0);
336 }
337 }
338 }
339 }
340
341 Ok(json!({ "path": path, "ref": reference, "items": items }))
342}
343
344/// Search file contents.
345///
346/// The pattern is passed after `-e` and the paths after `--`, so neither can be read
347/// as a flag: without `-e`, a query of `--and` or `-P` changes what git grep does.
348pub fn search(
349 git_dir: &Path,
350 reference: &str,
351 query: &str,
352 path: Option<&str>,
353 limit: usize,
354) -> Result<Value> {
355 if query.is_empty() {
356 return Err(Error::invalid("invalid-query", "q must not be empty"));
357 }
358
359 resolve(git_dir, reference)?;
360
361 let count = format!("--max-count={limit}");
362 let mut args = vec![
363 "grep",
364 "--fixed-strings",
365 "--line-number",
366 "--no-color",
367 "-I", // skip binary files; matching bytes are not useful to read
368 &count,
369 "-e",
370 query,
371 reference,
372 ];
373 if let Some(p) = path {
374 args.push("--");
375 args.push(p);
376 }
377
378 // `git grep` exits 1 for "no matches", which is an answer. A higher exit is a
379 // real failure and must not be reported as "nothing found".
380 let Some(out) = Git::at(git_dir).query(&args)? else {
381 return Ok(json!({ "items": [], "truncated": false }));
382 };
383
384 let items: Vec<Value> = String::from_utf8_lossy(&out)
385 .lines()
386 .filter_map(|line| {
387 // `<ref>:<path>:<line>:<content>`
388 let rest = line.strip_prefix(&format!("{reference}:"))?;
389 let (path, rest) = rest.split_once(':')?;
390 let (number, content) = rest.split_once(':')?;
391 Some(json!({
392 "path": path,
393 "line": number.parse::<u64>().unwrap_or(0),
394 "content": content,
395 }))
396 })
397 .take(limit)
398 .collect();
399
400 let truncated = items.len() >= limit;
401 Ok(json!({ "items": items, "truncated": truncated }))
402}
403
404// ── shared helpers ─────────────────────────────────────────────────────────
405
406/// The list envelope.
407///
408/// There is no cursor. An earlier version emitted `next_cursor: null` on every
409/// list, including truncated ones, which told a client the list had ended when it
410/// had not. `truncated` says what is actually true; when a real cursor exists it
411/// can be added without having lied in the meantime.
412fn page(items: Vec<Value>) -> Value {
413 json!({ "items": items, "truncated": false })
414}
415
416fn name_status(raw: &[u8]) -> Vec<Value> {
417 String::from_utf8_lossy(raw)
418 .lines()
419 .filter_map(|line| {
420 let (status, path) = line.split_once('\t')?;
421 Some(json!({ "status": status, "path": path }))
422 })
423 .collect()
424}
425
426fn parse_log(raw: &[u8]) -> Vec<Value> {
427 String::from_utf8_lossy(raw)
428 .split('\x1e')
429 .filter(|record| !record.trim().is_empty())
430 .filter_map(|record| {
431 let fields: Vec<&str> = record.trim_start_matches('\n').split('\x1f').collect();
432 let [sha, parents, name, email, at, message] = fields.as_slice() else {
433 return None;
434 };
435 Some(json!({
436 "sha": sha,
437 "message": message.trim_end(),
438 "author_name": name,
439 "author_email": email,
440 "authored_at": at.parse::<u64>().unwrap_or(0),
441 "parents": parents.split_whitespace().collect::<Vec<_>>(),
442 }))
443 })
444 .collect()
445}
446
447/// Whether a path-filtered log exhausted its walk budget.
448///
449/// Counted separately because `git log` gives no signal that it stopped early.
450fn walked_to_budget(git_dir: &Path, reference: &str, path: &str, budget: usize) -> bool {
451 let cap = format!("--max-count={}", budget + 1);
452 let Ok(out) = git(
453 git_dir,
454 &["rev-list", &cap, "--count", reference, "--", path],
455 ) else {
456 return false;
457 };
458 String::from_utf8_lossy(&out)
459 .trim()
460 .parse::<usize>()
461 .is_ok_and(|n| n > budget)
462}
463
464pub fn is_object_id(value: &str) -> bool {
465 matches!(value.len(), 40 | 64) && value.bytes().all(|b| b.is_ascii_hexdigit())
466}
467
468/// Validate a revision the caller supplied before it becomes a git argument.
469///
470/// Accepts a full object id or a fully-qualified readable ref. Anything else —
471/// `HEAD`, `main`, `--upload-pack=…` — is refused, so a value cannot be read as a
472/// flag and cannot name something outside `refs/`.
473pub fn revision(value: &str) -> Result<String> {
474 if is_object_id(value) {
475 return Ok(value.to_string());
476 }
477 validate::readable_ref_name(value)?;
478 Ok(value.to_string())
479}
480
481/// Run a read-only git query.
482///
483/// git exits 1 for "no such object" and "no matches", which are answers; anything
484/// higher is a fault. Collapsing both into 404 — as an earlier version did — meant a
485/// corrupt repository reported as empty and nothing paged anyone.
486fn git(git_dir: &Path, args: &[&str]) -> Result<Vec<u8>> {
487 Git::at(git_dir)
488 .query(args)?
489 .ok_or(Error::NotFound("object"))
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn a_revision_may_be_a_full_object_id_or_a_qualified_ref() {
498 revision(&"a".repeat(40)).unwrap();
499 revision(&"f".repeat(64)).unwrap();
500 revision("refs/heads/main").unwrap();
501 revision("refs/tags/v1").unwrap();
502 }
503
504 #[test]
505 fn a_revision_cannot_smuggle_a_flag_or_escape_refs() {
506 for bad in [
507 "--upload-pack=evil",
508 "-n",
509 "HEAD",
510 "main",
511 "refs/heads/../../config",
512 "../../etc/passwd",
513 &"z".repeat(40),
514 ] {
515 assert!(revision(bad).is_err(), "{bad:?} must be refused");
516 }
517 }
518
519 #[test]
520 fn object_ids_are_recognised_at_both_hash_lengths() {
521 assert!(is_object_id(&"0".repeat(40)));
522 assert!(is_object_id(&"0".repeat(64)));
523 assert!(!is_object_id("abc"));
524 assert!(!is_object_id(&"g".repeat(40)));
525 }
526
527 #[test]
528 fn parses_a_log_record_including_a_multiline_message() {
529 let raw = b"abc\x1fp1 p2\x1fAgent\x1fa@b\x1f1700000000\x1fsubject\n\nbody\x1e";
530 let parsed = parse_log(raw);
531 assert_eq!(parsed.len(), 1);
532 assert_eq!(parsed[0]["sha"], "abc");
533 assert_eq!(parsed[0]["message"], "subject\n\nbody");
534 assert_eq!(parsed[0]["authored_at"], 1_700_000_000u64);
535 assert_eq!(parsed[0]["parents"], json!(["p1", "p2"]));
536 }
537
538 #[test]
539 fn an_empty_log_parses_to_nothing_rather_than_a_blank_entry() {
540 assert!(parse_log(b"").is_empty());
541 assert!(parse_log(b"\n").is_empty());
542 }
543
544 #[test]
545 fn name_status_pairs_a_status_with_a_path() {
546 let parsed = name_status(b"M\tsrc/main.rs\nA\tREADME.md\n");
547 assert_eq!(parsed.len(), 2);
548 assert_eq!(parsed[0]["status"], "M");
549 assert_eq!(parsed[1]["path"], "README.md");
550 }
551}