Skip to main content

context_bar_core/
git_signal.rs

1use std::borrow::Cow;
2use std::time::{Duration, SystemTime, UNIX_EPOCH};
3
4#[cfg(target_arch = "wasm32")]
5use zed_extension_api::{self as zed, process::Command};
6
7#[derive(Clone, Debug, serde::Serialize)]
8pub struct CommitSummary {
9    pub sha: String,
10    pub subject: String,
11    /// Author commit time. Optional because old data on disk may omit it.
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub committed_at: Option<String>,
14    #[serde(skip)]
15    pub committed_at_system: Option<SystemTime>,
16}
17
18#[derive(Clone, Debug, serde::Serialize)]
19pub struct ChangeSummary {
20    pub path: String,
21    pub code: String,
22    pub staged: bool,
23    pub unstaged: bool,
24}
25
26#[derive(Clone, Debug, serde::Serialize)]
27pub struct GitSignals {
28    pub branch: String,
29    pub recent_commits: Vec<CommitSummary>,
30    pub staged_changes: Vec<ChangeSummary>,
31    pub unstaged_changes: Vec<ChangeSummary>,
32    pub clean_worktree: bool,
33}
34
35#[cfg(target_arch = "wasm32")]
36pub fn collect(worktree: &zed::Worktree) -> Result<GitSignals, String> {
37    let branch = run_git(worktree, ["rev-parse", "--abbrev-ref", "HEAD"])?
38        .trim()
39        .to_string();
40
41    let recent_commits = parse_commits(&run_git(
42        worktree,
43        ["log", "--since=7 days ago", "--max-count=40", "--format=%H%x09%ct%x09%s"],
44    )?);
45
46    let status = run_git(worktree, ["status", "--short"])?;
47    let (staged_changes, unstaged_changes) = parse_status(&status);
48
49    Ok(GitSignals {
50        branch,
51        recent_commits,
52        clean_worktree: staged_changes.is_empty() && unstaged_changes.is_empty(),
53        staged_changes,
54        unstaged_changes,
55    })
56}
57
58#[cfg(target_arch = "wasm32")]
59fn run_git<'a>(
60    worktree: &zed::Worktree,
61    args: impl IntoIterator<Item = &'a str>,
62) -> Result<String, String> {
63    let git = worktree
64        .which("git")
65        .ok_or_else(|| "git binary was not found in the worktree environment".to_string())?;
66
67    let mut command = Command::new(git);
68    command = command.arg("-C").arg(worktree.root_path());
69    command = command.args(args);
70    command = command.envs(worktree.shell_env());
71
72    let output = command.output()?;
73    if output.status == Some(0) {
74        String::from_utf8(output.stdout)
75            .map_err(|error| format!("git output was not valid UTF-8: {error}"))
76    } else {
77        let stderr = String::from_utf8_lossy(&output.stderr);
78        Err(format!(
79            "git command failed with status {:?}: {}",
80            output.status,
81            stderr.trim()
82        ))
83    }
84}
85
86pub fn parse_commits(raw: &str) -> Vec<CommitSummary> {
87    raw.lines()
88        .filter_map(|line| {
89            let mut parts = line.splitn(3, '\t');
90            let sha = parts.next()?;
91            let second = parts.next()?;
92            // Two formats supported: "%H\t%ct\t%s" (preferred) and "%H\t%s".
93            let (committed_at_system, subject) = match parts.next() {
94                Some(subject) => {
95                    let epoch: u64 = second.trim().parse().ok()?;
96                    (Some(UNIX_EPOCH + Duration::from_secs(epoch)), subject)
97                }
98                None => (None, second),
99            };
100            let committed_at = committed_at_system
101                .and_then(|time| {
102                    use time::{OffsetDateTime, format_description::well_known::Rfc3339};
103                    OffsetDateTime::from(time).format(&Rfc3339).ok()
104                });
105            Some(CommitSummary {
106                sha: sha.chars().take(7).collect(),
107                subject: subject.trim().to_string(),
108                committed_at,
109                committed_at_system,
110            })
111        })
112        .collect()
113}
114
115pub fn parse_status_public(raw: &str) -> (Vec<ChangeSummary>, Vec<ChangeSummary>) {
116    parse_status(raw)
117}
118
119fn parse_status(raw: &str) -> (Vec<ChangeSummary>, Vec<ChangeSummary>) {
120    let mut staged = Vec::new();
121    let mut unstaged = Vec::new();
122
123    for line in raw.lines() {
124        if line.len() < 3 {
125            continue;
126        }
127
128        let index_code = line.chars().next().unwrap_or(' ');
129        let worktree_code = line.chars().nth(1).unwrap_or(' ');
130        let path = line[3..].trim().to_string();
131
132        if index_code != ' ' && index_code != '?' {
133            staged.push(ChangeSummary {
134                path: normalize_status_path(&path).into_owned(),
135                code: index_code.to_string(),
136                staged: true,
137                unstaged: false,
138            });
139        }
140
141        if worktree_code != ' ' || (index_code == '?' && worktree_code == '?') {
142            unstaged.push(ChangeSummary {
143                path: normalize_status_path(&path).into_owned(),
144                code: if index_code == '?' && worktree_code == '?' {
145                    "??".to_string()
146                } else {
147                    worktree_code.to_string()
148                },
149                staged: false,
150                unstaged: true,
151            });
152        }
153    }
154
155    (staged, unstaged)
156}
157
158fn normalize_status_path(path: &str) -> Cow<'_, str> {
159    if let Some((_, new_path)) = path.split_once(" -> ") {
160        Cow::Owned(new_path.to_string())
161    } else {
162        Cow::Borrowed(path)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::parse_status;
169
170    #[test]
171    fn parses_git_status_into_staged_and_unstaged_views() {
172        let raw = "M  src/lib.rs\n M README.md\nR  old.rs -> new.rs\n?? src/new.rs\n";
173        let (staged, unstaged) = parse_status(raw);
174
175        assert_eq!(staged.len(), 2);
176        assert_eq!(staged[0].path, "src/lib.rs");
177        assert_eq!(staged[1].path, "new.rs");
178
179        assert_eq!(unstaged.len(), 2);
180        assert_eq!(unstaged[0].path, "README.md");
181        assert_eq!(unstaged[1].code, "??");
182    }
183}