Skip to main content

branchless/git/
status.rs

1use std::fmt::Display;
2use std::path::PathBuf;
3use std::str::FromStr;
4
5use bstr::ByteVec;
6use lazy_static::lazy_static;
7use regex::bytes::Regex;
8use tracing::{instrument, warn};
9
10/// A Git file status indicator.
11/// See <https://git-scm.com/docs/git-status#_short_format>.
12#[allow(missing_docs)]
13#[derive(Copy, Clone, Debug, PartialEq, Eq)]
14pub enum FileStatus {
15    Unmodified,
16    Modified,
17    Added,
18    Deleted,
19    Renamed,
20    Copied,
21    Unmerged,
22    Untracked,
23    Ignored,
24}
25
26impl FileStatus {
27    /// Determine if this status corresponds to a "changed" status, which means
28    /// that it should be included in a commit.
29    pub fn is_changed(&self) -> bool {
30        match self {
31            FileStatus::Added
32            | FileStatus::Copied
33            | FileStatus::Deleted
34            | FileStatus::Modified
35            | FileStatus::Renamed => true,
36            FileStatus::Ignored
37            | FileStatus::Unmerged
38            | FileStatus::Unmodified
39            | FileStatus::Untracked => false,
40        }
41    }
42}
43
44impl From<u8> for FileStatus {
45    fn from(status: u8) -> Self {
46        match status {
47            b'.' => FileStatus::Unmodified,
48            b'M' => FileStatus::Modified,
49            b'A' => FileStatus::Added,
50            b'D' => FileStatus::Deleted,
51            b'R' => FileStatus::Renamed,
52            b'C' => FileStatus::Copied,
53            b'U' => FileStatus::Unmerged,
54            b'?' => FileStatus::Untracked,
55            b'!' => FileStatus::Ignored,
56            _ => {
57                warn!(?status, "invalid status indicator");
58                FileStatus::Untracked
59            }
60        }
61    }
62}
63
64/// Wrapper around [git2::FileMode].
65#[allow(missing_docs)]
66#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
67pub enum FileMode {
68    Unreadable,
69    Tree,
70    Blob,
71    BlobExecutable,
72    BlobGroupWritable,
73    Link,
74    Commit,
75}
76
77impl From<git2::FileMode> for FileMode {
78    fn from(file_mode: git2::FileMode) -> Self {
79        match file_mode {
80            git2::FileMode::Blob => FileMode::Blob,
81            git2::FileMode::BlobExecutable => FileMode::BlobExecutable,
82            git2::FileMode::BlobGroupWritable => FileMode::BlobGroupWritable,
83            git2::FileMode::Commit => FileMode::Commit,
84            git2::FileMode::Link => FileMode::Link,
85            git2::FileMode::Tree => FileMode::Tree,
86            git2::FileMode::Unreadable => FileMode::Unreadable,
87        }
88    }
89}
90
91impl From<FileMode> for git2::FileMode {
92    fn from(file_mode: FileMode) -> Self {
93        match file_mode {
94            FileMode::Blob => git2::FileMode::Blob,
95            FileMode::BlobExecutable => git2::FileMode::BlobExecutable,
96            FileMode::BlobGroupWritable => git2::FileMode::BlobGroupWritable,
97            FileMode::Commit => git2::FileMode::Commit,
98            FileMode::Link => git2::FileMode::Link,
99            FileMode::Tree => git2::FileMode::Tree,
100            FileMode::Unreadable => git2::FileMode::Unreadable,
101        }
102    }
103}
104
105impl From<i32> for FileMode {
106    fn from(file_mode: i32) -> Self {
107        if file_mode == i32::from(git2::FileMode::Blob) {
108            FileMode::Blob
109        } else if file_mode == i32::from(git2::FileMode::BlobExecutable) {
110            FileMode::BlobExecutable
111        } else if file_mode == i32::from(git2::FileMode::Commit) {
112            FileMode::Commit
113        } else if file_mode == i32::from(git2::FileMode::Link) {
114            FileMode::Link
115        } else if file_mode == i32::from(git2::FileMode::Tree) {
116            FileMode::Tree
117        } else {
118            FileMode::Unreadable
119        }
120    }
121}
122
123impl From<FileMode> for i32 {
124    fn from(file_mode: FileMode) -> Self {
125        match file_mode {
126            FileMode::Blob => git2::FileMode::Blob.into(),
127            FileMode::BlobExecutable => git2::FileMode::BlobExecutable.into(),
128            FileMode::BlobGroupWritable => git2::FileMode::BlobGroupWritable.into(),
129            FileMode::Commit => git2::FileMode::Commit.into(),
130            FileMode::Link => git2::FileMode::Link.into(),
131            FileMode::Tree => git2::FileMode::Tree.into(),
132            FileMode::Unreadable => git2::FileMode::Unreadable.into(),
133        }
134    }
135}
136
137impl From<FileMode> for u32 {
138    fn from(file_mode: FileMode) -> Self {
139        i32::from(file_mode).try_into().unwrap()
140    }
141}
142
143impl From<scm_record::FileMode> for FileMode {
144    fn from(file_mode: scm_record::FileMode) -> Self {
145        match file_mode {
146            scm_record::FileMode::Unix(file_mode) => {
147                let file_mode: i32 = file_mode.try_into().unwrap();
148                Self::from(file_mode)
149            }
150            scm_record::FileMode::Absent => FileMode::Unreadable,
151        }
152    }
153}
154
155impl FromStr for FileMode {
156    type Err = eyre::Error;
157
158    // Parses the string representation of a filemode for a status entry.
159    // Git only supports a small subset of Unix octal file mode permissions.
160    // See http://git-scm.com/book/en/v2/Git-Internals-Git-Objects
161    fn from_str(file_mode: &str) -> eyre::Result<Self> {
162        let file_mode = match file_mode {
163            "000000" => FileMode::Unreadable,
164            "040000" => FileMode::Tree,
165            "100644" => FileMode::Blob,
166            "100755" => FileMode::BlobExecutable,
167            "100664" => FileMode::BlobGroupWritable,
168            "120000" => FileMode::Link,
169            "160000" => FileMode::Commit,
170            _ => eyre::bail!("unknown file mode: {}", file_mode),
171        };
172        Ok(file_mode)
173    }
174}
175
176impl Display for FileMode {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        match self {
179            FileMode::Unreadable => write!(f, "000000"),
180            FileMode::Tree => write!(f, "040000"),
181            FileMode::Blob => write!(f, "100644"),
182            FileMode::BlobExecutable => write!(f, "100755"),
183            FileMode::BlobGroupWritable => write!(f, "100664"),
184            FileMode::Link => write!(f, "120000"),
185            FileMode::Commit => write!(f, "160000"),
186        }
187    }
188}
189
190/// The status of a file in the repo.
191#[derive(Clone, Debug, PartialEq, Eq)]
192pub struct StatusEntry {
193    /// The status of the file in the index.
194    pub index_status: FileStatus,
195    /// The status of the file in the working copy.
196    pub working_copy_status: FileStatus,
197    /// The file mode of the file in the working copy.
198    pub working_copy_file_mode: FileMode,
199    /// The file path.
200    pub path: PathBuf,
201    /// The original path of the file (for renamed files).
202    pub orig_path: Option<PathBuf>,
203}
204
205impl StatusEntry {
206    /// Create a status entry for a currently-untracked, to-be-added file.
207    pub fn new_untracked(filename: String) -> Self {
208        StatusEntry {
209            index_status: FileStatus::Untracked,
210            working_copy_status: FileStatus::Untracked,
211            working_copy_file_mode: FileMode::Blob,
212            path: PathBuf::from(filename),
213            orig_path: None,
214        }
215    }
216
217    /// Returns the paths associated with the status entry.
218    pub fn paths(&self) -> Vec<PathBuf> {
219        let mut result = vec![self.path.clone()];
220        if let Some(orig_path) = &self.orig_path {
221            result.push(orig_path.clone());
222        }
223        result
224    }
225}
226
227impl TryFrom<&[u8]> for StatusEntry {
228    type Error = eyre::Error;
229
230    #[instrument]
231    fn try_from(line: &[u8]) -> eyre::Result<StatusEntry> {
232        lazy_static! {
233            /// Parses an entry of the git porcelain v2 status format.
234            /// See https://git-scm.com/docs/git-status#_porcelain_format_version_2
235            static ref STATUS_PORCELAIN_V2_REGEXP: Regex = Regex::new(concat!(
236                r#"^(?P<prefix>1|2|u) "#,                                    // Prefix.
237                r#"(?P<index_status>[\w.])(?P<working_copy_status>[\w.]) "#, // Status indicators.
238                r#"[\w.]+ "#,                                                // Submodule state.
239                r#"(\d{6} ){2,3}(?P<working_copy_filemode>\d{6}) "#,         // HEAD, Index, and Working Copy file modes;
240                                                                             // or stage1, stage2, stage3, and working copy file modes.
241                r#"([a-f\d]+ ){2,3}([CR]\d{1,3} )?"#,                        // HEAD and Index object IDs, and optionally the rename/copy score;
242                                                                             // or stage1, stage2, and stage3 object IDs.
243                r#"(?P<path>[^\x00]+)(\x00(?P<orig_path>[^\x00]+))?$"#       // Path and original path (for renames/copies).
244            ))
245            .expect("porcelain v2 status line regex");
246        }
247
248        let status_line_parts = STATUS_PORCELAIN_V2_REGEXP
249            .captures(line)
250            .ok_or_else(|| eyre::eyre!("unable to parse status line into parts"))?;
251
252        let index_status: FileStatus = match status_line_parts.name("prefix") {
253            Some(m) if m.as_bytes() == b"u" => FileStatus::Unmerged,
254            _ => status_line_parts
255                .name("index_status")
256                .and_then(|m| m.as_bytes().iter().next().copied())
257                .ok_or_else(|| eyre::eyre!("no index status indicator"))?
258                .into(),
259        };
260        let working_copy_status: FileStatus = status_line_parts
261            .name("working_copy_status")
262            .and_then(|m| m.as_bytes().iter().next().copied())
263            .ok_or_else(|| eyre::eyre!("no working copy status indicator"))?
264            .into();
265        let working_copy_file_mode = status_line_parts
266            .name("working_copy_filemode")
267            .ok_or_else(|| eyre::eyre!("no working copy filemode in status line"))
268            .and_then(|m| {
269                std::str::from_utf8(m.as_bytes())
270                    .map_err(|err| {
271                        eyre::eyre!("unable to decode working copy file mode: {:?}", err)
272                    })
273                    .and_then(|working_copy_file_mode| working_copy_file_mode.parse::<FileMode>())
274            })?;
275        let path = status_line_parts
276            .name("path")
277            .ok_or_else(|| eyre::eyre!("no path in status line"))?
278            .as_bytes();
279        let orig_path = status_line_parts
280            .name("orig_path")
281            .map(|orig_path| orig_path.as_bytes());
282
283        Ok(StatusEntry {
284            index_status,
285            working_copy_status,
286            working_copy_file_mode,
287            path: path.to_vec().into_path_buf()?,
288            orig_path: orig_path
289                .map(|orig_path| orig_path.to_vec().into_path_buf())
290                .transpose()?,
291        })
292    }
293}