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/// Return a unified diff for the repository or a specific file.
341///
342/// - `staged = true` produces `git diff --cached` (index vs HEAD).
343/// - `staged = false` produces `git diff` (working tree vs index).
344/// - `path` restricts the diff to a single file.
345pub async fn git_diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String, GitError> {
346    if let Some(p) = path {
347        validate_path(p)?;
348    }
349
350    let mut args: Vec<&str> = vec!["diff", "-U5"];
351    if staged {
352        args.push("--cached");
353    }
354    if let Some(p) = path {
355        args.push("--");
356        args.push(p);
357    }
358
359    // `git diff` exits 0 even when there are no differences.
360    git_run(&args, repo).await
361}
362
363/// Stage files. If `all` is true, runs `git add -A`. Otherwise stages
364/// only the listed `paths`.
365pub async fn git_add(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
366    if all {
367        git_run(&["add", "-A"], repo).await?;
368    } else {
369        for p in paths {
370            validate_path(p)?;
371        }
372        let mut args = vec!["add", "--"];
373        let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
374        args.extend_from_slice(&path_refs);
375        git_run(&args, repo).await?;
376    }
377    Ok(())
378}
379
380/// Unstage files. If `all` is true, unstages everything. Otherwise
381/// unstages only the listed `paths`.
382///
383/// `git reset HEAD` can exit 1 on repositories with no commits yet;
384/// this is handled gracefully.
385pub async fn git_unstage(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
386    if all {
387        git_run_tolerant(&["reset", "HEAD", "--", "."], repo).await?;
388    } else {
389        for p in paths {
390            validate_path(p)?;
391        }
392        let mut args = vec!["reset", "HEAD", "--"];
393        let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
394        args.extend_from_slice(&path_refs);
395        git_run_tolerant(&args, repo).await?;
396    }
397    Ok(())
398}
399
400/// Create a commit with the given message. Returns the new commit hash and
401/// a one-line summary.
402pub async fn git_commit(
403    repo: &Path,
404    message: &str,
405    author_name: &str,
406    author_email: &str,
407) -> Result<CommitResult, GitError> {
408    let author_str = format!("{author_name} <{author_email}>");
409    git_run(
410        &["commit", "-m", message, "--author", &author_str],
411        repo,
412    )
413    .await?;
414
415    let hash = git_run(&["rev-parse", "HEAD"], repo).await?;
416    let hash = hash.trim().to_string();
417    let short_hash = if hash.len() >= 7 {
418        hash[..7].to_string()
419    } else {
420        hash.clone()
421    };
422
423    // Extract the commit subject for the summary.
424    let summary = git_run(&["log", "-1", "--format=%s", &hash], repo)
425        .await
426        .unwrap_or_else(|_| message.to_string());
427    let summary = summary.trim().to_string();
428
429    Ok(CommitResult {
430        hash,
431        short_hash,
432        summary,
433    })
434}
435
436/// Return the list of local branches and identify the current one.
437pub async fn git_branches(repo: &Path) -> Result<BranchInfo, GitError> {
438    let raw = match git_run(
439        &["branch", "--format=%(HEAD) %(refname:short)"],
440        repo,
441    )
442    .await
443    {
444        Ok(v) => v,
445        Err(GitError::BackendFailed { ref stderr, .. })
446            if stderr.contains("does not have any commits")
447                || stderr.contains("bad default revision") =>
448        {
449            return Ok(BranchInfo {
450                current: "main".to_string(),
451                local: vec![],
452            });
453        }
454        Err(e) => return Err(e),
455    };
456
457    if raw.trim().is_empty() {
458        return Ok(BranchInfo {
459            current: "main".to_string(),
460            local: vec![],
461        });
462    }
463
464    let mut current = String::from("main");
465    let mut local: Vec<String> = Vec::new();
466
467    for line in raw.lines() {
468        // Format: `* main` or `  feature-x`
469        let is_current = line.starts_with("* ");
470        let name = line.trim_start_matches("* ").trim_start_matches("  ").trim();
471        if name.is_empty() {
472            continue;
473        }
474        if is_current {
475            current = name.to_string();
476        }
477        local.push(name.to_string());
478    }
479
480    Ok(BranchInfo { current, local })
481}
482
483/// Create and switch to a new branch called `name`.
484pub async fn git_create_branch(repo: &Path, name: &str) -> Result<(), GitError> {
485    // Validate branch name: no `..`, no spaces, must not start with `-`.
486    if name.contains("..") || name.contains(' ') || name.starts_with('-') {
487        return Err(GitError::PathTraversal(format!(
488            "invalid branch name: {name}"
489        )));
490    }
491    git_run(&["checkout", "-b", name], repo).await?;
492    Ok(())
493}
494
495/// Discard working-tree changes to the listed `paths` (equivalent to
496/// `git checkout -- <paths>`).
497pub async fn git_discard(repo: &Path, paths: &[String]) -> Result<(), GitError> {
498    for p in paths {
499        validate_path(p)?;
500    }
501    let mut args = vec!["checkout", "--"];
502    let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
503    args.extend_from_slice(&path_refs);
504    git_run(&args, repo).await?;
505    Ok(())
506}
507
508// ---------------------------------------------------------------------------
509// Tests
510// ---------------------------------------------------------------------------
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn make_status(raw: &str) -> StatusReport {
517        parse_status_output(raw).expect("parse failed")
518    }
519
520    #[test]
521    fn parse_clean_repo() {
522        let raw = "## main...origin/main\n";
523        let s = make_status(raw);
524        assert_eq!(s.branch, "main");
525        assert_eq!(s.ahead, 0);
526        assert_eq!(s.behind, 0);
527        assert!(s.is_clean);
528        assert!(s.staged.is_empty());
529        assert!(s.unstaged.is_empty());
530        assert!(s.untracked.is_empty());
531    }
532
533    #[test]
534    fn parse_ahead_behind() {
535        let raw = "## main...origin/main [ahead 3, behind 1]\n";
536        let s = make_status(raw);
537        assert_eq!(s.branch, "main");
538        assert_eq!(s.ahead, 3);
539        assert_eq!(s.behind, 1);
540    }
541
542    #[test]
543    fn parse_ahead_only() {
544        let raw = "## feature...origin/feature [ahead 2]\n";
545        let s = make_status(raw);
546        assert_eq!(s.branch, "feature");
547        assert_eq!(s.ahead, 2);
548        assert_eq!(s.behind, 0);
549    }
550
551    #[test]
552    fn parse_no_commits_yet() {
553        let raw = "## No commits yet on main\n";
554        let s = make_status(raw);
555        assert_eq!(s.branch, "main");
556        assert_eq!(s.ahead, 0);
557        assert_eq!(s.behind, 0);
558        assert!(s.is_clean);
559    }
560
561    #[test]
562    fn parse_no_commits_yet_with_staged() {
563        let raw = "## No commits yet on main\nA  README.md\n";
564        let s = make_status(raw);
565        assert_eq!(s.branch, "main");
566        assert_eq!(s.staged.len(), 1);
567        assert_eq!(s.staged[0].change_type, ChangeType::Added);
568        assert_eq!(s.staged[0].path, "README.md");
569        assert!(!s.is_clean);
570    }
571
572    #[test]
573    fn parse_modified_staged_and_unstaged() {
574        // X=M (staged modified), Y=M (unstaged modified)
575        let raw = "## main\nMM src/lib.rs\n";
576        let s = make_status(raw);
577        assert_eq!(s.staged.len(), 1);
578        assert_eq!(s.staged[0].change_type, ChangeType::Modified);
579        assert_eq!(s.unstaged.len(), 1);
580        assert_eq!(s.unstaged[0].change_type, ChangeType::Modified);
581    }
582
583    #[test]
584    fn parse_untracked() {
585        let raw = "## main\n?? newfile.txt\n";
586        let s = make_status(raw);
587        assert_eq!(s.untracked, vec!["newfile.txt"]);
588        assert!(!s.is_clean);
589    }
590
591    #[test]
592    fn parse_deleted_staged() {
593        let raw = "## main\nD  old.txt\n";
594        let s = make_status(raw);
595        assert_eq!(s.staged.len(), 1);
596        assert_eq!(s.staged[0].change_type, ChangeType::Deleted);
597        assert_eq!(s.staged[0].path, "old.txt");
598    }
599
600    #[test]
601    fn parse_renamed_staged() {
602        let raw = "## main\nR  new.txt -> old.txt\n";
603        let s = make_status(raw);
604        assert_eq!(s.staged.len(), 1);
605        assert_eq!(s.staged[0].change_type, ChangeType::Renamed);
606        assert_eq!(s.staged[0].path, "new.txt");
607        assert_eq!(s.staged[0].old_path.as_deref(), Some("old.txt"));
608    }
609
610    #[test]
611    fn parse_branch_no_tracking() {
612        let raw = "## detached-head\nM  foo.rs\n";
613        let s = make_status(raw);
614        assert_eq!(s.branch, "detached-head");
615        assert_eq!(s.ahead, 0);
616        assert_eq!(s.behind, 0);
617    }
618
619    #[test]
620    fn validate_path_rejects_dotdot() {
621        assert!(validate_path("../etc/passwd").is_err());
622        assert!(validate_path("foo/../../bar").is_err());
623    }
624
625    #[test]
626    fn validate_path_rejects_absolute() {
627        assert!(validate_path("/etc/passwd").is_err());
628    }
629
630    #[test]
631    fn validate_path_accepts_normal() {
632        assert!(validate_path("src/lib.rs").is_ok());
633        assert!(validate_path("README.md").is_ok());
634    }
635}