Skip to main content

solid_pod_rs_git/
api.rs

1//! High-level git control-panel operations for a pod's git repository.
2//!
3//! Each function shells out to the `git` binary via [`tokio::process::Command`].
4//! All functions take `repo: &Path` — the absolute filesystem path to the
5//! pod's git repository (i.e. `data_root/{pubkey}`).
6//!
7//! These are wired to REST endpoints in `solid-pod-rs-server` behind
8//! `#[cfg(feature = "git")]`.
9
10use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13use tokio::process::Command;
14
15use crate::error::GitError;
16
17// ---------------------------------------------------------------------------
18// Public types
19// ---------------------------------------------------------------------------
20
21/// Type of change reported by `git status`.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "lowercase")]
24pub enum ChangeType {
25    /// File contents modified.
26    Modified,
27    /// File added to the index.
28    Added,
29    /// File deleted.
30    Deleted,
31    /// File renamed (old path is in `FileStatus::old_path`).
32    Renamed,
33    /// File copied (old path is in `FileStatus::old_path`).
34    Copied,
35}
36
37/// A single file entry in the status report.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct FileStatus {
41    /// Relative path within the repository.
42    pub path: String,
43    /// Nature of the change.
44    pub change_type: ChangeType,
45    /// For renames/copies: the original path before the operation.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub old_path: Option<String>,
48}
49
50/// Full status report for a repository.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct StatusReport {
54    /// Current branch name.
55    pub branch: String,
56    /// Commits ahead of the upstream tracking branch.
57    pub ahead: u32,
58    /// Commits behind the upstream tracking branch.
59    pub behind: u32,
60    /// Files staged for the next commit (index changes).
61    pub staged: Vec<FileStatus>,
62    /// Files with unstaged working-tree changes.
63    pub unstaged: Vec<FileStatus>,
64    /// Paths not tracked by git.
65    pub untracked: Vec<String>,
66    /// `true` when all three lists are empty.
67    pub is_clean: bool,
68}
69
70/// A single commit log entry.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct CommitEntry {
74    /// Full 40-character SHA-1 hash.
75    pub hash: String,
76    /// 7-character abbreviated hash.
77    pub short_hash: String,
78    /// First line of the commit message.
79    pub message: String,
80    /// Author name.
81    pub author: String,
82    /// ISO-8601 author date.
83    pub date: String,
84    /// Human-readable relative date (e.g. "3 days ago").
85    pub date_relative: String,
86}
87
88/// Current branch state.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct BranchInfo {
92    /// The currently checked-out branch.
93    pub current: String,
94    /// All local branches.
95    pub local: Vec<String>,
96}
97
98/// Result of a successful commit.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct CommitResult {
102    /// Full commit hash.
103    pub hash: String,
104    /// Abbreviated commit hash.
105    pub short_hash: String,
106    /// One-line commit subject.
107    pub summary: String,
108}
109
110// ---------------------------------------------------------------------------
111// Internal helpers
112// ---------------------------------------------------------------------------
113
114/// Run a git command and collect stdout. Returns `GitError::BackendFailed`
115/// when the exit code is non-zero.
116async fn git_run(args: &[&str], repo: &Path) -> Result<String, GitError> {
117    let output = Command::new("git")
118        .args(args)
119        .current_dir(repo)
120        .output()
121        .await
122        .map_err(|e| {
123            if e.kind() == std::io::ErrorKind::NotFound {
124                GitError::BackendNotAvailable("git binary not found in PATH".into())
125            } else {
126                GitError::Io(e)
127            }
128        })?;
129
130    if output.status.success() {
131        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
132    } else {
133        Err(GitError::BackendFailed {
134            exit_code: output.status.code(),
135            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
136        })
137    }
138}
139
140/// Same as `git_run` but tolerates a non-zero exit code, returning the
141/// stdout anyway (some git commands like `reset HEAD` exit 1 on early commits).
142async fn git_run_tolerant(args: &[&str], repo: &Path) -> Result<String, GitError> {
143    let output = Command::new("git")
144        .args(args)
145        .current_dir(repo)
146        .output()
147        .await
148        .map_err(|e| {
149            if e.kind() == std::io::ErrorKind::NotFound {
150                GitError::BackendNotAvailable("git binary not found in PATH".into())
151            } else {
152                GitError::Io(e)
153            }
154        })?;
155
156    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
157}
158
159/// Validate a user-supplied file path segment: must not contain `..` and must
160/// not start with `/`.
161fn validate_path(path: &str) -> Result<(), GitError> {
162    if path.starts_with('/') || path.contains("..") {
163        return Err(GitError::PathTraversal(path.to_string()));
164    }
165    Ok(())
166}
167
168/// Parse a `--porcelain=v1 -b` status line for the index (X) character.
169fn parse_xy(x: char, y: char) -> (Option<ChangeType>, Option<ChangeType>) {
170    let map_char = |c: char| match c {
171        'M' => Some(ChangeType::Modified),
172        'A' => Some(ChangeType::Added),
173        'D' => Some(ChangeType::Deleted),
174        'R' => Some(ChangeType::Renamed),
175        'C' => Some(ChangeType::Copied),
176        _ => None,
177    };
178    (map_char(x), map_char(y))
179}
180
181// ---------------------------------------------------------------------------
182// Public API
183// ---------------------------------------------------------------------------
184
185/// Return the working-tree and index status of `repo`.
186pub async fn git_status(repo: &Path) -> Result<StatusReport, GitError> {
187    let raw = git_run(&["status", "--porcelain=v1", "-b"], repo).await?;
188    parse_status_output(&raw)
189}
190
191/// Pure-function status parser — split out for unit-testing.
192pub fn parse_status_output(raw: &str) -> Result<StatusReport, GitError> {
193    let mut lines = raw.lines();
194
195    // ── Branch line ──────────────────────────────────────────────────────────
196    let branch_line = lines.next().unwrap_or("");
197    // Strip leading `## `
198    let branch_line = branch_line.trim_start_matches("## ");
199
200    let mut ahead: u32 = 0;
201    let mut behind: u32 = 0;
202
203    let branch: String;
204    if branch_line.starts_with("No commits yet on ") {
205        branch = branch_line
206            .trim_start_matches("No commits yet on ")
207            .to_string();
208    } else {
209        // Format: `main...origin/main [ahead 1, behind 2]`
210        // or just: `main` (no tracking)
211        let (branch_part, tracking_part) = if let Some(idx) = branch_line.find("...") {
212            (&branch_line[..idx], Some(&branch_line[idx + 3..]))
213        } else {
214            (branch_line, None)
215        };
216        branch = branch_part.to_string();
217
218        if let Some(tracking) = tracking_part {
219            // Parse optional `[ahead N, behind M]` suffix.
220            if let Some(bracket_start) = tracking.find('[') {
221                let inside = &tracking[bracket_start + 1..];
222                let inside = inside.trim_end_matches(']');
223                for part in inside.split(',') {
224                    let part = part.trim();
225                    if let Some(n) = part.strip_prefix("ahead ") {
226                        ahead = n.trim().parse().unwrap_or(0);
227                    } else if let Some(n) = part.strip_prefix("behind ") {
228                        behind = n.trim().parse().unwrap_or(0);
229                    }
230                }
231            }
232        }
233    }
234
235    // ── File lines ───────────────────────────────────────────────────────────
236    let mut staged: Vec<FileStatus> = Vec::new();
237    let mut unstaged: Vec<FileStatus> = Vec::new();
238    let mut untracked: Vec<String> = Vec::new();
239
240    for line in lines {
241        if line.len() < 4 {
242            continue;
243        }
244        let x = line.chars().next().unwrap_or(' ');
245        let y = line.chars().nth(1).unwrap_or(' ');
246        let rest = &line[3..]; // skip "XY "
247
248        if x == '?' && y == '?' {
249            untracked.push(rest.to_string());
250            continue;
251        }
252
253        let (staged_change, unstaged_change) = parse_xy(x, y);
254
255        // Renamed / Copied entries in porcelain v1 look like:
256        //   `R  new_path\0old_path` but porcelain v1 uses `->` separator.
257        //   `R  newpath -> oldpath`
258        // Actually porcelain=v1 uses: `R  dest\toriginal` on a single line
259        // separated by `\0` in -z mode. Without -z it is `dest -> origin`.
260        let (path, old_path) = if (x == 'R' || x == 'C' || y == 'R' || y == 'C')
261            && rest.contains(" -> ")
262        {
263            let mut parts = rest.splitn(2, " -> ");
264            let dest = parts.next().unwrap_or(rest).to_string();
265            let orig = parts.next().map(str::to_string);
266            (dest, orig)
267        } else {
268            (rest.to_string(), None)
269        };
270
271        if let Some(ct) = staged_change {
272            staged.push(FileStatus {
273                path: path.clone(),
274                change_type: ct,
275                old_path: old_path.clone(),
276            });
277        }
278        if let Some(ct) = unstaged_change {
279            unstaged.push(FileStatus {
280                path: path.clone(),
281                change_type: ct,
282                old_path,
283            });
284        }
285    }
286
287    let is_clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
288
289    Ok(StatusReport {
290        branch,
291        ahead,
292        behind,
293        staged,
294        unstaged,
295        untracked,
296        is_clean,
297    })
298}
299
300/// Return the commit log for `repo`, up to `limit` entries (capped at 100).
301pub async fn git_log(repo: &Path, limit: u32) -> Result<Vec<CommitEntry>, GitError> {
302    let cap = limit.min(100);
303    let cap_str = cap.to_string();
304    let format = "%H\x1F%h\x1F%s\x1F%an\x1F%aI\x1F%ar";
305    let raw = match git_run(
306        &["log", &format!("--format={format}"), "-n", &cap_str],
307        repo,
308    )
309    .await
310    {
311        Ok(v) => v,
312        Err(GitError::BackendFailed { ref stderr, .. }) if stderr.contains("does not have any commits") || stderr.contains("bad default revision") || stderr.contains("fatal: your current branch") => {
313            return Ok(Vec::new());
314        }
315        Err(e) => return Err(e),
316    };
317
318    if raw.trim().is_empty() {
319        return Ok(Vec::new());
320    }
321
322    let mut entries = Vec::new();
323    for line in raw.lines() {
324        let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
325        if parts.len() < 6 {
326            continue;
327        }
328        entries.push(CommitEntry {
329            hash: parts[0].to_string(),
330            short_hash: parts[1].to_string(),
331            message: parts[2].to_string(),
332            author: parts[3].to_string(),
333            date: parts[4].to_string(),
334            date_relative: parts[5].to_string(),
335        });
336    }
337    Ok(entries)
338}
339
340/// Metadata + changed files for one commit, resolved by SHA. Backs the
341/// `_prov/{commit_sha}` provenance resolver (master-plan §2.4).
342#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase")]
344pub struct ResolvedCommit {
345    /// Full 40-char commit SHA (canonicalised from the requested rev).
346    pub hash: String,
347    /// Commit author email — the writer's `did:nostr` (the git-mark binds the
348    /// authenticated agent to commit author identity, see `mark.rs`).
349    pub author_email: String,
350    /// Commit author name (the committer label, e.g. `solid-pod-rs`).
351    pub author_name: String,
352    /// Commit subject (the LDP method + path the write recorded).
353    pub subject: String,
354    /// Author commit time, Unix seconds.
355    pub committed_at: u64,
356    /// Parent commit SHA (the append-only chain link), or `None` for the
357    /// genesis commit.
358    pub parent: Option<String>,
359    /// Repo-relative paths the commit touched (sidecars are caller-filtered).
360    pub files: Vec<String>,
361}
362
363/// Resolve a commit `sha` (any rev git accepts) to its metadata + changed
364/// files. Returns [`GitError::NotARepository`] mapped from a bad-revision
365/// failure so the caller can surface a 404 for an unknown commit.
366///
367/// Shells `git show --no-patch` (metadata) + `git show --name-only` (files),
368/// mirroring the other `api` operations. Used by the `_prov/{commit_sha}`
369/// route to map a git-mark back to its resource + [`ProvenanceMark`].
370pub async fn resolve_commit(repo: &Path, sha: &str) -> Result<ResolvedCommit, GitError> {
371    // Reject obviously-malformed revs early (defence-in-depth; the route also
372    // validates). A commit-ish is hex; refuse anything with shell/path metachars.
373    if sha.is_empty()
374        || sha.len() > 64
375        || !sha.bytes().all(|b| b.is_ascii_hexdigit())
376    {
377        return Err(GitError::PathTraversal(format!("invalid commit id: {sha}")));
378    }
379
380    // Metadata: full-hash, author-email, author-name, subject, author-unixtime,
381    // parent-hashes — unit-separated, one record.
382    let fmt = "%H\x1F%ae\x1F%an\x1F%s\x1F%at\x1F%P";
383    let meta = match git_run(&["show", "--no-patch", &format!("--format={fmt}"), sha], repo).await {
384        Ok(v) => v,
385        Err(GitError::BackendFailed { ref stderr, .. })
386            if stderr.contains("unknown revision")
387                || stderr.contains("bad revision")
388                || stderr.contains("bad object")
389                || stderr.contains("ambiguous argument")
390                || stderr.contains("does not have any commits") =>
391        {
392            return Err(GitError::NotARepository(format!("unknown commit {sha}")));
393        }
394        Err(e) => return Err(e),
395    };
396    let line = meta.lines().next().unwrap_or("");
397    let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
398    if parts.len() < 5 {
399        return Err(GitError::MalformedCgi(format!("git show metadata for {sha}")));
400    }
401    let committed_at = parts[4].trim().parse::<u64>().unwrap_or(0);
402    let parent = parts
403        .get(5)
404        .map(|s| s.trim())
405        .filter(|s| !s.is_empty())
406        // `%P` lists all parents space-separated; the first is the chain link.
407        .and_then(|s| s.split_whitespace().next())
408        .map(str::to_string);
409
410    // Changed files: `--name-only` with an empty format prints only paths.
411    let files_raw = git_run(&["show", "--name-only", "--format=", sha], repo)
412        .await
413        .unwrap_or_default();
414    let files: Vec<String> = files_raw
415        .lines()
416        .map(str::trim)
417        .filter(|l| !l.is_empty())
418        .map(str::to_string)
419        .collect();
420
421    Ok(ResolvedCommit {
422        hash: parts[0].to_string(),
423        author_email: parts[1].to_string(),
424        author_name: parts[2].to_string(),
425        subject: parts[3].to_string(),
426        committed_at,
427        parent,
428        files,
429    })
430}
431
432/// Return a unified diff for the repository or a specific file.
433///
434/// - `staged = true` produces `git diff --cached` (index vs HEAD).
435/// - `staged = false` produces `git diff` (working tree vs index).
436/// - `path` restricts the diff to a single file.
437pub async fn git_diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String, GitError> {
438    if let Some(p) = path {
439        validate_path(p)?;
440    }
441
442    let mut args: Vec<&str> = vec!["diff", "-U5"];
443    if staged {
444        args.push("--cached");
445    }
446    if let Some(p) = path {
447        args.push("--");
448        args.push(p);
449    }
450
451    // `git diff` exits 0 even when there are no differences.
452    git_run(&args, repo).await
453}
454
455/// Stage files. If `all` is true, runs `git add -A`. Otherwise stages
456/// only the listed `paths`.
457pub async fn git_add(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
458    if all {
459        git_run(&["add", "-A"], repo).await?;
460    } else {
461        for p in paths {
462            validate_path(p)?;
463        }
464        let mut args = vec!["add", "--"];
465        let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
466        args.extend_from_slice(&path_refs);
467        git_run(&args, repo).await?;
468    }
469    Ok(())
470}
471
472/// Unstage files. If `all` is true, unstages everything. Otherwise
473/// unstages only the listed `paths`.
474///
475/// `git reset HEAD` can exit 1 on repositories with no commits yet;
476/// this is handled gracefully.
477pub async fn git_unstage(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
478    if all {
479        git_run_tolerant(&["reset", "HEAD", "--", "."], repo).await?;
480    } else {
481        for p in paths {
482            validate_path(p)?;
483        }
484        let mut args = vec!["reset", "HEAD", "--"];
485        let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
486        args.extend_from_slice(&path_refs);
487        git_run_tolerant(&args, repo).await?;
488    }
489    Ok(())
490}
491
492/// Create a commit with the given message. Returns the new commit hash and
493/// a one-line summary.
494pub async fn git_commit(
495    repo: &Path,
496    message: &str,
497    author_name: &str,
498    author_email: &str,
499) -> Result<CommitResult, GitError> {
500    let author_str = format!("{author_name} <{author_email}>");
501    git_run(
502        &["commit", "-m", message, "--author", &author_str],
503        repo,
504    )
505    .await?;
506
507    let hash = git_run(&["rev-parse", "HEAD"], repo).await?;
508    let hash = hash.trim().to_string();
509    let short_hash = if hash.len() >= 7 {
510        hash[..7].to_string()
511    } else {
512        hash.clone()
513    };
514
515    // Extract the commit subject for the summary.
516    let summary = git_run(&["log", "-1", "--format=%s", &hash], repo)
517        .await
518        .unwrap_or_else(|_| message.to_string());
519    let summary = summary.trim().to_string();
520
521    Ok(CommitResult {
522        hash,
523        short_hash,
524        summary,
525    })
526}
527
528/// Return the list of local branches and identify the current one.
529pub async fn git_branches(repo: &Path) -> Result<BranchInfo, GitError> {
530    let raw = match git_run(
531        &["branch", "--format=%(HEAD) %(refname:short)"],
532        repo,
533    )
534    .await
535    {
536        Ok(v) => v,
537        Err(GitError::BackendFailed { ref stderr, .. })
538            if stderr.contains("does not have any commits")
539                || stderr.contains("bad default revision") =>
540        {
541            return Ok(BranchInfo {
542                current: "main".to_string(),
543                local: vec![],
544            });
545        }
546        Err(e) => return Err(e),
547    };
548
549    if raw.trim().is_empty() {
550        return Ok(BranchInfo {
551            current: "main".to_string(),
552            local: vec![],
553        });
554    }
555
556    let mut current = String::from("main");
557    let mut local: Vec<String> = Vec::new();
558
559    for line in raw.lines() {
560        // Format: `* main` or `  feature-x`
561        let is_current = line.starts_with("* ");
562        let name = line.trim_start_matches("* ").trim_start_matches("  ").trim();
563        if name.is_empty() {
564            continue;
565        }
566        if is_current {
567            current = name.to_string();
568        }
569        local.push(name.to_string());
570    }
571
572    Ok(BranchInfo { current, local })
573}
574
575/// Create and switch to a new branch called `name`.
576pub async fn git_create_branch(repo: &Path, name: &str) -> Result<(), GitError> {
577    // Validate branch name: no `..`, no spaces, must not start with `-`.
578    if name.contains("..") || name.contains(' ') || name.starts_with('-') {
579        return Err(GitError::PathTraversal(format!(
580            "invalid branch name: {name}"
581        )));
582    }
583    git_run(&["checkout", "-b", name], repo).await?;
584    Ok(())
585}
586
587/// Discard working-tree changes to the listed `paths` (equivalent to
588/// `git checkout -- <paths>`).
589pub async fn git_discard(repo: &Path, paths: &[String]) -> Result<(), GitError> {
590    for p in paths {
591        validate_path(p)?;
592    }
593    let mut args = vec!["checkout", "--"];
594    let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
595    args.extend_from_slice(&path_refs);
596    git_run(&args, repo).await?;
597    Ok(())
598}
599
600// ---------------------------------------------------------------------------
601// Tests
602// ---------------------------------------------------------------------------
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    fn make_status(raw: &str) -> StatusReport {
609        parse_status_output(raw).expect("parse failed")
610    }
611
612    #[test]
613    fn parse_clean_repo() {
614        let raw = "## main...origin/main\n";
615        let s = make_status(raw);
616        assert_eq!(s.branch, "main");
617        assert_eq!(s.ahead, 0);
618        assert_eq!(s.behind, 0);
619        assert!(s.is_clean);
620        assert!(s.staged.is_empty());
621        assert!(s.unstaged.is_empty());
622        assert!(s.untracked.is_empty());
623    }
624
625    #[test]
626    fn parse_ahead_behind() {
627        let raw = "## main...origin/main [ahead 3, behind 1]\n";
628        let s = make_status(raw);
629        assert_eq!(s.branch, "main");
630        assert_eq!(s.ahead, 3);
631        assert_eq!(s.behind, 1);
632    }
633
634    #[test]
635    fn parse_ahead_only() {
636        let raw = "## feature...origin/feature [ahead 2]\n";
637        let s = make_status(raw);
638        assert_eq!(s.branch, "feature");
639        assert_eq!(s.ahead, 2);
640        assert_eq!(s.behind, 0);
641    }
642
643    #[test]
644    fn parse_no_commits_yet() {
645        let raw = "## No commits yet on main\n";
646        let s = make_status(raw);
647        assert_eq!(s.branch, "main");
648        assert_eq!(s.ahead, 0);
649        assert_eq!(s.behind, 0);
650        assert!(s.is_clean);
651    }
652
653    #[test]
654    fn parse_no_commits_yet_with_staged() {
655        let raw = "## No commits yet on main\nA  README.md\n";
656        let s = make_status(raw);
657        assert_eq!(s.branch, "main");
658        assert_eq!(s.staged.len(), 1);
659        assert_eq!(s.staged[0].change_type, ChangeType::Added);
660        assert_eq!(s.staged[0].path, "README.md");
661        assert!(!s.is_clean);
662    }
663
664    #[test]
665    fn parse_modified_staged_and_unstaged() {
666        // X=M (staged modified), Y=M (unstaged modified)
667        let raw = "## main\nMM src/lib.rs\n";
668        let s = make_status(raw);
669        assert_eq!(s.staged.len(), 1);
670        assert_eq!(s.staged[0].change_type, ChangeType::Modified);
671        assert_eq!(s.unstaged.len(), 1);
672        assert_eq!(s.unstaged[0].change_type, ChangeType::Modified);
673    }
674
675    #[test]
676    fn parse_untracked() {
677        let raw = "## main\n?? newfile.txt\n";
678        let s = make_status(raw);
679        assert_eq!(s.untracked, vec!["newfile.txt"]);
680        assert!(!s.is_clean);
681    }
682
683    #[test]
684    fn parse_deleted_staged() {
685        let raw = "## main\nD  old.txt\n";
686        let s = make_status(raw);
687        assert_eq!(s.staged.len(), 1);
688        assert_eq!(s.staged[0].change_type, ChangeType::Deleted);
689        assert_eq!(s.staged[0].path, "old.txt");
690    }
691
692    #[test]
693    fn parse_renamed_staged() {
694        let raw = "## main\nR  new.txt -> old.txt\n";
695        let s = make_status(raw);
696        assert_eq!(s.staged.len(), 1);
697        assert_eq!(s.staged[0].change_type, ChangeType::Renamed);
698        assert_eq!(s.staged[0].path, "new.txt");
699        assert_eq!(s.staged[0].old_path.as_deref(), Some("old.txt"));
700    }
701
702    #[test]
703    fn parse_branch_no_tracking() {
704        let raw = "## detached-head\nM  foo.rs\n";
705        let s = make_status(raw);
706        assert_eq!(s.branch, "detached-head");
707        assert_eq!(s.ahead, 0);
708        assert_eq!(s.behind, 0);
709    }
710
711    #[test]
712    fn validate_path_rejects_dotdot() {
713        assert!(validate_path("../etc/passwd").is_err());
714        assert!(validate_path("foo/../../bar").is_err());
715    }
716
717    #[test]
718    fn validate_path_rejects_absolute() {
719        assert!(validate_path("/etc/passwd").is_err());
720    }
721
722    #[test]
723    fn validate_path_accepts_normal() {
724        assert!(validate_path("src/lib.rs").is_ok());
725        assert!(validate_path("README.md").is_ok());
726    }
727
728    #[tokio::test]
729    async fn resolve_commit_rejects_malformed_rev() {
730        // Defence-in-depth: a non-hex / over-long / empty rev is rejected
731        // before ever shelling to git (no path/shell metachars reach `git`).
732        let repo = std::path::Path::new("/nonexistent");
733        for bad in ["", "../etc", "deadbeef; rm -rf /", &"a".repeat(65), "g00dbeef"] {
734            assert!(
735                matches!(
736                    resolve_commit(repo, bad).await,
737                    Err(GitError::PathTraversal(_))
738                ),
739                "malformed rev {bad:?} must be rejected pre-git"
740            );
741        }
742    }
743}