gitstatusd/
lib.rs

1//TODO: Serde Support
2//TODO: Clean up docs
3//TODO: Build instructions
4
5
6//! Bindings to [gitstatusd](https://github.com/romkatv/gitstatus)
7//!
8//! *gitstatusd* is a c++ binary that provides extreamly fast alternative
9//! to `git status`. This project is a library that make comunicating with
10//! that binary easier.
11//!
12//! ```no_run
13//! let mut gsd = gitstatusd::SatusDaemon::new("/Users/nixon/bin/gitstatusd", ".").unwrap();
14//! let req = gitstatusd::StatusRequest {
15//!     id: "".to_owned(),
16//!     dir: "/Users/nixon/dev/rs/gitstatusd".to_owned(),
17//!     read_index:  gitstatusd::ReadIndex::ReadAll,
18//! };
19//! let rsp = gsd.request(req).unwrap();
20//! assert_eq!(rsp.details.unwrap().commits_ahead, 0);
21//! ```
22
23
24use std::{
25    ffi::OsStr,
26    fmt,
27    io::{self, BufRead, Write},
28    path::Path,
29    process,
30};
31
32///////////////////////////////////////////////////////////////////////////////
33// Responce
34///////////////////////////////////////////////////////////////////////////////
35
36#[derive(Debug, PartialEq)]
37/// The result of a request for the git status. 
38///
39/// If the request was inside a git reposity, `details` will contain a 
40/// `GitDetails` with the results
41pub struct GitStatus {
42    /// Request id. The same as the first field in the request.
43    pub id: String,
44    /// The inner responce.
45    pub details: Option<GitDetails>,
46}
47
48
49
50/// Details about git state.
51///
52/// Note: Renamed files are reported as deleted plus new.
53#[derive(Debug, PartialEq)]
54pub struct GitDetails {
55    /// Absolute path to the git repository workdir.
56    pub abspath: String,
57    /// Commit hash that HEAD is pointing to. 40 hex digits.
58    // TODO: Change the type
59    pub head_commit_hash: String,
60    // TODO: Docs unclear
61    /// Local branch name or empty if not on a branch.
62    pub local_branch: String,
63    /// Upstream branch name. Can be empty.
64    pub upstream_branch: String,
65    /// The remote name, e.g. "upstream" or "origin".
66    pub remote_name: String,
67    /// Remote URL. Can be empty.
68    pub remote_url: String,
69    /// Repository state, A.K.A. action. Can be empty.
70    pub repository_state: String,
71    /// The number of files in the index.
72    pub num_files_in_index: u32,
73    /// The number of staged changes.
74    pub num_staged_changes: u32,
75    /// The number of unstaged changes.
76    pub num_unstaged_changes: u32,
77    /// The number of conflicted changes.
78    pub num_conflicted_changes: u32,
79    /// The number of untracked files.
80    pub num_untrached_files: u32,
81    /// Number of commits the current branch is ahead of upstream.
82    pub commits_ahead: u32,
83    /// Number of commits the current branch is behind upstream.
84    pub commits_behind: u32,
85    /// The number of stashes.
86    pub num_stashes: u32,
87    /// The last tag (in lexicographical order) that points to the same
88    /// commit as HEAD.
89    pub last_tag: String,
90    /// The number of unstaged deleted files.
91    pub num_unstaged_deleted: u32,
92    /// The number of staged new files.
93    pub num_staged_new: u32,
94    /// The number of staged deleted files.
95    pub num_staged_deleted: u32,
96    /// The push remote name, e.g. "upstream" or "origin".
97    pub push_remote_name: String,
98    /// Push remote URL. Can be empty.
99    pub push_remote_url: String,
100    /// Number of commits the current branch is ahead of push remote.
101    pub commits_ahead_push_remote: u32,
102    /// Number of commits the current branch is behind push remote.
103    pub commits_behind_push_remote: u32,
104    /// Number of files in the index with skip-worktree bit set.
105    pub num_index_skip_worktree: u32,
106    /// Number of files in the index with assume-unchanged bit set.
107    pub num_index_assume_unchanged: u32,
108}
109
110#[derive(Debug, PartialEq)]
111/// An error if the responce from gitstatusd couldn't be parsed
112pub enum ResponceParseError {
113    /// Not Enought Parts were recieved
114    TooShort,
115    /// A part was sent, but we cant parse it.
116    InvalidPart,
117    ParseIntError(std::num::ParseIntError),
118}
119
120impl From<std::num::ParseIntError> for ResponceParseError {
121    fn from(e: std::num::ParseIntError) -> Self {
122        Self::ParseIntError(e)
123    }
124}
125
126macro_rules! munch {
127    ($expr:expr) => {
128        match $expr.next() {
129            Some(v) => v,
130            None => return Err($crate::ResponceParseError::TooShort),
131        }
132    };
133}
134
135impl std::str::FromStr for GitStatus {
136    type Err = ResponceParseError;
137    fn from_str(s: &str) -> Result<Self, Self::Err> {
138        GitStatus::from_str(s)
139    }
140}
141
142impl GitStatus {
143    // TODO: Make this run on &[u8]
144    fn from_str(s: &str) -> Result<Self, ResponceParseError> {
145        let mut parts = s.split("\x1f");
146        let id = munch!(parts);
147        let is_repo = munch!(parts);
148        match is_repo {
149            "0" => {
150                return Ok(GitStatus {
151                    id: id.to_owned(),
152                    details: Option::None,
153                })
154            }
155            // 1 indicated a git repo, so we do the real stuff
156            "1" => {}
157            // If not 0 or 1, give up
158            _ => return Err(ResponceParseError::InvalidPart),
159        }
160
161        let abspath = munch!(parts);
162        let head_commit_hash = munch!(parts);
163        let local_branch = munch!(parts);
164        let upstream_branch = munch!(parts);
165        let remote_name = munch!(parts);
166        let remote_url = munch!(parts);
167        let repository_state = munch!(parts);
168
169        let num_files_in_index: u32 = munch!(parts).parse()?;
170        let num_staged_changes: u32 = munch!(parts).parse()?;
171        let num_unstaged_changes: u32 = munch!(parts).parse()?;
172        let num_conflicted_changes: u32 = munch!(parts).parse()?;
173        let num_untrached_files: u32 = munch!(parts).parse()?;
174        let commits_ahead: u32 = munch!(parts).parse()?;
175        let commits_behind: u32 = munch!(parts).parse()?;
176        let num_stashes: u32 = munch!(parts).parse()?;
177        let last_tag = munch!(parts);
178        let num_unstaged_deleted: u32 = munch!(parts).parse()?;
179        let num_staged_new: u32 = munch!(parts).parse()?;
180        let num_staged_deleted: u32 = munch!(parts).parse()?;
181        let push_remote_name = munch!(parts);
182        let push_remote_url: &str = munch!(parts);
183        let commits_ahead_push_remote: u32 = munch!(parts).parse()?;
184        let commits_behind_push_remote: u32 = munch!(parts).parse()?;
185        let num_index_skip_worktree: u32 = munch!(parts).parse()?;
186        let num_index_assume_unchanged: u32 = munch!(parts).parse()?;
187
188        // Only do ownership once we have all the stuff
189        let git_part = GitDetails {
190            abspath: abspath.to_owned(),
191            head_commit_hash: head_commit_hash.to_owned(),
192            local_branch: local_branch.to_owned(),
193            upstream_branch: upstream_branch.to_owned(),
194            remote_name: remote_name.to_owned(),
195            remote_url: remote_url.to_owned(),
196            repository_state: repository_state.to_owned(),
197            num_files_in_index,
198            num_staged_changes,
199            num_unstaged_changes,
200            num_conflicted_changes,
201            num_untrached_files,
202            commits_ahead,
203            commits_behind,
204            num_stashes,
205            last_tag: last_tag.to_owned(),
206            num_unstaged_deleted,
207            num_staged_new,
208            num_staged_deleted,
209            push_remote_name: push_remote_name.to_owned(),
210            push_remote_url: push_remote_url.to_owned(),
211            commits_ahead_push_remote,
212            commits_behind_push_remote,
213            num_index_skip_worktree,
214            num_index_assume_unchanged,
215        };
216
217        Ok(GitStatus {
218            id: id.to_owned(),
219            details: Option::Some(git_part),
220        })
221    }
222}
223
224///////////////////////////////////////////////////////////////////////////////
225// Request
226///////////////////////////////////////////////////////////////////////////////
227
228#[derive(Copy, Clone, Debug, Hash)]
229/// Tell gitstatusd weather or not to read the git index
230pub enum ReadIndex {
231    /// default behavior of computing everything
232    ReadAll = 0,
233    /// Disables computation of anything that requires reading git index
234    DontRead = 1,
235}
236
237/// A Request to be sent to the demon.
238pub struct StatusRequest {
239    // TODO: Are these always utf-8
240    // TODO: borrow these
241    /// The request Id, can be blank
242    pub id: String,
243    /// Path to the directory for which git stats are being requested.
244    ///
245    /// If the first character is ':', it is removed and the remaning path is 
246    /// treated as GIT_DIR.
247    pub dir: String,
248    /// Wether or not to read the git index
249    pub read_index: ReadIndex,
250}
251
252// TODO, this should probably work for non utf8.
253impl fmt::Display for StatusRequest {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        write!(
256            f,
257            "{id}\x1f{dir}\x1f{index}\x1e",
258            id = self.id,
259            dir = self.dir,
260            index = self.read_index as u8
261        )
262    }
263}
264
265///////////////////////////////////////////////////////////////////////////////
266// Status
267///////////////////////////////////////////////////////////////////////////////
268
269/// The daemon that gets `git status`
270///
271/// Idealy have one long running Daemon that is long running, so gitstatusd can
272/// take advantage of incremental stuff
273pub struct SatusDaemon {
274    // I need to store the child so it's pipes don't close
275    _proc: process::Child,
276    stdin: process::ChildStdin,
277    stdout: io::BufReader<process::ChildStdout>,
278    // TODO: decide if I need this
279    _stderr: process::ChildStderr,
280}
281
282impl SatusDaemon {
283    // TODO: does the path matter
284    // TODO: binary detection
285    /// Create a new status demon.
286    ///
287    /// - `bin_path`: The path to the `gitstatusd` binary.
288    /// - `run_dir`: The directory to run the binary in.
289    pub fn new<C: AsRef<OsStr> + Default, P: AsRef<Path>>(
290        bin_path: C,
291        run_dir: P,
292    ) -> io::Result<SatusDaemon> {
293        let mut proc = process::Command::new(bin_path)
294            .current_dir(run_dir)
295            .stdin(process::Stdio::piped())
296            .stdout(process::Stdio::piped())
297            .stderr(process::Stdio::piped())
298            .spawn()?;
299
300        let stdin = proc.stdin.take().ok_or(io::Error::new(
301            io::ErrorKind::BrokenPipe,
302            "Couldn't obtain stdin",
303        ))?;
304        let stdout = io::BufReader::new(proc.stdout.take().ok_or(
305            io::Error::new(io::ErrorKind::BrokenPipe, "Couldn't obtain stdout"),
306        )?);
307        let stderr = proc.stderr.take().ok_or(io::Error::new(
308            io::ErrorKind::BrokenPipe,
309            "Couldn't obtain stderr",
310        ))?;
311
312        Ok(SatusDaemon {
313            _proc: proc,
314            stdin,
315            stdout,
316            _stderr: stderr,
317        })
318    }
319
320    //TODO: Better Error Handling
321    //TODO: Non blocking version
322    //TODO: Id generation
323    /// Get the git status
324    pub fn request(&mut self, r: StatusRequest) -> io::Result<GitStatus> {
325        write!(self.stdin, "{}", r)?;
326        let mut read = Vec::with_capacity(256);
327        self.stdout.read_until(0x1e, &mut read)?;
328        assert_eq!(read.last(), Some(&0x1e));
329        // Drop the controll byte
330        read.truncate(read.len().saturating_sub(1));
331
332        // TODO: Handle error
333        // TODO: Check the id's are the same.
334        let read = String::from_utf8(read).unwrap();
335        let responce = GitStatus::from_str(&read).unwrap();
336        Ok(responce)
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use pretty_assertions::assert_eq;
344
345    #[test]
346    fn it_works() {
347        assert_eq!(2 + 2, 4);
348    }
349
350    #[test]
351    fn pickup_gsd() {
352        let gsd = SatusDaemon::new("./gitstatusd/usrbin/gitstatusd", ".");
353
354        assert!(gsd.is_ok());
355    }
356
357    #[test]
358    fn write_request() {
359        let req = StatusRequest {
360            id: "SomeID".to_owned(),
361            dir: "some/path".to_owned(),
362            read_index: ReadIndex::ReadAll,
363        };
364        let to_send = format!("{}", req);
365        assert_eq!(to_send, "SomeID\x1fsome/path\x1f0\x1e");
366
367        let req = StatusRequest {
368            id: "SomeOtherID".to_owned(),
369            dir: "some/other/path".to_owned(),
370            read_index: ReadIndex::DontRead,
371        };
372        let to_send = format!("{}", req);
373        assert_eq!(to_send, "SomeOtherID\x1fsome/other/path\x1f1\x1e");
374    }
375
376    #[test]
377    fn parse_responce_no_git() {
378        let resp1 = "id1\x1f0";
379        let r1p = resp1.parse();
380        assert_eq!(
381            r1p,
382            Ok(GitStatus {
383                id: "id1".to_owned(),
384                details: Option::None,
385            })
386        );
387    }
388
389    fn responce_test(s: &str, resp: Result<GitStatus, ResponceParseError>) {
390        let r_got = s.parse();
391        assert_eq!(r_got, resp);
392    }
393
394    #[test]
395    fn parse_responce_no_git_no_id() {
396        responce_test(
397            "\x1f0",
398            Ok(GitStatus {
399                id: "".to_owned(),
400                details: Option::None,
401            }),
402        );
403    }
404
405    #[test]
406    fn parse_responce_empty() {
407        responce_test("", Err(ResponceParseError::TooShort));
408    }
409
410    #[test]
411    fn parse_responce_git_full() {
412        responce_test(
413            "id\u{1f}1\u{1f}/Users/nixon/dev/rs/gitstatusd\u{1f}1c9be4fe5460a30e70de9cbf99c3ec7064296b28\u{1f}master\u{1f}\u{1f}\u{1f}\u{1f}\u{1f}7\u{1f}0\u{1f}1\u{1f}0\u{1f}1\u{1f}0\u{1f}0\u{1f}0\u{1f}\u{1f}0\u{1f}0\u{1f}0\u{1f}\u{1f}\u{1f}0\u{1f}0\u{1f}0\u{1f}0",
414            Ok(GitStatus {
415                id: "id".to_owned(),
416                details: Option::Some(GitDetails{
417                    abspath: "/Users/nixon/dev/rs/gitstatusd".to_owned(),
418                    head_commit_hash: "1c9be4fe5460a30e70de9cbf99c3ec7064296b28".to_owned(),
419                    local_branch: "master".to_owned(),
420                    upstream_branch: "".to_owned(),
421                    remote_name: "".to_owned(),
422                    remote_url: "".to_owned(),
423                    repository_state: "".to_owned(),
424                    num_files_in_index: 7,
425                    num_staged_changes: 0,
426                    num_unstaged_changes: 1,
427                    num_conflicted_changes: 0,
428                    num_untrached_files: 1,
429                    commits_ahead: 0,
430                    commits_behind: 0,
431                    num_stashes: 0,
432                    last_tag: "".to_owned(),
433                    num_unstaged_deleted: 0,
434                    num_staged_new: 0,
435                    num_staged_deleted: 0,
436                    push_remote_name: "".to_owned(),
437                    push_remote_url: "".to_owned(),
438                    commits_ahead_push_remote: 0,
439                    commits_behind_push_remote: 0,
440                    num_index_skip_worktree: 0,
441                    num_index_assume_unchanged: 0,
442                })
443            })
444        );
445    }
446
447    #[test]
448    fn run_this_dir_is_git() {
449        let req = StatusRequest {
450            id: "Request1".to_owned(),
451            dir: env!("CARGO_MANIFEST_DIR").to_owned(),
452            read_index: ReadIndex::ReadAll,
453        };
454        let mut gsd =
455            SatusDaemon::new("./gitstatusd/usrbin/gitstatusd", ".").unwrap();
456        let responce = gsd.request(req).unwrap();
457        assert!(matches!(responce.details, Option::Some(_)));
458    }
459}