1use anyhow::Result;
2use collections::HashMap;
3use git2::{BranchType, ErrorCode};
4use parking_lot::Mutex;
5use rpc::proto;
6use serde_derive::{Deserialize, Serialize};
7use std::{
8 cmp::Ordering,
9 ffi::OsStr,
10 os::unix::prelude::OsStrExt,
11 path::{Component, Path, PathBuf},
12 sync::Arc,
13};
14use sum_tree::{MapSeekTarget, TreeMap};
15use util::ResultExt;
16
17pub use git2::Repository as LibGitRepository;
18
19#[derive(Clone, Debug, Hash, PartialEq)]
20pub struct Branch {
21 pub name: Box<str>,
22 /// Timestamp of most recent commit, normalized to Unix Epoch format.
23 pub unix_timestamp: Option<i64>,
24}
25#[async_trait::async_trait]
26pub trait GitRepository: Send {
27 fn reload_index(&self);
28
29 fn load_index_text(&self, relative_file_path: &Path) -> Option<String>;
30
31 fn branch_name(&self) -> Option<String>;
32
33 fn statuses(&self) -> Option<TreeMap<RepoPath, GitFileStatus>>;
34
35 fn status(&self, path: &RepoPath) -> Result<Option<GitFileStatus>>;
36 fn branches(&self) -> Result<Vec<Branch>> {
37 Ok(vec![])
38 }
39 fn change_branch(&self, _: &str) -> Result<()> {
40 Ok(())
41 }
42 fn create_branch(&self, _: &str) -> Result<()> {
43 Ok(())
44 }
45}
46
47impl std::fmt::Debug for dyn GitRepository {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.debug_struct("dyn GitRepository<...>").finish()
50 }
51}
52
53#[async_trait::async_trait]
54impl GitRepository for LibGitRepository {
55 fn reload_index(&self) {
56 if let Ok(mut index) = self.index() {
57 _ = index.read(false);
58 }
59 }
60
61 fn load_index_text(&self, relative_file_path: &Path) -> Option<String> {
62 fn logic(repo: &LibGitRepository, relative_file_path: &Path) -> Result<Option<String>> {
63 const STAGE_NORMAL: i32 = 0;
64 let index = repo.index()?;
65
66 // This check is required because index.get_path() unwraps internally :(
67 check_path_to_repo_path_errors(relative_file_path)?;
68
69 let oid = match index.get_path(&relative_file_path, STAGE_NORMAL) {
70 Some(entry) => entry.id,
71 None => return Ok(None),
72 };
73
74 let content = repo.find_blob(oid)?.content().to_owned();
75 Ok(Some(String::from_utf8(content)?))
76 }
77
78 match logic(&self, relative_file_path) {
79 Ok(value) => return value,
80 Err(err) => log::error!("Error loading head text: {:?}", err),
81 }
82 None
83 }
84
85 fn branch_name(&self) -> Option<String> {
86 let head = self.head().log_err()?;
87 let branch = String::from_utf8_lossy(head.shorthand_bytes());
88 Some(branch.to_string())
89 }
90
91 fn statuses(&self) -> Option<TreeMap<RepoPath, GitFileStatus>> {
92 let statuses = self.statuses(None).log_err()?;
93
94 let mut map = TreeMap::default();
95
96 for status in statuses
97 .iter()
98 .filter(|status| !status.status().contains(git2::Status::IGNORED))
99 {
100 let path = RepoPath(PathBuf::from(OsStr::from_bytes(status.path_bytes())));
101 let Some(status) = read_status(status.status()) else {
102 continue
103 };
104
105 map.insert(path, status)
106 }
107
108 Some(map)
109 }
110
111 fn status(&self, path: &RepoPath) -> Result<Option<GitFileStatus>> {
112 let status = self.status_file(path);
113 match status {
114 Ok(status) => Ok(read_status(status)),
115 Err(e) => {
116 if e.code() == ErrorCode::NotFound {
117 Ok(None)
118 } else {
119 Err(e.into())
120 }
121 }
122 }
123 }
124 fn branches(&self) -> Result<Vec<Branch>> {
125 let local_branches = self.branches(Some(BranchType::Local))?;
126 let valid_branches = local_branches
127 .filter_map(|branch| {
128 branch.ok().and_then(|(branch, _)| {
129 let name = branch.name().ok().flatten().map(Box::from)?;
130 let timestamp = branch.get().peel_to_commit().ok()?.time();
131 let unix_timestamp = timestamp.seconds();
132 let timezone_offset = timestamp.offset_minutes();
133 let utc_offset =
134 time::UtcOffset::from_whole_seconds(timezone_offset * 60).ok()?;
135 let unix_timestamp =
136 time::OffsetDateTime::from_unix_timestamp(unix_timestamp).ok()?;
137 Some(Branch {
138 name,
139 unix_timestamp: Some(unix_timestamp.to_offset(utc_offset).unix_timestamp()),
140 })
141 })
142 })
143 .collect();
144 Ok(valid_branches)
145 }
146 fn change_branch(&self, name: &str) -> Result<()> {
147 let revision = self.find_branch(name, BranchType::Local)?;
148 let revision = revision.get();
149 let as_tree = revision.peel_to_tree()?;
150 self.checkout_tree(as_tree.as_object(), None)?;
151 self.set_head(
152 revision
153 .name()
154 .ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?,
155 )?;
156 Ok(())
157 }
158 fn create_branch(&self, name: &str) -> Result<()> {
159 let current_commit = self.head()?.peel_to_commit()?;
160 self.branch(name, ¤t_commit, false)?;
161
162 Ok(())
163 }
164}
165
166fn read_status(status: git2::Status) -> Option<GitFileStatus> {
167 if status.contains(git2::Status::CONFLICTED) {
168 Some(GitFileStatus::Conflict)
169 } else if status.intersects(
170 git2::Status::WT_MODIFIED
171 | git2::Status::WT_RENAMED
172 | git2::Status::INDEX_MODIFIED
173 | git2::Status::INDEX_RENAMED,
174 ) {
175 Some(GitFileStatus::Modified)
176 } else if status.intersects(git2::Status::WT_NEW | git2::Status::INDEX_NEW) {
177 Some(GitFileStatus::Added)
178 } else {
179 None
180 }
181}
182
183#[derive(Debug, Clone, Default)]
184pub struct FakeGitRepository {
185 state: Arc<Mutex<FakeGitRepositoryState>>,
186}
187
188#[derive(Debug, Clone, Default)]
189pub struct FakeGitRepositoryState {
190 pub index_contents: HashMap<PathBuf, String>,
191 pub worktree_statuses: HashMap<RepoPath, GitFileStatus>,
192 pub branch_name: Option<String>,
193}
194
195impl FakeGitRepository {
196 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<Mutex<dyn GitRepository>> {
197 Arc::new(Mutex::new(FakeGitRepository { state }))
198 }
199}
200
201#[async_trait::async_trait]
202impl GitRepository for FakeGitRepository {
203 fn reload_index(&self) {}
204
205 fn load_index_text(&self, path: &Path) -> Option<String> {
206 let state = self.state.lock();
207 state.index_contents.get(path).cloned()
208 }
209
210 fn branch_name(&self) -> Option<String> {
211 let state = self.state.lock();
212 state.branch_name.clone()
213 }
214
215 fn statuses(&self) -> Option<TreeMap<RepoPath, GitFileStatus>> {
216 let state = self.state.lock();
217 let mut map = TreeMap::default();
218 for (repo_path, status) in state.worktree_statuses.iter() {
219 map.insert(repo_path.to_owned(), status.to_owned());
220 }
221 Some(map)
222 }
223
224 fn status(&self, path: &RepoPath) -> Result<Option<GitFileStatus>> {
225 let state = self.state.lock();
226 Ok(state.worktree_statuses.get(path).cloned())
227 }
228}
229
230fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
231 match relative_file_path.components().next() {
232 None => anyhow::bail!("repo path should not be empty"),
233 Some(Component::Prefix(_)) => anyhow::bail!(
234 "repo path `{}` should be relative, not a windows prefix",
235 relative_file_path.to_string_lossy()
236 ),
237 Some(Component::RootDir) => {
238 anyhow::bail!(
239 "repo path `{}` should be relative",
240 relative_file_path.to_string_lossy()
241 )
242 }
243 Some(Component::CurDir) => {
244 anyhow::bail!(
245 "repo path `{}` should not start with `.`",
246 relative_file_path.to_string_lossy()
247 )
248 }
249 Some(Component::ParentDir) => {
250 anyhow::bail!(
251 "repo path `{}` should not start with `..`",
252 relative_file_path.to_string_lossy()
253 )
254 }
255 _ => Ok(()),
256 }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260pub enum GitFileStatus {
261 Added,
262 Modified,
263 Conflict,
264}
265
266impl GitFileStatus {
267 pub fn merge(
268 this: Option<GitFileStatus>,
269 other: Option<GitFileStatus>,
270 prefer_other: bool,
271 ) -> Option<GitFileStatus> {
272 if prefer_other {
273 return other;
274 } else {
275 match (this, other) {
276 (Some(GitFileStatus::Conflict), _) | (_, Some(GitFileStatus::Conflict)) => {
277 Some(GitFileStatus::Conflict)
278 }
279 (Some(GitFileStatus::Modified), _) | (_, Some(GitFileStatus::Modified)) => {
280 Some(GitFileStatus::Modified)
281 }
282 (Some(GitFileStatus::Added), _) | (_, Some(GitFileStatus::Added)) => {
283 Some(GitFileStatus::Added)
284 }
285 _ => None,
286 }
287 }
288 }
289
290 pub fn from_proto(git_status: Option<i32>) -> Option<GitFileStatus> {
291 git_status.and_then(|status| {
292 proto::GitStatus::from_i32(status).map(|status| match status {
293 proto::GitStatus::Added => GitFileStatus::Added,
294 proto::GitStatus::Modified => GitFileStatus::Modified,
295 proto::GitStatus::Conflict => GitFileStatus::Conflict,
296 })
297 })
298 }
299
300 pub fn to_proto(self) -> i32 {
301 match self {
302 GitFileStatus::Added => proto::GitStatus::Added as i32,
303 GitFileStatus::Modified => proto::GitStatus::Modified as i32,
304 GitFileStatus::Conflict => proto::GitStatus::Conflict as i32,
305 }
306 }
307}
308
309#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
310pub struct RepoPath(pub PathBuf);
311
312impl RepoPath {
313 pub fn new(path: PathBuf) -> Self {
314 debug_assert!(path.is_relative(), "Repo paths must be relative");
315
316 RepoPath(path)
317 }
318}
319
320impl From<&Path> for RepoPath {
321 fn from(value: &Path) -> Self {
322 RepoPath::new(value.to_path_buf())
323 }
324}
325
326impl From<PathBuf> for RepoPath {
327 fn from(value: PathBuf) -> Self {
328 RepoPath::new(value)
329 }
330}
331
332impl Default for RepoPath {
333 fn default() -> Self {
334 RepoPath(PathBuf::new())
335 }
336}
337
338impl AsRef<Path> for RepoPath {
339 fn as_ref(&self) -> &Path {
340 self.0.as_ref()
341 }
342}
343
344impl std::ops::Deref for RepoPath {
345 type Target = PathBuf;
346
347 fn deref(&self) -> &Self::Target {
348 &self.0
349 }
350}
351
352#[derive(Debug)]
353pub struct RepoPathDescendants<'a>(pub &'a Path);
354
355impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
356 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
357 if key.starts_with(&self.0) {
358 Ordering::Greater
359 } else {
360 self.0.cmp(key)
361 }
362 }
363}