Skip to main content

gw_render/
status.rs

1//! `gw status` view.
2
3use crate::style::{BRANCH, BRANCH_BOLD, DIM, GREEN, RED, UPSTREAM, YELLOW, paint, sanitize};
4use gw_parse::{Entry, Numstat, Status, SubmoduleState};
5use unicode_width::UnicodeWidthStr;
6
7/// Rewrites a repo-root relative path to be relative to `prefix` (from `git rev-parse --show-prefix`).
8pub fn relative(path: &str, prefix: &str) -> String {
9    // Untracked directories come as `dir/`: resolve the dir itself, then restore the slash.
10    if let Some(dir) = path.strip_suffix('/') {
11        return relative(dir, prefix) + "/";
12    }
13    let pre: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
14    let parts: Vec<&str> = path.split('/').collect();
15    let dirs = &parts[..parts.len() - 1];
16    let common = pre.iter().zip(dirs).take_while(|(a, b)| a == b).count();
17    let mut out = "../".repeat(pre.len() - common);
18    out += &parts[common..].join("/");
19    out
20}
21
22fn count(n: u32, sgr: &str, arrow: &str, color: bool) -> String {
23    paint(&format!("{arrow}{n}"), if n > 0 { sgr } else { DIM }, color)
24}
25
26/// Line counts per group, keyed by repo-relative path. Missing entries show no stat.
27#[derive(Default)]
28pub struct LineStats {
29    pub staged: Numstat,
30    pub unstaged: Numstat,
31    pub untracked: Numstat,
32    /// Checked-out commit per submodule path (from `git submodule status`), when known.
33    pub checkouts: std::collections::HashMap<String, String>,
34}
35
36fn short(sha: &str) -> &str {
37    &sha[..sha.len().min(7)]
38}
39
40/// `<recorded> → <current>  <labels>` for a submodule line.
41fn submodule_detail(e: &Entry, sub: &SubmoduleState, checkouts: &std::collections::HashMap<String, String>, color: bool) -> String {
42    // A newly added submodule has no commit in HEAD (all zeros): its recorded commit is the index one.
43    let recorded = if sub.head.bytes().all(|b| b == b'0') { &sub.index } else { &sub.head };
44    let current = if sub.commit_changed { checkouts.get(&e.path).unwrap_or(recorded) } else { &sub.index };
45    let mut s = if current == recorded {
46        paint(short(recorded), DIM, color)
47    } else {
48        format!("{} → {}", paint(short(recorded), DIM, color), paint(short(current), YELLOW, color))
49    };
50    let labels: Vec<String> = [
51        (sub.head != sub.index && recorded == &sub.head, "staged", GREEN),
52        (sub.commit_changed, "new commits", YELLOW),
53        (sub.modified, "modified content", YELLOW),
54        (sub.untracked, "untracked content", DIM),
55    ]
56    .into_iter()
57    .filter(|l| l.0)
58    .map(|(_, t, sgr)| paint(t, sgr, color))
59    .collect();
60    if !labels.is_empty() {
61        s += &format!("  {}", labels.join(", "));
62    }
63    s
64}
65
66/// `+a -d` (GitHub/GitLab style), `bin` for binary files.
67fn stat(n: Option<Option<(u64, u64)>>, color: bool) -> String {
68    match n {
69        Some(Some((a, d))) => format!(
70            "{} {}",
71            paint(&format!("+{a}"), if a > 0 { GREEN } else { DIM }, color),
72            paint(&format!("-{d}"), if d > 0 { RED } else { DIM }, color)
73        ),
74        Some(None) => paint("bin", DIM, color),
75        None => String::new(),
76    }
77}
78
79/// A `gw status` group, in display order.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Group {
82    Conflicts,
83    Staged,
84    Changed,
85    Submodules,
86    Untracked,
87}
88
89impl Group {
90    pub fn title(self) -> &'static str {
91        match self {
92            Group::Conflicts => "Conflicts",
93            Group::Staged => "Staged",
94            Group::Changed => "Changed",
95            Group::Submodules => "Submodules",
96            Group::Untracked => "Untracked",
97        }
98    }
99
100    pub fn sgr(self) -> &'static str {
101        match self {
102            Group::Conflicts => RED,
103            Group::Staged => GREEN,
104            Group::Changed => YELLOW,
105            Group::Submodules => BRANCH,
106            Group::Untracked => DIM,
107        }
108    }
109
110    /// The one-letter change code shown for `e` in this group.
111    pub fn code(self, e: &Entry) -> char {
112        match self {
113            Group::Conflicts => 'U',
114            Group::Staged => e.x,
115            Group::Changed => e.y,
116            Group::Submodules if e.x == 'A' => 'A',
117            Group::Submodules => 'M',
118            Group::Untracked => '?',
119        }
120    }
121
122    /// Renames show `old -> new` in these groups.
123    pub fn shows_orig(self) -> bool {
124        matches!(self, Group::Staged | Group::Submodules)
125    }
126}
127
128/// Entries per group, all five groups in display order (some may be empty).
129/// A file staged and changed again appears in both Staged and Changed.
130pub fn groups(st: &Status) -> Vec<(Group, Vec<&Entry>)> {
131    let file = |e: &&Entry| !e.unmerged && e.x != '?' && e.submodule.is_none();
132    let pick = |f: &dyn Fn(&&Entry) -> bool| st.entries.iter().filter(|e| f(e)).collect::<Vec<_>>();
133    vec![
134        (Group::Conflicts, pick(&|e| e.unmerged)),
135        (Group::Staged, pick(&|e| file(e) && e.x != '.')),
136        (Group::Changed, pick(&|e| file(e) && e.y != '.')),
137        (Group::Submodules, pick(&|e| e.submodule.is_some() && !e.unmerged)),
138        (Group::Untracked, pick(&|e| e.x == '?')),
139    ]
140}
141
142pub fn render_status(st: &Status, prefix: &str, stats: &LineStats, color: bool) -> String {
143    let mut out = match (&st.head, &st.oid) {
144        (Some(h), _) => paint(&sanitize(h), BRANCH_BOLD, color),
145        (None, Some(oid)) => paint(&format!("detached at {}", &oid[..oid.len().min(7)]), YELLOW, color),
146        (None, None) => paint("detached", YELLOW, color),
147    };
148    if st.head.is_some() {
149        match &st.upstream {
150            Some(up) => {
151                out += &format!(" -> {}", paint(&sanitize(up), UPSTREAM, color));
152                if let Some((a, b)) = st.ahead_behind {
153                    out += &format!("  {} {}", count(a, GREEN, "↑", color), count(b, YELLOW, "↓", color));
154                }
155            }
156            None => out += &format!("  {}", paint("no upstream", DIM, color)),
157        }
158    }
159    out.push('\n');
160
161    // -z output is unquoted: file names may carry escape sequences.
162    let rel = |p: &str| sanitize(&relative(p, prefix));
163    let name = |e: &Entry| match &e.orig {
164        Some(o) => format!("{} -> {}", rel(o), rel(&e.path)),
165        None => rel(&e.path),
166    };
167    // (code, path, stats, submodule detail)
168    type Line = (char, String, Option<Option<(u64, u64)>>, String);
169    let groups: Vec<(&str, &str, Vec<Line>)> = groups(st)
170        .into_iter()
171        .map(|(g, entries)| {
172            let lines = entries
173                .into_iter()
174                .map(|e| {
175                    let path = if g.shows_orig() { name(e) } else { rel(&e.path) };
176                    let n = match g {
177                        Group::Staged => stats.staged.get(&e.path).copied(),
178                        Group::Changed => stats.unstaged.get(&e.path).copied(),
179                        Group::Untracked => stats.untracked.get(&e.path).copied(),
180                        _ => None,
181                    };
182                    let detail = match (g, &e.submodule) {
183                        (Group::Submodules, Some(sub)) => submodule_detail(e, sub, &stats.checkouts, color),
184                        _ => String::new(),
185                    };
186                    (g.code(e), path, n, detail)
187                })
188                .collect();
189            (g.title(), g.sgr(), lines)
190        })
191        .collect();
192    if groups.iter().all(|g| g.2.is_empty()) {
193        out += &paint("clean", DIM, color);
194        out.push('\n');
195        return out;
196    }
197    for (title, sgr, files) in groups.iter().filter(|g| !g.2.is_empty()) {
198        out += &format!("{}\n", paint(&format!("{title} ({})", files.len()), sgr, color));
199        let width = files.iter().filter(|f| f.2.is_some() || !f.3.is_empty()).map(|f| f.1.width()).max().unwrap_or(0);
200        for (code, path, n, detail) in files {
201            let code = paint(&code.to_string(), sgr, color);
202            let tail = if detail.is_empty() { stat(*n, color) } else { detail.clone() };
203            if tail.is_empty() {
204                out += &format!("  {code} {path}\n");
205            } else {
206                out += &format!("  {code} {path}{}  {tail}\n", " ".repeat(width.saturating_sub(path.width())));
207            }
208        }
209    }
210    // Sum of every count shown; binary files and files without stats add nothing.
211    let (a, d) = groups.iter().flat_map(|g| &g.2).filter_map(|f| f.2.flatten()).fold((0, 0), |t, (a, d)| (t.0 + a, t.1 + d));
212    if groups.iter().any(|g| g.2.iter().any(|f| f.2.is_some())) {
213        out += &format!("Total {}\n", stat(Some(Some((a, d))), color));
214    }
215    out
216}
217
218#[cfg(test)]
219mod tests {
220    use super::relative;
221
222    #[test]
223    fn relative_paths() {
224        assert_eq!(relative("src/a.rs", "src/"), "a.rs");
225        assert_eq!(relative("b.rs", "src/"), "../b.rs");
226        assert_eq!(relative("src2/x", "src/"), "../src2/x");
227        assert_eq!(relative("a/b/c", "a/d/"), "../b/c");
228        assert_eq!(relative("x", ""), "x");
229        assert_eq!(relative("src/", "src/"), "../src/");
230        assert_eq!(relative("src/d/", "src/"), "d/");
231    }
232}