1use crate::status::FileStatus;
2use crate::GitHostingProviderRegistry;
3use crate::{blame::Blame, status::GitStatus};
4use anyhow::{anyhow, Context, Result};
5use collections::{HashMap, HashSet};
6use git2::BranchType;
7use gpui::SharedString;
8use parking_lot::Mutex;
9use rope::Rope;
10use std::borrow::Borrow;
11use std::io::Write as _;
12use std::process::Stdio;
13use std::sync::LazyLock;
14use std::{
15 cmp::Ordering,
16 path::{Component, Path, PathBuf},
17 sync::Arc,
18};
19use sum_tree::MapSeekTarget;
20use util::command::new_std_command;
21use util::ResultExt;
22
23#[derive(Clone, Debug, Hash, PartialEq, Eq)]
24pub struct Branch {
25 pub is_head: bool,
26 pub name: SharedString,
27 pub upstream: Option<Upstream>,
28 pub most_recent_commit: Option<CommitSummary>,
29}
30
31impl Branch {
32 pub fn priority_key(&self) -> (bool, Option<i64>) {
33 (
34 self.is_head,
35 self.most_recent_commit
36 .as_ref()
37 .map(|commit| commit.commit_timestamp),
38 )
39 }
40}
41
42#[derive(Clone, Debug, Hash, PartialEq, Eq)]
43pub struct Upstream {
44 pub ref_name: SharedString,
45 pub tracking: Option<UpstreamTracking>,
46}
47
48#[derive(Clone, Debug, Hash, PartialEq, Eq)]
49pub struct UpstreamTracking {
50 pub ahead: u32,
51 pub behind: u32,
52}
53
54#[derive(Clone, Debug, Hash, PartialEq, Eq)]
55pub struct CommitSummary {
56 pub sha: SharedString,
57 pub subject: SharedString,
58 /// This is a unix timestamp
59 pub commit_timestamp: i64,
60}
61
62#[derive(Clone, Debug, Hash, PartialEq, Eq)]
63pub struct CommitDetails {
64 pub sha: SharedString,
65 pub message: SharedString,
66 pub commit_timestamp: i64,
67 pub committer_email: SharedString,
68 pub committer_name: SharedString,
69}
70
71pub enum ResetMode {
72 // reset the branch pointer, leave index and worktree unchanged
73 // (this will make it look like things that were committed are now
74 // staged)
75 Soft,
76 // reset the branch pointer and index, leave worktree unchanged
77 // (this makes it look as though things that were committed are now
78 // unstaged)
79 Mixed,
80}
81
82pub trait GitRepository: Send + Sync {
83 fn reload_index(&self);
84
85 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
86 ///
87 /// Note that for symlink entries, this will return the contents of the symlink, not the target.
88 fn load_index_text(&self, path: &RepoPath) -> Option<String>;
89
90 /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path.
91 ///
92 /// Note that for symlink entries, this will return the contents of the symlink, not the target.
93 fn load_committed_text(&self, path: &RepoPath) -> Option<String>;
94
95 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()>;
96
97 /// Returns the URL of the remote with the given name.
98 fn remote_url(&self, name: &str) -> Option<String>;
99
100 /// Returns the SHA of the current HEAD.
101 fn head_sha(&self) -> Option<String>;
102
103 fn merge_head_shas(&self) -> Vec<String>;
104
105 /// Returns the list of git statuses, sorted by path
106 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
107
108 fn branches(&self) -> Result<Vec<Branch>>;
109 fn change_branch(&self, _: &str) -> Result<()>;
110 fn create_branch(&self, _: &str) -> Result<()>;
111 fn branch_exits(&self, _: &str) -> Result<bool>;
112
113 fn reset(&self, commit: &str, mode: ResetMode) -> Result<()>;
114
115 fn show(&self, commit: &str) -> Result<CommitDetails>;
116
117 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
118
119 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
120 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
121 fn path(&self) -> PathBuf;
122
123 /// Returns the absolute path to the ".git" dir for the main repository, typically a `.git`
124 /// folder. For worktrees, this will be the path to the repository the worktree was created
125 /// from. Otherwise, this is the same value as `path()`.
126 ///
127 /// Git documentation calls this the "commondir", and for git CLI is overridden by
128 /// `GIT_COMMON_DIR`.
129 fn main_repository_path(&self) -> PathBuf;
130
131 /// Updates the index to match the worktree at the given paths.
132 ///
133 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
134 fn stage_paths(&self, paths: &[RepoPath]) -> Result<()>;
135 /// Updates the index to match HEAD at the given paths.
136 ///
137 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
138 fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()>;
139
140 fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()>;
141}
142
143impl std::fmt::Debug for dyn GitRepository {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.debug_struct("dyn GitRepository<...>").finish()
146 }
147}
148
149pub struct RealGitRepository {
150 pub repository: Mutex<git2::Repository>,
151 pub git_binary_path: PathBuf,
152 hosting_provider_registry: Arc<GitHostingProviderRegistry>,
153}
154
155impl RealGitRepository {
156 pub fn new(
157 repository: git2::Repository,
158 git_binary_path: Option<PathBuf>,
159 hosting_provider_registry: Arc<GitHostingProviderRegistry>,
160 ) -> Self {
161 Self {
162 repository: Mutex::new(repository),
163 git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
164 hosting_provider_registry,
165 }
166 }
167}
168
169// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
170const GIT_MODE_SYMLINK: u32 = 0o120000;
171
172impl GitRepository for RealGitRepository {
173 fn reload_index(&self) {
174 if let Ok(mut index) = self.repository.lock().index() {
175 _ = index.read(false);
176 }
177 }
178
179 fn path(&self) -> PathBuf {
180 let repo = self.repository.lock();
181 repo.path().into()
182 }
183
184 fn main_repository_path(&self) -> PathBuf {
185 let repo = self.repository.lock();
186 repo.commondir().into()
187 }
188
189 fn show(&self, commit: &str) -> Result<CommitDetails> {
190 let repo = self.repository.lock();
191 let Ok(commit) = repo.revparse_single(commit)?.into_commit() else {
192 anyhow::bail!("{} is not a commit", commit);
193 };
194 let details = CommitDetails {
195 sha: commit.id().to_string().into(),
196 message: String::from_utf8_lossy(commit.message_raw_bytes())
197 .to_string()
198 .into(),
199 commit_timestamp: commit.time().seconds(),
200 committer_email: String::from_utf8_lossy(commit.committer().email_bytes())
201 .to_string()
202 .into(),
203 committer_name: String::from_utf8_lossy(commit.committer().name_bytes())
204 .to_string()
205 .into(),
206 };
207 Ok(details)
208 }
209
210 fn reset(&self, commit: &str, mode: ResetMode) -> Result<()> {
211 let working_directory = self
212 .repository
213 .lock()
214 .workdir()
215 .context("failed to read git work directory")?
216 .to_path_buf();
217
218 let mode_flag = match mode {
219 ResetMode::Mixed => "--mixed",
220 ResetMode::Soft => "--soft",
221 };
222
223 let output = new_std_command(&self.git_binary_path)
224 .current_dir(&working_directory)
225 .args(["reset", mode_flag, commit])
226 .output()?;
227 if !output.status.success() {
228 return Err(anyhow!(
229 "Failed to reset:\n{}",
230 String::from_utf8_lossy(&output.stderr)
231 ));
232 }
233 Ok(())
234 }
235
236 fn load_index_text(&self, path: &RepoPath) -> Option<String> {
237 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
238 const STAGE_NORMAL: i32 = 0;
239 let index = repo.index()?;
240
241 // This check is required because index.get_path() unwraps internally :(
242 check_path_to_repo_path_errors(path)?;
243
244 let oid = match index.get_path(path, STAGE_NORMAL) {
245 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
246 _ => return Ok(None),
247 };
248
249 let content = repo.find_blob(oid)?.content().to_owned();
250 Ok(Some(String::from_utf8(content)?))
251 }
252
253 match logic(&self.repository.lock(), path) {
254 Ok(value) => return value,
255 Err(err) => log::error!("Error loading index text: {:?}", err),
256 }
257 None
258 }
259
260 fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
261 let repo = self.repository.lock();
262 let head = repo.head().ok()?.peel_to_tree().log_err()?;
263 let oid = head.get_path(path).ok()?.id();
264 let content = repo.find_blob(oid).log_err()?.content().to_owned();
265 let content = String::from_utf8(content).log_err()?;
266 Some(content)
267 }
268
269 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
270 let working_directory = self
271 .repository
272 .lock()
273 .workdir()
274 .context("failed to read git work directory")?
275 .to_path_buf();
276 if let Some(content) = content {
277 let mut child = new_std_command(&self.git_binary_path)
278 .current_dir(&working_directory)
279 .args(["hash-object", "-w", "--stdin"])
280 .stdin(Stdio::piped())
281 .stdout(Stdio::piped())
282 .spawn()?;
283 child.stdin.take().unwrap().write_all(content.as_bytes())?;
284 let output = child.wait_with_output()?.stdout;
285 let sha = String::from_utf8(output)?;
286
287 log::debug!("indexing SHA: {sha}, path {path:?}");
288
289 let status = new_std_command(&self.git_binary_path)
290 .current_dir(&working_directory)
291 .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
292 .arg(path.as_ref())
293 .status()?;
294
295 if !status.success() {
296 return Err(anyhow!("Failed to add to index: {status:?}"));
297 }
298 } else {
299 let status = new_std_command(&self.git_binary_path)
300 .current_dir(&working_directory)
301 .args(["update-index", "--force-remove"])
302 .arg(path.as_ref())
303 .status()?;
304
305 if !status.success() {
306 return Err(anyhow!("Failed to remove from index: {status:?}"));
307 }
308 }
309
310 Ok(())
311 }
312
313 fn remote_url(&self, name: &str) -> Option<String> {
314 let repo = self.repository.lock();
315 let remote = repo.find_remote(name).ok()?;
316 remote.url().map(|url| url.to_string())
317 }
318
319 fn head_sha(&self) -> Option<String> {
320 Some(self.repository.lock().head().ok()?.target()?.to_string())
321 }
322
323 fn merge_head_shas(&self) -> Vec<String> {
324 let mut shas = Vec::default();
325 self.repository
326 .lock()
327 .mergehead_foreach(|oid| {
328 shas.push(oid.to_string());
329 true
330 })
331 .ok();
332 shas
333 }
334
335 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
336 let working_directory = self
337 .repository
338 .lock()
339 .workdir()
340 .context("failed to read git work directory")?
341 .to_path_buf();
342 GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
343 }
344
345 fn branch_exits(&self, name: &str) -> Result<bool> {
346 let repo = self.repository.lock();
347 let branch = repo.find_branch(name, BranchType::Local);
348 match branch {
349 Ok(_) => Ok(true),
350 Err(e) => match e.code() {
351 git2::ErrorCode::NotFound => Ok(false),
352 _ => Err(anyhow!(e)),
353 },
354 }
355 }
356
357 fn branches(&self) -> Result<Vec<Branch>> {
358 let working_directory = self
359 .repository
360 .lock()
361 .workdir()
362 .context("failed to read git work directory")?
363 .to_path_buf();
364 let fields = [
365 "%(HEAD)",
366 "%(objectname)",
367 "%(refname)",
368 "%(upstream)",
369 "%(upstream:track)",
370 "%(committerdate:unix)",
371 "%(contents:subject)",
372 ]
373 .join("%00");
374 let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
375
376 let output = new_std_command(&self.git_binary_path)
377 .current_dir(&working_directory)
378 .args(args)
379 .output()?;
380
381 if !output.status.success() {
382 return Err(anyhow!(
383 "Failed to git git branches:\n{}",
384 String::from_utf8_lossy(&output.stderr)
385 ));
386 }
387
388 let input = String::from_utf8_lossy(&output.stdout);
389
390 let mut branches = parse_branch_input(&input)?;
391 if branches.is_empty() {
392 let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
393
394 let output = new_std_command(&self.git_binary_path)
395 .current_dir(&working_directory)
396 .args(args)
397 .output()?;
398
399 // git symbolic-ref returns a non-0 exit code if HEAD points
400 // to something other than a branch
401 if output.status.success() {
402 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
403
404 branches.push(Branch {
405 name: name.into(),
406 is_head: true,
407 upstream: None,
408 most_recent_commit: None,
409 });
410 }
411 }
412
413 Ok(branches)
414 }
415
416 fn change_branch(&self, name: &str) -> Result<()> {
417 let repo = self.repository.lock();
418 let revision = repo.find_branch(name, BranchType::Local)?;
419 let revision = revision.get();
420 let as_tree = revision.peel_to_tree()?;
421 repo.checkout_tree(as_tree.as_object(), None)?;
422 repo.set_head(
423 revision
424 .name()
425 .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
426 )?;
427 Ok(())
428 }
429
430 fn create_branch(&self, name: &str) -> Result<()> {
431 let repo = self.repository.lock();
432 let current_commit = repo.head()?.peel_to_commit()?;
433 repo.branch(name, ¤t_commit, false)?;
434 Ok(())
435 }
436
437 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
438 let working_directory = self
439 .repository
440 .lock()
441 .workdir()
442 .with_context(|| format!("failed to get git working directory for file {:?}", path))?
443 .to_path_buf();
444
445 const REMOTE_NAME: &str = "origin";
446 let remote_url = self.remote_url(REMOTE_NAME);
447
448 crate::blame::Blame::for_path(
449 &self.git_binary_path,
450 &working_directory,
451 path,
452 &content,
453 remote_url,
454 self.hosting_provider_registry.clone(),
455 )
456 }
457
458 fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
459 let working_directory = self
460 .repository
461 .lock()
462 .workdir()
463 .context("failed to read git work directory")?
464 .to_path_buf();
465
466 if !paths.is_empty() {
467 let output = new_std_command(&self.git_binary_path)
468 .current_dir(&working_directory)
469 .args(["update-index", "--add", "--remove", "--"])
470 .args(paths.iter().map(|p| p.as_ref()))
471 .output()?;
472 if !output.status.success() {
473 return Err(anyhow!(
474 "Failed to stage paths:\n{}",
475 String::from_utf8_lossy(&output.stderr)
476 ));
477 }
478 }
479 Ok(())
480 }
481
482 fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
483 let working_directory = self
484 .repository
485 .lock()
486 .workdir()
487 .context("failed to read git work directory")?
488 .to_path_buf();
489
490 if !paths.is_empty() {
491 let output = new_std_command(&self.git_binary_path)
492 .current_dir(&working_directory)
493 .args(["reset", "--quiet", "--"])
494 .args(paths.iter().map(|p| p.as_ref()))
495 .output()?;
496 if !output.status.success() {
497 return Err(anyhow!(
498 "Failed to unstage:\n{}",
499 String::from_utf8_lossy(&output.stderr)
500 ));
501 }
502 }
503 Ok(())
504 }
505
506 fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> {
507 let working_directory = self
508 .repository
509 .lock()
510 .workdir()
511 .context("failed to read git work directory")?
512 .to_path_buf();
513 let mut args = vec!["commit", "--quiet", "-m", message, "--cleanup=strip"];
514 let author = name_and_email.map(|(name, email)| format!("{name} <{email}>"));
515 if let Some(author) = author.as_deref() {
516 args.push("--author");
517 args.push(author);
518 }
519
520 let output = new_std_command(&self.git_binary_path)
521 .current_dir(&working_directory)
522 .args(args)
523 .output()?;
524
525 if !output.status.success() {
526 return Err(anyhow!(
527 "Failed to commit:\n{}",
528 String::from_utf8_lossy(&output.stderr)
529 ));
530 }
531 Ok(())
532 }
533}
534
535#[derive(Debug, Clone)]
536pub struct FakeGitRepository {
537 state: Arc<Mutex<FakeGitRepositoryState>>,
538}
539
540#[derive(Debug, Clone)]
541pub struct FakeGitRepositoryState {
542 pub path: PathBuf,
543 pub event_emitter: smol::channel::Sender<PathBuf>,
544 pub head_contents: HashMap<RepoPath, String>,
545 pub index_contents: HashMap<RepoPath, String>,
546 pub blames: HashMap<RepoPath, Blame>,
547 pub statuses: HashMap<RepoPath, FileStatus>,
548 pub current_branch_name: Option<String>,
549 pub branches: HashSet<String>,
550}
551
552impl FakeGitRepository {
553 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
554 Arc::new(FakeGitRepository { state })
555 }
556}
557
558impl FakeGitRepositoryState {
559 pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
560 FakeGitRepositoryState {
561 path,
562 event_emitter,
563 head_contents: Default::default(),
564 index_contents: Default::default(),
565 blames: Default::default(),
566 statuses: Default::default(),
567 current_branch_name: Default::default(),
568 branches: Default::default(),
569 }
570 }
571}
572
573impl GitRepository for FakeGitRepository {
574 fn reload_index(&self) {}
575
576 fn load_index_text(&self, path: &RepoPath) -> Option<String> {
577 let state = self.state.lock();
578 state.index_contents.get(path.as_ref()).cloned()
579 }
580
581 fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
582 let state = self.state.lock();
583 state.head_contents.get(path.as_ref()).cloned()
584 }
585
586 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
587 let mut state = self.state.lock();
588 if let Some(content) = content {
589 state.index_contents.insert(path.clone(), content);
590 } else {
591 state.index_contents.remove(path);
592 }
593 state
594 .event_emitter
595 .try_send(state.path.clone())
596 .expect("Dropped repo change event");
597 Ok(())
598 }
599
600 fn remote_url(&self, _name: &str) -> Option<String> {
601 None
602 }
603
604 fn head_sha(&self) -> Option<String> {
605 None
606 }
607
608 fn merge_head_shas(&self) -> Vec<String> {
609 vec![]
610 }
611
612 fn show(&self, _: &str) -> Result<CommitDetails> {
613 unimplemented!()
614 }
615
616 fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
617 unimplemented!()
618 }
619
620 fn path(&self) -> PathBuf {
621 let state = self.state.lock();
622 state.path.clone()
623 }
624
625 fn main_repository_path(&self) -> PathBuf {
626 self.path()
627 }
628
629 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
630 let state = self.state.lock();
631
632 let mut entries = state
633 .statuses
634 .iter()
635 .filter_map(|(repo_path, status)| {
636 if path_prefixes
637 .iter()
638 .any(|path_prefix| repo_path.0.starts_with(path_prefix))
639 {
640 Some((repo_path.to_owned(), *status))
641 } else {
642 None
643 }
644 })
645 .collect::<Vec<_>>();
646 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
647
648 Ok(GitStatus {
649 entries: entries.into(),
650 })
651 }
652
653 fn branches(&self) -> Result<Vec<Branch>> {
654 let state = self.state.lock();
655 let current_branch = &state.current_branch_name;
656 Ok(state
657 .branches
658 .iter()
659 .map(|branch_name| Branch {
660 is_head: Some(branch_name) == current_branch.as_ref(),
661 name: branch_name.into(),
662 most_recent_commit: None,
663 upstream: None,
664 })
665 .collect())
666 }
667
668 fn branch_exits(&self, name: &str) -> Result<bool> {
669 let state = self.state.lock();
670 Ok(state.branches.contains(name))
671 }
672
673 fn change_branch(&self, name: &str) -> Result<()> {
674 let mut state = self.state.lock();
675 state.current_branch_name = Some(name.to_owned());
676 state
677 .event_emitter
678 .try_send(state.path.clone())
679 .expect("Dropped repo change event");
680 Ok(())
681 }
682
683 fn create_branch(&self, name: &str) -> Result<()> {
684 let mut state = self.state.lock();
685 state.branches.insert(name.to_owned());
686 state
687 .event_emitter
688 .try_send(state.path.clone())
689 .expect("Dropped repo change event");
690 Ok(())
691 }
692
693 fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
694 let state = self.state.lock();
695 state
696 .blames
697 .get(path)
698 .with_context(|| format!("failed to get blame for {:?}", path))
699 .cloned()
700 }
701
702 fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
703 unimplemented!()
704 }
705
706 fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
707 unimplemented!()
708 }
709
710 fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
711 unimplemented!()
712 }
713}
714
715fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
716 match relative_file_path.components().next() {
717 None => anyhow::bail!("repo path should not be empty"),
718 Some(Component::Prefix(_)) => anyhow::bail!(
719 "repo path `{}` should be relative, not a windows prefix",
720 relative_file_path.to_string_lossy()
721 ),
722 Some(Component::RootDir) => {
723 anyhow::bail!(
724 "repo path `{}` should be relative",
725 relative_file_path.to_string_lossy()
726 )
727 }
728 Some(Component::CurDir) => {
729 anyhow::bail!(
730 "repo path `{}` should not start with `.`",
731 relative_file_path.to_string_lossy()
732 )
733 }
734 Some(Component::ParentDir) => {
735 anyhow::bail!(
736 "repo path `{}` should not start with `..`",
737 relative_file_path.to_string_lossy()
738 )
739 }
740 _ => Ok(()),
741 }
742}
743
744pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
745 LazyLock::new(|| RepoPath(Path::new("").into()));
746
747#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
748pub struct RepoPath(pub Arc<Path>);
749
750impl RepoPath {
751 pub fn new(path: PathBuf) -> Self {
752 debug_assert!(path.is_relative(), "Repo paths must be relative");
753
754 RepoPath(path.into())
755 }
756
757 pub fn from_str(path: &str) -> Self {
758 let path = Path::new(path);
759 debug_assert!(path.is_relative(), "Repo paths must be relative");
760
761 RepoPath(path.into())
762 }
763}
764
765impl std::fmt::Display for RepoPath {
766 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767 self.0.to_string_lossy().fmt(f)
768 }
769}
770
771impl From<&Path> for RepoPath {
772 fn from(value: &Path) -> Self {
773 RepoPath::new(value.into())
774 }
775}
776
777impl From<Arc<Path>> for RepoPath {
778 fn from(value: Arc<Path>) -> Self {
779 RepoPath(value)
780 }
781}
782
783impl From<PathBuf> for RepoPath {
784 fn from(value: PathBuf) -> Self {
785 RepoPath::new(value)
786 }
787}
788
789impl From<&str> for RepoPath {
790 fn from(value: &str) -> Self {
791 Self::from_str(value)
792 }
793}
794
795impl Default for RepoPath {
796 fn default() -> Self {
797 RepoPath(Path::new("").into())
798 }
799}
800
801impl AsRef<Path> for RepoPath {
802 fn as_ref(&self) -> &Path {
803 self.0.as_ref()
804 }
805}
806
807impl std::ops::Deref for RepoPath {
808 type Target = Path;
809
810 fn deref(&self) -> &Self::Target {
811 &self.0
812 }
813}
814
815impl Borrow<Path> for RepoPath {
816 fn borrow(&self) -> &Path {
817 self.0.as_ref()
818 }
819}
820
821#[derive(Debug)]
822pub struct RepoPathDescendants<'a>(pub &'a Path);
823
824impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
825 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
826 if key.starts_with(self.0) {
827 Ordering::Greater
828 } else {
829 self.0.cmp(key)
830 }
831 }
832}
833
834fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
835 let mut branches = Vec::new();
836 for line in input.split('\n') {
837 if line.is_empty() {
838 continue;
839 }
840 let mut fields = line.split('\x00');
841 let is_current_branch = fields.next().context("no HEAD")? == "*";
842 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
843 let ref_name: SharedString = fields
844 .next()
845 .context("no refname")?
846 .strip_prefix("refs/heads/")
847 .context("unexpected format for refname")?
848 .to_string()
849 .into();
850 let upstream_name = fields.next().context("no upstream")?.to_string();
851 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
852 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
853 let subject: SharedString = fields
854 .next()
855 .context("no contents:subject")?
856 .to_string()
857 .into();
858
859 branches.push(Branch {
860 is_head: is_current_branch,
861 name: ref_name,
862 most_recent_commit: Some(CommitSummary {
863 sha: head_sha,
864 subject,
865 commit_timestamp: commiterdate,
866 }),
867 upstream: if upstream_name.is_empty() {
868 None
869 } else {
870 Some(Upstream {
871 ref_name: upstream_name.into(),
872 tracking: upstream_tracking,
873 })
874 },
875 })
876 }
877
878 Ok(branches)
879}
880
881fn parse_upstream_track(upstream_track: &str) -> Result<Option<UpstreamTracking>> {
882 if upstream_track == "" {
883 return Ok(Some(UpstreamTracking {
884 ahead: 0,
885 behind: 0,
886 }));
887 }
888
889 let upstream_track = upstream_track
890 .strip_prefix("[")
891 .ok_or_else(|| anyhow!("missing ["))?;
892 let upstream_track = upstream_track
893 .strip_suffix("]")
894 .ok_or_else(|| anyhow!("missing ["))?;
895 let mut ahead: u32 = 0;
896 let mut behind: u32 = 0;
897 for component in upstream_track.split(", ") {
898 if component == "gone" {
899 return Ok(None);
900 }
901 if let Some(ahead_num) = component.strip_prefix("ahead ") {
902 ahead = ahead_num.parse::<u32>()?;
903 }
904 if let Some(behind_num) = component.strip_prefix("behind ") {
905 behind = behind_num.parse::<u32>()?;
906 }
907 }
908 Ok(Some(UpstreamTracking { ahead, behind }))
909}
910
911#[test]
912fn test_branches_parsing() {
913 // suppress "help: octal escapes are not supported, `\0` is always null"
914 #[allow(clippy::octal_escapes)]
915 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
916 assert_eq!(
917 parse_branch_input(&input).unwrap(),
918 vec![Branch {
919 is_head: true,
920 name: "zed-patches".into(),
921 upstream: Some(Upstream {
922 ref_name: "refs/remotes/origin/zed-patches".into(),
923 tracking: Some(UpstreamTracking {
924 ahead: 0,
925 behind: 0
926 })
927 }),
928 most_recent_commit: Some(CommitSummary {
929 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
930 subject: "generated protobuf".into(),
931 commit_timestamp: 1733187470,
932 })
933 }]
934 )
935}