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) =
261            if (x == 'R' || x == 'C' || y == 'R' || y == 'C') && rest.contains(" -> ") {
262                let mut parts = rest.splitn(2, " -> ");
263                let dest = parts.next().unwrap_or(rest).to_string();
264                let orig = parts.next().map(str::to_string);
265                (dest, orig)
266            } else {
267                (rest.to_string(), None)
268            };
269
270        if let Some(ct) = staged_change {
271            staged.push(FileStatus {
272                path: path.clone(),
273                change_type: ct,
274                old_path: old_path.clone(),
275            });
276        }
277        if let Some(ct) = unstaged_change {
278            unstaged.push(FileStatus {
279                path: path.clone(),
280                change_type: ct,
281                old_path,
282            });
283        }
284    }
285
286    let is_clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
287
288    Ok(StatusReport {
289        branch,
290        ahead,
291        behind,
292        staged,
293        unstaged,
294        untracked,
295        is_clean,
296    })
297}
298
299/// Return the commit log for `repo`, up to `limit` entries (capped at 100).
300pub async fn git_log(repo: &Path, limit: u32) -> Result<Vec<CommitEntry>, GitError> {
301    let cap = limit.min(100);
302    let cap_str = cap.to_string();
303    let format = "%H\x1F%h\x1F%s\x1F%an\x1F%aI\x1F%ar";
304    let raw = match git_run(
305        &["log", &format!("--format={format}"), "-n", &cap_str],
306        repo,
307    )
308    .await
309    {
310        Ok(v) => v,
311        Err(GitError::BackendFailed { ref stderr, .. })
312            if stderr.contains("does not have any commits")
313                || stderr.contains("bad default revision")
314                || stderr.contains("fatal: your current branch") =>
315        {
316            return Ok(Vec::new());
317        }
318        Err(e) => return Err(e),
319    };
320
321    if raw.trim().is_empty() {
322        return Ok(Vec::new());
323    }
324
325    let mut entries = Vec::new();
326    for line in raw.lines() {
327        let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
328        if parts.len() < 6 {
329            continue;
330        }
331        entries.push(CommitEntry {
332            hash: parts[0].to_string(),
333            short_hash: parts[1].to_string(),
334            message: parts[2].to_string(),
335            author: parts[3].to_string(),
336            date: parts[4].to_string(),
337            date_relative: parts[5].to_string(),
338        });
339    }
340    Ok(entries)
341}
342
343/// Metadata + changed files for one commit, resolved by SHA. Backs the
344/// `_prov/{commit_sha}` provenance resolver (master-plan §2.4).
345#[derive(Debug, Clone, Serialize, Deserialize)]
346#[serde(rename_all = "camelCase")]
347pub struct ResolvedCommit {
348    /// Full 40-char commit SHA (canonicalised from the requested rev).
349    pub hash: String,
350    /// Commit author email — the writer's `did:nostr` (the git-mark binds the
351    /// authenticated agent to commit author identity, see `mark.rs`).
352    pub author_email: String,
353    /// Commit author name (the committer label, e.g. `solid-pod-rs`).
354    pub author_name: String,
355    /// Commit subject (the LDP method + path the write recorded).
356    pub subject: String,
357    /// Author commit time, Unix seconds.
358    pub committed_at: u64,
359    /// Parent commit SHA (the append-only chain link), or `None` for the
360    /// genesis commit.
361    pub parent: Option<String>,
362    /// Repo-relative paths the commit touched (sidecars are caller-filtered).
363    pub files: Vec<String>,
364}
365
366/// Resolve a commit `sha` (any rev git accepts) to its metadata + changed
367/// files. Returns [`GitError::NotARepository`] mapped from a bad-revision
368/// failure so the caller can surface a 404 for an unknown commit.
369///
370/// Shells `git show --no-patch` (metadata) + `git show --name-only` (files),
371/// mirroring the other `api` operations. Used by the `_prov/{commit_sha}`
372/// route to map a git-mark back to its resource + [`ProvenanceMark`].
373pub async fn resolve_commit(repo: &Path, sha: &str) -> Result<ResolvedCommit, GitError> {
374    // Reject obviously-malformed revs early (defence-in-depth; the route also
375    // validates). A commit-ish is hex; refuse anything with shell/path metachars.
376    if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
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(
384        &["show", "--no-patch", &format!("--format={fmt}"), sha],
385        repo,
386    )
387    .await
388    {
389        Ok(v) => v,
390        Err(GitError::BackendFailed { ref stderr, .. })
391            if stderr.contains("unknown revision")
392                || stderr.contains("bad revision")
393                || stderr.contains("bad object")
394                || stderr.contains("ambiguous argument")
395                || stderr.contains("does not have any commits") =>
396        {
397            return Err(GitError::NotARepository(format!("unknown commit {sha}")));
398        }
399        Err(e) => return Err(e),
400    };
401    let line = meta.lines().next().unwrap_or("");
402    let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
403    if parts.len() < 5 {
404        return Err(GitError::MalformedCgi(format!(
405            "git show metadata for {sha}"
406        )));
407    }
408    let committed_at = parts[4].trim().parse::<u64>().unwrap_or(0);
409    let parent = parts
410        .get(5)
411        .map(|s| s.trim())
412        .filter(|s| !s.is_empty())
413        // `%P` lists all parents space-separated; the first is the chain link.
414        .and_then(|s| s.split_whitespace().next())
415        .map(str::to_string);
416
417    // Changed files: `--name-only` with an empty format prints only paths.
418    let files_raw = git_run(&["show", "--name-only", "--format=", sha], repo)
419        .await
420        .unwrap_or_default();
421    let files: Vec<String> = files_raw
422        .lines()
423        .map(str::trim)
424        .filter(|l| !l.is_empty())
425        .map(str::to_string)
426        .collect();
427
428    Ok(ResolvedCommit {
429        hash: parts[0].to_string(),
430        author_email: parts[1].to_string(),
431        author_name: parts[2].to_string(),
432        subject: parts[3].to_string(),
433        committed_at,
434        parent,
435        files,
436    })
437}
438
439/// Return a unified diff for the repository or a specific file.
440///
441/// - `staged = true` produces `git diff --cached` (index vs HEAD).
442/// - `staged = false` produces `git diff` (working tree vs index).
443/// - `path` restricts the diff to a single file.
444pub async fn git_diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String, GitError> {
445    if let Some(p) = path {
446        validate_path(p)?;
447    }
448
449    let mut args: Vec<&str> = vec!["diff", "-U5"];
450    if staged {
451        args.push("--cached");
452    }
453    if let Some(p) = path {
454        args.push("--");
455        args.push(p);
456    }
457
458    // `git diff` exits 0 even when there are no differences.
459    git_run(&args, repo).await
460}
461
462/// Stage files. If `all` is true, runs `git add -A`. Otherwise stages
463/// only the listed `paths`.
464pub async fn git_add(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
465    if all {
466        git_run(&["add", "-A"], repo).await?;
467    } else {
468        for p in paths {
469            validate_path(p)?;
470        }
471        let mut args = vec!["add", "--"];
472        let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
473        args.extend_from_slice(&path_refs);
474        git_run(&args, repo).await?;
475    }
476    Ok(())
477}
478
479/// Unstage files. If `all` is true, unstages everything. Otherwise
480/// unstages only the listed `paths`.
481///
482/// `git reset HEAD` can exit 1 on repositories with no commits yet;
483/// this is handled gracefully.
484pub async fn git_unstage(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
485    if all {
486        git_run_tolerant(&["reset", "HEAD", "--", "."], repo).await?;
487    } else {
488        for p in paths {
489            validate_path(p)?;
490        }
491        let mut args = vec!["reset", "HEAD", "--"];
492        let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
493        args.extend_from_slice(&path_refs);
494        git_run_tolerant(&args, repo).await?;
495    }
496    Ok(())
497}
498
499/// Create a commit with the given message. Returns the new commit hash and
500/// a one-line summary.
501pub async fn git_commit(
502    repo: &Path,
503    message: &str,
504    author_name: &str,
505    author_email: &str,
506) -> Result<CommitResult, GitError> {
507    let author_str = format!("{author_name} <{author_email}>");
508    git_run(&["commit", "-m", message, "--author", &author_str], repo).await?;
509
510    let hash = git_run(&["rev-parse", "HEAD"], repo).await?;
511    let hash = hash.trim().to_string();
512    let short_hash = if hash.len() >= 7 {
513        hash[..7].to_string()
514    } else {
515        hash.clone()
516    };
517
518    // Extract the commit subject for the summary.
519    let summary = git_run(&["log", "-1", "--format=%s", &hash], repo)
520        .await
521        .unwrap_or_else(|_| message.to_string());
522    let summary = summary.trim().to_string();
523
524    Ok(CommitResult {
525        hash,
526        short_hash,
527        summary,
528    })
529}
530
531/// Return the list of local branches and identify the current one.
532pub async fn git_branches(repo: &Path) -> Result<BranchInfo, GitError> {
533    let raw = match git_run(&["branch", "--format=%(HEAD) %(refname:short)"], repo).await {
534        Ok(v) => v,
535        Err(GitError::BackendFailed { ref stderr, .. })
536            if stderr.contains("does not have any commits")
537                || stderr.contains("bad default revision") =>
538        {
539            return Ok(BranchInfo {
540                current: "main".to_string(),
541                local: vec![],
542            });
543        }
544        Err(e) => return Err(e),
545    };
546
547    if raw.trim().is_empty() {
548        return Ok(BranchInfo {
549            current: "main".to_string(),
550            local: vec![],
551        });
552    }
553
554    let mut current = String::from("main");
555    let mut local: Vec<String> = Vec::new();
556
557    for line in raw.lines() {
558        // Format: `* main` or `  feature-x`
559        let is_current = line.starts_with("* ");
560        let name = line
561            .trim_start_matches("* ")
562            .trim_start_matches("  ")
563            .trim();
564        if name.is_empty() {
565            continue;
566        }
567        if is_current {
568            current = name.to_string();
569        }
570        local.push(name.to_string());
571    }
572
573    Ok(BranchInfo { current, local })
574}
575
576/// Create and switch to a new branch called `name`.
577pub async fn git_create_branch(repo: &Path, name: &str) -> Result<(), GitError> {
578    // Validate branch name: no `..`, no spaces, must not start with `-`.
579    if name.contains("..") || name.contains(' ') || name.starts_with('-') {
580        return Err(GitError::PathTraversal(format!(
581            "invalid branch name: {name}"
582        )));
583    }
584    git_run(&["checkout", "-b", name], repo).await?;
585    Ok(())
586}
587
588/// Discard working-tree changes to the listed `paths` (equivalent to
589/// `git checkout -- <paths>`).
590pub async fn git_discard(repo: &Path, paths: &[String]) -> Result<(), GitError> {
591    for p in paths {
592        validate_path(p)?;
593    }
594    let mut args = vec!["checkout", "--"];
595    let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
596    args.extend_from_slice(&path_refs);
597    git_run(&args, repo).await?;
598    Ok(())
599}
600
601// ---------------------------------------------------------------------------
602// Tests
603// ---------------------------------------------------------------------------
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    fn make_status(raw: &str) -> StatusReport {
610        parse_status_output(raw).expect("parse failed")
611    }
612
613    #[test]
614    fn parse_clean_repo() {
615        let raw = "## main...origin/main\n";
616        let s = make_status(raw);
617        assert_eq!(s.branch, "main");
618        assert_eq!(s.ahead, 0);
619        assert_eq!(s.behind, 0);
620        assert!(s.is_clean);
621        assert!(s.staged.is_empty());
622        assert!(s.unstaged.is_empty());
623        assert!(s.untracked.is_empty());
624    }
625
626    #[test]
627    fn parse_ahead_behind() {
628        let raw = "## main...origin/main [ahead 3, behind 1]\n";
629        let s = make_status(raw);
630        assert_eq!(s.branch, "main");
631        assert_eq!(s.ahead, 3);
632        assert_eq!(s.behind, 1);
633    }
634
635    #[test]
636    fn parse_ahead_only() {
637        let raw = "## feature...origin/feature [ahead 2]\n";
638        let s = make_status(raw);
639        assert_eq!(s.branch, "feature");
640        assert_eq!(s.ahead, 2);
641        assert_eq!(s.behind, 0);
642    }
643
644    #[test]
645    fn parse_no_commits_yet() {
646        let raw = "## No commits yet on main\n";
647        let s = make_status(raw);
648        assert_eq!(s.branch, "main");
649        assert_eq!(s.ahead, 0);
650        assert_eq!(s.behind, 0);
651        assert!(s.is_clean);
652    }
653
654    #[test]
655    fn parse_no_commits_yet_with_staged() {
656        let raw = "## No commits yet on main\nA  README.md\n";
657        let s = make_status(raw);
658        assert_eq!(s.branch, "main");
659        assert_eq!(s.staged.len(), 1);
660        assert_eq!(s.staged[0].change_type, ChangeType::Added);
661        assert_eq!(s.staged[0].path, "README.md");
662        assert!(!s.is_clean);
663    }
664
665    #[test]
666    fn parse_modified_staged_and_unstaged() {
667        // X=M (staged modified), Y=M (unstaged modified)
668        let raw = "## main\nMM src/lib.rs\n";
669        let s = make_status(raw);
670        assert_eq!(s.staged.len(), 1);
671        assert_eq!(s.staged[0].change_type, ChangeType::Modified);
672        assert_eq!(s.unstaged.len(), 1);
673        assert_eq!(s.unstaged[0].change_type, ChangeType::Modified);
674    }
675
676    #[test]
677    fn parse_untracked() {
678        let raw = "## main\n?? newfile.txt\n";
679        let s = make_status(raw);
680        assert_eq!(s.untracked, vec!["newfile.txt"]);
681        assert!(!s.is_clean);
682    }
683
684    #[test]
685    fn parse_deleted_staged() {
686        let raw = "## main\nD  old.txt\n";
687        let s = make_status(raw);
688        assert_eq!(s.staged.len(), 1);
689        assert_eq!(s.staged[0].change_type, ChangeType::Deleted);
690        assert_eq!(s.staged[0].path, "old.txt");
691    }
692
693    #[test]
694    fn parse_renamed_staged() {
695        let raw = "## main\nR  new.txt -> old.txt\n";
696        let s = make_status(raw);
697        assert_eq!(s.staged.len(), 1);
698        assert_eq!(s.staged[0].change_type, ChangeType::Renamed);
699        assert_eq!(s.staged[0].path, "new.txt");
700        assert_eq!(s.staged[0].old_path.as_deref(), Some("old.txt"));
701    }
702
703    #[test]
704    fn parse_branch_no_tracking() {
705        let raw = "## detached-head\nM  foo.rs\n";
706        let s = make_status(raw);
707        assert_eq!(s.branch, "detached-head");
708        assert_eq!(s.ahead, 0);
709        assert_eq!(s.behind, 0);
710    }
711
712    #[test]
713    fn validate_path_rejects_dotdot() {
714        assert!(validate_path("../etc/passwd").is_err());
715        assert!(validate_path("foo/../../bar").is_err());
716    }
717
718    #[test]
719    fn validate_path_rejects_absolute() {
720        assert!(validate_path("/etc/passwd").is_err());
721    }
722
723    #[test]
724    fn validate_path_accepts_normal() {
725        assert!(validate_path("src/lib.rs").is_ok());
726        assert!(validate_path("README.md").is_ok());
727    }
728
729    #[tokio::test]
730    async fn resolve_commit_rejects_malformed_rev() {
731        // Defence-in-depth: a non-hex / over-long / empty rev is rejected
732        // before ever shelling to git (no path/shell metachars reach `git`).
733        let repo = std::path::Path::new("/nonexistent");
734        for bad in [
735            "",
736            "../etc",
737            "deadbeef; rm -rf /",
738            &"a".repeat(65),
739            "g00dbeef",
740        ] {
741            assert!(
742                matches!(
743                    resolve_commit(repo, bad).await,
744                    Err(GitError::PathTraversal(_))
745                ),
746                "malformed rev {bad:?} must be rejected pre-git"
747            );
748        }
749    }
750}