fm/io/
git.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4// Copied and modified from https://github.com/9ary/gitprompt-rs/blob/master/src/main.rs
5// Couldn't use without forking and I'm lazy.
6
7use std::fmt::Write as _;
8use std::path::Path;
9
10use anyhow::{anyhow, Context, Result};
11
12use crate::common::{is_in_path, set_current_dir};
13use crate::io::execute_and_output_no_log;
14
15#[derive(Default)]
16struct GitStatus {
17    branch: Option<String>,
18    ahead: i64,
19    behind: i64,
20
21    staged: i64,
22    modified: i64,
23    deleted: i64,
24    unmerged: i64,
25    untracked: i64,
26}
27
28impl GitStatus {
29    fn parse_porcelain2(porcerlain2_output: String) -> Option<GitStatus> {
30        let mut status = GitStatus::default();
31        // Simple parser for the porcelain v2 format
32        for entry in porcerlain2_output.split('\0') {
33            let mut entry = entry.split(' ');
34            match entry.next() {
35                // Header lines
36                Some("#") => match entry.next()? {
37                    "branch.head" => {
38                        let head = entry.next()?;
39                        if head != "(detached)" {
40                            status.branch = Some(String::from(head));
41                        }
42                    }
43                    "branch.ab" => {
44                        let a = entry.next()?;
45                        let b = entry.next()?;
46                        status.ahead = a.parse::<i64>().ok()?.abs();
47                        status.behind = b.parse::<i64>().ok()?.abs();
48                    }
49                    _ => {}
50                },
51                // File entries
52                Some("1") | Some("2") => {
53                    let mut xy = entry.next()?.chars();
54                    let x = xy.next()?;
55                    let y = xy.next()?;
56                    if x != '.' {
57                        status.staged += 1;
58                    }
59                    match y {
60                        'M' => status.modified += 1,
61                        'D' => status.deleted += 1,
62                        _ => {}
63                    }
64                }
65                Some("u") => status.unmerged += 1,
66                Some("?") => status.untracked += 1,
67                _ => {}
68            }
69        }
70        Some(status)
71    }
72
73    fn is_modified(&self) -> bool {
74        self.untracked + self.modified + self.deleted + self.unmerged + self.staged > 0
75    }
76
77    fn format_git_string(&self) -> Result<String> {
78        let mut git_string = String::new();
79
80        git_string.push('(');
81
82        if let Some(branch) = &self.branch {
83            git_string.push_str(branch);
84        } else {
85            // Detached head
86            git_string.push_str(":HEAD");
87        }
88
89        // Divergence with remote branch
90        if self.ahead != 0 {
91            write!(git_string, "↑{}", self.ahead)?;
92        }
93        if self.behind != 0 {
94            write!(git_string, "↓{}", self.behind)?;
95        }
96
97        if self.is_modified() {
98            git_string.push('|');
99
100            if self.untracked != 0 {
101                write!(git_string, "+{}", self.untracked)?;
102            }
103            if self.modified != 0 {
104                write!(git_string, "~{}", self.modified)?;
105            }
106            if self.deleted != 0 {
107                write!(git_string, "-{}", self.deleted)?;
108            }
109            if self.unmerged != 0 {
110                write!(git_string, "x{}", self.unmerged)?;
111            }
112            if self.staged != 0 {
113                write!(git_string, "•{}", self.staged)?;
114            }
115        }
116
117        git_string.push(')');
118
119        Ok(git_string)
120    }
121}
122
123fn porcelain2() -> Result<std::process::Output> {
124    execute_and_output_no_log(
125        "git",
126        [
127            "status",
128            "--porcelain=v2",
129            "-z",
130            "--branch",
131            "--untracked-files=all",
132        ],
133    )
134}
135
136/// Returns a string representation of the git status of this path.
137/// Will return an empty string if we're not in a git repository.
138pub fn git(path: &Path) -> Result<String> {
139    if !is_in_path("git") {
140        return Ok("".to_owned());
141    }
142    if set_current_dir(path).is_err() {
143        // The path may not exist. It should never happen.
144        return Ok("".to_owned());
145    }
146    let output = porcelain2()?;
147    if !output.status.success() {
148        // We're most likely not in a Git repo
149        return Ok("".to_owned());
150    }
151    let porcerlain_output = String::from_utf8(output.stdout)?;
152
153    GitStatus::parse_porcelain2(porcerlain_output)
154        .context("Error while parsing Git output")?
155        .format_git_string()
156}
157
158/// Returns the git root.
159/// Returns an error outside of a git repository.
160pub fn git_root() -> Result<String> {
161    let output = execute_and_output_no_log("git", ["rev-parse", "--show-toplevel"])?;
162    if !output.status.success() {
163        // We're most likely not in a Git repo
164        return Err(anyhow!("git root: git command returned an error"));
165    }
166    Ok(String::from_utf8(output.stdout)?.trim().to_owned())
167}