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