context_bar_core/
git_signal.rs1use 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 #[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 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}