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 +
373/// [`ProvenanceMark`](solid_pod_rs::provenance::ProvenanceMark).
374pub async fn resolve_commit(repo: &Path, sha: &str) -> Result<ResolvedCommit, GitError> {
375    // Reject obviously-malformed revs early (defence-in-depth; the route also
376    // validates). A commit-ish is hex; refuse anything with shell/path metachars.
377    if sha.is_empty() || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
378        return Err(GitError::PathTraversal(format!("invalid commit id: {sha}")));
379    }
380
381    // Metadata: full-hash, author-email, author-name, subject, author-unixtime,
382    // parent-hashes — unit-separated, one record.
383    let fmt = "%H\x1F%ae\x1F%an\x1F%s\x1F%at\x1F%P";
384    let meta = match git_run(
385        &["show", "--no-patch", &format!("--format={fmt}"), sha],
386        repo,
387    )
388    .await
389    {
390        Ok(v) => v,
391        Err(GitError::BackendFailed { ref stderr, .. })
392            if stderr.contains("unknown revision")
393                || stderr.contains("bad revision")
394                || stderr.contains("bad object")
395                || stderr.contains("ambiguous argument")
396                || stderr.contains("does not have any commits") =>
397        {
398            return Err(GitError::NotARepository(format!("unknown commit {sha}")));
399        }
400        Err(e) => return Err(e),
401    };
402    let line = meta.lines().next().unwrap_or("");
403    let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
404    if parts.len() < 5 {
405        return Err(GitError::MalformedCgi(format!(
406            "git show metadata for {sha}"
407        )));
408    }
409    let committed_at = parts[4].trim().parse::<u64>().unwrap_or(0);
410    let parent = parts
411        .get(5)
412        .map(|s| s.trim())
413        .filter(|s| !s.is_empty())
414        // `%P` lists all parents space-separated; the first is the chain link.
415        .and_then(|s| s.split_whitespace().next())
416        .map(str::to_string);
417
418    // Changed files: `--name-only` with an empty format prints only paths.
419    let files_raw = git_run(&["show", "--name-only", "--format=", sha], repo)
420        .await
421        .unwrap_or_default();
422    let files: Vec<String> = files_raw
423        .lines()
424        .map(str::trim)
425        .filter(|l| !l.is_empty())
426        .map(str::to_string)
427        .collect();
428
429    Ok(ResolvedCommit {
430        hash: parts[0].to_string(),
431        author_email: parts[1].to_string(),
432        author_name: parts[2].to_string(),
433        subject: parts[3].to_string(),
434        committed_at,
435        parent,
436        files,
437    })
438}
439
440/// Return a unified diff for the repository or a specific file.
441///
442/// - `staged = true` produces `git diff --cached` (index vs HEAD).
443/// - `staged = false` produces `git diff` (working tree vs index).
444/// - `path` restricts the diff to a single file.
445pub async fn git_diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String, GitError> {
446    if let Some(p) = path {
447        validate_path(p)?;
448    }
449
450    let mut args: Vec<&str> = vec!["diff", "-U5"];
451    if staged {
452        args.push("--cached");
453    }
454    if let Some(p) = path {
455        args.push("--");
456        args.push(p);
457    }
458
459    // `git diff` exits 0 even when there are no differences.
460    git_run(&args, repo).await
461}
462
463/// Stage files. If `all` is true, runs `git add -A`. Otherwise stages
464/// only the listed `paths`.
465pub async fn git_add(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
466    if all {
467        git_run(&["add", "-A"], repo).await?;
468    } else {
469        for p in paths {
470            validate_path(p)?;
471        }
472        let mut args = vec!["add", "--"];
473        let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
474        args.extend_from_slice(&path_refs);
475        git_run(&args, repo).await?;
476    }
477    Ok(())
478}
479
480/// Unstage files. If `all` is true, unstages everything. Otherwise
481/// unstages only the listed `paths`.
482///
483/// `git reset HEAD` can exit 1 on repositories with no commits yet;
484/// this is handled gracefully.
485pub async fn git_unstage(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
486    if all {
487        git_run_tolerant(&["reset", "HEAD", "--", "."], repo).await?;
488    } else {
489        for p in paths {
490            validate_path(p)?;
491        }
492        let mut args = vec!["reset", "HEAD", "--"];
493        let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
494        args.extend_from_slice(&path_refs);
495        git_run_tolerant(&args, repo).await?;
496    }
497    Ok(())
498}
499
500/// Create a commit with the given message. Returns the new commit hash and
501/// a one-line summary.
502pub async fn git_commit(
503    repo: &Path,
504    message: &str,
505    author_name: &str,
506    author_email: &str,
507) -> Result<CommitResult, GitError> {
508    let author_str = format!("{author_name} <{author_email}>");
509    git_run(&["commit", "-m", message, "--author", &author_str], repo).await?;
510
511    let hash = git_run(&["rev-parse", "HEAD"], repo).await?;
512    let hash = hash.trim().to_string();
513    let short_hash = if hash.len() >= 7 {
514        hash[..7].to_string()
515    } else {
516        hash.clone()
517    };
518
519    // Extract the commit subject for the summary.
520    let summary = git_run(&["log", "-1", "--format=%s", &hash], repo)
521        .await
522        .unwrap_or_else(|_| message.to_string());
523    let summary = summary.trim().to_string();
524
525    Ok(CommitResult {
526        hash,
527        short_hash,
528        summary,
529    })
530}
531
532/// Return the list of local branches and identify the current one.
533pub async fn git_branches(repo: &Path) -> Result<BranchInfo, GitError> {
534    let raw = match git_run(&["branch", "--format=%(HEAD) %(refname:short)"], repo).await {
535        Ok(v) => v,
536        Err(GitError::BackendFailed { ref stderr, .. })
537            if stderr.contains("does not have any commits")
538                || stderr.contains("bad default revision") =>
539        {
540            return Ok(BranchInfo {
541                current: "main".to_string(),
542                local: vec![],
543            });
544        }
545        Err(e) => return Err(e),
546    };
547
548    if raw.trim().is_empty() {
549        return Ok(BranchInfo {
550            current: "main".to_string(),
551            local: vec![],
552        });
553    }
554
555    let mut current = String::from("main");
556    let mut local: Vec<String> = Vec::new();
557
558    for line in raw.lines() {
559        // Format: `* main` or `  feature-x`
560        let is_current = line.starts_with("* ");
561        let name = line
562            .trim_start_matches("* ")
563            .trim_start_matches("  ")
564            .trim();
565        if name.is_empty() {
566            continue;
567        }
568        if is_current {
569            current = name.to_string();
570        }
571        local.push(name.to_string());
572    }
573
574    Ok(BranchInfo { current, local })
575}
576
577/// Create and switch to a new branch called `name`.
578pub async fn git_create_branch(repo: &Path, name: &str) -> Result<(), GitError> {
579    // Validate branch name: no `..`, no spaces, must not start with `-`.
580    if name.contains("..") || name.contains(' ') || name.starts_with('-') {
581        return Err(GitError::PathTraversal(format!(
582            "invalid branch name: {name}"
583        )));
584    }
585    git_run(&["checkout", "-b", name], repo).await?;
586    Ok(())
587}
588
589/// Discard working-tree changes to the listed `paths` (equivalent to
590/// `git checkout -- <paths>`).
591pub async fn git_discard(repo: &Path, paths: &[String]) -> Result<(), GitError> {
592    for p in paths {
593        validate_path(p)?;
594    }
595    let mut args = vec!["checkout", "--"];
596    let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
597    args.extend_from_slice(&path_refs);
598    git_run(&args, repo).await?;
599    Ok(())
600}
601
602// ---------------------------------------------------------------------------
603// Tests
604// ---------------------------------------------------------------------------
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    fn make_status(raw: &str) -> StatusReport {
611        parse_status_output(raw).expect("parse failed")
612    }
613
614    #[test]
615    fn parse_clean_repo() {
616        let raw = "## main...origin/main\n";
617        let s = make_status(raw);
618        assert_eq!(s.branch, "main");
619        assert_eq!(s.ahead, 0);
620        assert_eq!(s.behind, 0);
621        assert!(s.is_clean);
622        assert!(s.staged.is_empty());
623        assert!(s.unstaged.is_empty());
624        assert!(s.untracked.is_empty());
625    }
626
627    #[test]
628    fn parse_ahead_behind() {
629        let raw = "## main...origin/main [ahead 3, behind 1]\n";
630        let s = make_status(raw);
631        assert_eq!(s.branch, "main");
632        assert_eq!(s.ahead, 3);
633        assert_eq!(s.behind, 1);
634    }
635
636    #[test]
637    fn parse_ahead_only() {
638        let raw = "## feature...origin/feature [ahead 2]\n";
639        let s = make_status(raw);
640        assert_eq!(s.branch, "feature");
641        assert_eq!(s.ahead, 2);
642        assert_eq!(s.behind, 0);
643    }
644
645    #[test]
646    fn parse_no_commits_yet() {
647        let raw = "## No commits yet on main\n";
648        let s = make_status(raw);
649        assert_eq!(s.branch, "main");
650        assert_eq!(s.ahead, 0);
651        assert_eq!(s.behind, 0);
652        assert!(s.is_clean);
653    }
654
655    #[test]
656    fn parse_no_commits_yet_with_staged() {
657        let raw = "## No commits yet on main\nA  README.md\n";
658        let s = make_status(raw);
659        assert_eq!(s.branch, "main");
660        assert_eq!(s.staged.len(), 1);
661        assert_eq!(s.staged[0].change_type, ChangeType::Added);
662        assert_eq!(s.staged[0].path, "README.md");
663        assert!(!s.is_clean);
664    }
665
666    #[test]
667    fn parse_modified_staged_and_unstaged() {
668        // X=M (staged modified), Y=M (unstaged modified)
669        let raw = "## main\nMM src/lib.rs\n";
670        let s = make_status(raw);
671        assert_eq!(s.staged.len(), 1);
672        assert_eq!(s.staged[0].change_type, ChangeType::Modified);
673        assert_eq!(s.unstaged.len(), 1);
674        assert_eq!(s.unstaged[0].change_type, ChangeType::Modified);
675    }
676
677    #[test]
678    fn parse_untracked() {
679        let raw = "## main\n?? newfile.txt\n";
680        let s = make_status(raw);
681        assert_eq!(s.untracked, vec!["newfile.txt"]);
682        assert!(!s.is_clean);
683    }
684
685    #[test]
686    fn parse_deleted_staged() {
687        let raw = "## main\nD  old.txt\n";
688        let s = make_status(raw);
689        assert_eq!(s.staged.len(), 1);
690        assert_eq!(s.staged[0].change_type, ChangeType::Deleted);
691        assert_eq!(s.staged[0].path, "old.txt");
692    }
693
694    #[test]
695    fn parse_renamed_staged() {
696        let raw = "## main\nR  new.txt -> old.txt\n";
697        let s = make_status(raw);
698        assert_eq!(s.staged.len(), 1);
699        assert_eq!(s.staged[0].change_type, ChangeType::Renamed);
700        assert_eq!(s.staged[0].path, "new.txt");
701        assert_eq!(s.staged[0].old_path.as_deref(), Some("old.txt"));
702    }
703
704    #[test]
705    fn parse_branch_no_tracking() {
706        let raw = "## detached-head\nM  foo.rs\n";
707        let s = make_status(raw);
708        assert_eq!(s.branch, "detached-head");
709        assert_eq!(s.ahead, 0);
710        assert_eq!(s.behind, 0);
711    }
712
713    #[test]
714    fn validate_path_rejects_dotdot() {
715        assert!(validate_path("../etc/passwd").is_err());
716        assert!(validate_path("foo/../../bar").is_err());
717    }
718
719    #[test]
720    fn validate_path_rejects_absolute() {
721        assert!(validate_path("/etc/passwd").is_err());
722    }
723
724    #[test]
725    fn validate_path_accepts_normal() {
726        assert!(validate_path("src/lib.rs").is_ok());
727        assert!(validate_path("README.md").is_ok());
728    }
729
730    #[tokio::test]
731    async fn resolve_commit_rejects_malformed_rev() {
732        // Defence-in-depth: a non-hex / over-long / empty rev is rejected
733        // before ever shelling to git (no path/shell metachars reach `git`).
734        let repo = std::path::Path::new("/nonexistent");
735        for bad in [
736            "",
737            "../etc",
738            "deadbeef; rm -rf /",
739            &"a".repeat(65),
740            "g00dbeef",
741        ] {
742            assert!(
743                matches!(
744                    resolve_commit(repo, bad).await,
745                    Err(GitError::PathTraversal(_))
746                ),
747                "malformed rev {bad:?} must be rejected pre-git"
748            );
749        }
750    }
751}