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 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 oid = head.get_path(path).ok()?.id();
290 let content = repo.find_blob(oid).log_err()?.content().to_owned();
291 let content = String::from_utf8(content).log_err()?;
292 Some(content)
293 }
294
295 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
296 let working_directory = self
297 .repository
298 .lock()
299 .workdir()
300 .context("failed to read git work directory")?
301 .to_path_buf();
302 if let Some(content) = content {
303 let mut child = new_std_command(&self.git_binary_path)
304 .current_dir(&working_directory)
305 .args(["hash-object", "-w", "--stdin"])
306 .stdin(Stdio::piped())
307 .stdout(Stdio::piped())
308 .spawn()?;
309 child.stdin.take().unwrap().write_all(content.as_bytes())?;
310 let output = child.wait_with_output()?.stdout;
311 let sha = String::from_utf8(output)?;
312
313 log::debug!("indexing SHA: {sha}, path {path:?}");
314
315 let status = new_std_command(&self.git_binary_path)
316 .current_dir(&working_directory)
317 .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
318 .arg(path.as_ref())
319 .status()?;
320
321 if !status.success() {
322 return Err(anyhow!("Failed to add to index: {status:?}"));
323 }
324 } else {
325 let status = new_std_command(&self.git_binary_path)
326 .current_dir(&working_directory)
327 .args(["update-index", "--force-remove"])
328 .arg(path.as_ref())
329 .status()?;
330
331 if !status.success() {
332 return Err(anyhow!("Failed to remove from index: {status:?}"));
333 }
334 }
335
336 Ok(())
337 }
338
339 fn remote_url(&self, name: &str) -> Option<String> {
340 let repo = self.repository.lock();
341 let remote = repo.find_remote(name).ok()?;
342 remote.url().map(|url| url.to_string())
343 }
344
345 fn head_sha(&self) -> Option<String> {
346 Some(self.repository.lock().head().ok()?.target()?.to_string())
347 }
348
349 fn merge_head_shas(&self) -> Vec<String> {
350 let mut shas = Vec::default();
351 self.repository
352 .lock()
353 .mergehead_foreach(|oid| {
354 shas.push(oid.to_string());
355 true
356 })
357 .ok();
358 shas
359 }
360
361 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
362 let working_directory = self
363 .repository
364 .lock()
365 .workdir()
366 .context("failed to read git work directory")?
367 .to_path_buf();
368 GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
369 }
370
371 fn branch_exits(&self, name: &str) -> Result<bool> {
372 let repo = self.repository.lock();
373 let branch = repo.find_branch(name, BranchType::Local);
374 match branch {
375 Ok(_) => Ok(true),
376 Err(e) => match e.code() {
377 git2::ErrorCode::NotFound => Ok(false),
378 _ => Err(anyhow!(e)),
379 },
380 }
381 }
382
383 fn branches(&self) -> Result<Vec<Branch>> {
384 let working_directory = self
385 .repository
386 .lock()
387 .workdir()
388 .context("failed to read git work directory")?
389 .to_path_buf();
390 let fields = [
391 "%(HEAD)",
392 "%(objectname)",
393 "%(refname)",
394 "%(upstream)",
395 "%(upstream:track)",
396 "%(committerdate:unix)",
397 "%(contents:subject)",
398 ]
399 .join("%00");
400 let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
401
402 let output = new_std_command(&self.git_binary_path)
403 .current_dir(&working_directory)
404 .args(args)
405 .output()?;
406
407 if !output.status.success() {
408 return Err(anyhow!(
409 "Failed to git git branches:\n{}",
410 String::from_utf8_lossy(&output.stderr)
411 ));
412 }
413
414 let input = String::from_utf8_lossy(&output.stdout);
415
416 let mut branches = parse_branch_input(&input)?;
417 if branches.is_empty() {
418 let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
419
420 let output = new_std_command(&self.git_binary_path)
421 .current_dir(&working_directory)
422 .args(args)
423 .output()?;
424
425 // git symbolic-ref returns a non-0 exit code if HEAD points
426 // to something other than a branch
427 if output.status.success() {
428 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
429
430 branches.push(Branch {
431 name: name.into(),
432 is_head: true,
433 upstream: None,
434 most_recent_commit: None,
435 });
436 }
437 }
438
439 Ok(branches)
440 }
441
442 fn change_branch(&self, name: &str) -> Result<()> {
443 let repo = self.repository.lock();
444 let revision = repo.find_branch(name, BranchType::Local)?;
445 let revision = revision.get();
446 let as_tree = revision.peel_to_tree()?;
447 repo.checkout_tree(as_tree.as_object(), None)?;
448 repo.set_head(
449 revision
450 .name()
451 .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
452 )?;
453 Ok(())
454 }
455
456 fn create_branch(&self, name: &str) -> Result<()> {
457 let repo = self.repository.lock();
458 let current_commit = repo.head()?.peel_to_commit()?;
459 repo.branch(name, ¤t_commit, false)?;
460 Ok(())
461 }
462
463 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
464 let working_directory = self
465 .repository
466 .lock()
467 .workdir()
468 .with_context(|| format!("failed to get git working directory for file {:?}", path))?
469 .to_path_buf();
470
471 const REMOTE_NAME: &str = "origin";
472 let remote_url = self.remote_url(REMOTE_NAME);
473
474 crate::blame::Blame::for_path(
475 &self.git_binary_path,
476 &working_directory,
477 path,
478 &content,
479 remote_url,
480 self.hosting_provider_registry.clone(),
481 )
482 }
483
484 fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
485 let working_directory = self
486 .repository
487 .lock()
488 .workdir()
489 .context("failed to read git work directory")?
490 .to_path_buf();
491
492 if !paths.is_empty() {
493 let output = new_std_command(&self.git_binary_path)
494 .current_dir(&working_directory)
495 .args(["update-index", "--add", "--remove", "--"])
496 .args(paths.iter().map(|p| p.as_ref()))
497 .output()?;
498 if !output.status.success() {
499 return Err(anyhow!(
500 "Failed to stage paths:\n{}",
501 String::from_utf8_lossy(&output.stderr)
502 ));
503 }
504 }
505 Ok(())
506 }
507
508 fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
509 let working_directory = self
510 .repository
511 .lock()
512 .workdir()
513 .context("failed to read git work directory")?
514 .to_path_buf();
515
516 if !paths.is_empty() {
517 let output = new_std_command(&self.git_binary_path)
518 .current_dir(&working_directory)
519 .args(["reset", "--quiet", "--"])
520 .args(paths.iter().map(|p| p.as_ref()))
521 .output()?;
522 if !output.status.success() {
523 return Err(anyhow!(
524 "Failed to unstage:\n{}",
525 String::from_utf8_lossy(&output.stderr)
526 ));
527 }
528 }
529 Ok(())
530 }
531
532 fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> {
533 let working_directory = self
534 .repository
535 .lock()
536 .workdir()
537 .context("failed to read git work directory")?
538 .to_path_buf();
539 let mut args = vec!["commit", "--quiet", "-m", message, "--cleanup=strip"];
540 let author = name_and_email.map(|(name, email)| format!("{name} <{email}>"));
541 if let Some(author) = author.as_deref() {
542 args.push("--author");
543 args.push(author);
544 }
545
546 let output = new_std_command(&self.git_binary_path)
547 .current_dir(&working_directory)
548 .args(args)
549 .output()?;
550
551 if !output.status.success() {
552 return Err(anyhow!(
553 "Failed to commit:\n{}",
554 String::from_utf8_lossy(&output.stderr)
555 ));
556 }
557 Ok(())
558 }
559}
560
561#[derive(Debug, Clone)]
562pub struct FakeGitRepository {
563 state: Arc<Mutex<FakeGitRepositoryState>>,
564}
565
566#[derive(Debug, Clone)]
567pub struct FakeGitRepositoryState {
568 pub path: PathBuf,
569 pub event_emitter: smol::channel::Sender<PathBuf>,
570 pub head_contents: HashMap<RepoPath, String>,
571 pub index_contents: HashMap<RepoPath, String>,
572 pub blames: HashMap<RepoPath, Blame>,
573 pub statuses: HashMap<RepoPath, FileStatus>,
574 pub current_branch_name: Option<String>,
575 pub branches: HashSet<String>,
576}
577
578impl FakeGitRepository {
579 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
580 Arc::new(FakeGitRepository { state })
581 }
582}
583
584impl FakeGitRepositoryState {
585 pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
586 FakeGitRepositoryState {
587 path,
588 event_emitter,
589 head_contents: Default::default(),
590 index_contents: Default::default(),
591 blames: Default::default(),
592 statuses: Default::default(),
593 current_branch_name: Default::default(),
594 branches: Default::default(),
595 }
596 }
597}
598
599impl GitRepository for FakeGitRepository {
600 fn reload_index(&self) {}
601
602 fn load_index_text(&self, path: &RepoPath) -> Option<String> {
603 let state = self.state.lock();
604 state.index_contents.get(path.as_ref()).cloned()
605 }
606
607 fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
608 let state = self.state.lock();
609 state.head_contents.get(path.as_ref()).cloned()
610 }
611
612 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
613 let mut state = self.state.lock();
614 if let Some(content) = content {
615 state.index_contents.insert(path.clone(), content);
616 } else {
617 state.index_contents.remove(path);
618 }
619 state
620 .event_emitter
621 .try_send(state.path.clone())
622 .expect("Dropped repo change event");
623 Ok(())
624 }
625
626 fn remote_url(&self, _name: &str) -> Option<String> {
627 None
628 }
629
630 fn head_sha(&self) -> Option<String> {
631 None
632 }
633
634 fn merge_head_shas(&self) -> Vec<String> {
635 vec![]
636 }
637
638 fn show(&self, _: &str) -> Result<CommitDetails> {
639 unimplemented!()
640 }
641
642 fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
643 unimplemented!()
644 }
645
646 fn checkout_files(&self, _: &str, _: &[RepoPath]) -> Result<()> {
647 unimplemented!()
648 }
649
650 fn path(&self) -> PathBuf {
651 let state = self.state.lock();
652 state.path.clone()
653 }
654
655 fn main_repository_path(&self) -> PathBuf {
656 self.path()
657 }
658
659 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
660 let state = self.state.lock();
661
662 let mut entries = state
663 .statuses
664 .iter()
665 .filter_map(|(repo_path, status)| {
666 if path_prefixes
667 .iter()
668 .any(|path_prefix| repo_path.0.starts_with(path_prefix))
669 {
670 Some((repo_path.to_owned(), *status))
671 } else {
672 None
673 }
674 })
675 .collect::<Vec<_>>();
676 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
677
678 Ok(GitStatus {
679 entries: entries.into(),
680 })
681 }
682
683 fn branches(&self) -> Result<Vec<Branch>> {
684 let state = self.state.lock();
685 let current_branch = &state.current_branch_name;
686 Ok(state
687 .branches
688 .iter()
689 .map(|branch_name| Branch {
690 is_head: Some(branch_name) == current_branch.as_ref(),
691 name: branch_name.into(),
692 most_recent_commit: None,
693 upstream: None,
694 })
695 .collect())
696 }
697
698 fn branch_exits(&self, name: &str) -> Result<bool> {
699 let state = self.state.lock();
700 Ok(state.branches.contains(name))
701 }
702
703 fn change_branch(&self, name: &str) -> Result<()> {
704 let mut state = self.state.lock();
705 state.current_branch_name = Some(name.to_owned());
706 state
707 .event_emitter
708 .try_send(state.path.clone())
709 .expect("Dropped repo change event");
710 Ok(())
711 }
712
713 fn create_branch(&self, name: &str) -> Result<()> {
714 let mut state = self.state.lock();
715 state.branches.insert(name.to_owned());
716 state
717 .event_emitter
718 .try_send(state.path.clone())
719 .expect("Dropped repo change event");
720 Ok(())
721 }
722
723 fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
724 let state = self.state.lock();
725 state
726 .blames
727 .get(path)
728 .with_context(|| format!("failed to get blame for {:?}", path))
729 .cloned()
730 }
731
732 fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
733 unimplemented!()
734 }
735
736 fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
737 unimplemented!()
738 }
739
740 fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
741 unimplemented!()
742 }
743}
744
745fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
746 match relative_file_path.components().next() {
747 None => anyhow::bail!("repo path should not be empty"),
748 Some(Component::Prefix(_)) => anyhow::bail!(
749 "repo path `{}` should be relative, not a windows prefix",
750 relative_file_path.to_string_lossy()
751 ),
752 Some(Component::RootDir) => {
753 anyhow::bail!(
754 "repo path `{}` should be relative",
755 relative_file_path.to_string_lossy()
756 )
757 }
758 Some(Component::CurDir) => {
759 anyhow::bail!(
760 "repo path `{}` should not start with `.`",
761 relative_file_path.to_string_lossy()
762 )
763 }
764 Some(Component::ParentDir) => {
765 anyhow::bail!(
766 "repo path `{}` should not start with `..`",
767 relative_file_path.to_string_lossy()
768 )
769 }
770 _ => Ok(()),
771 }
772}
773
774pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
775 LazyLock::new(|| RepoPath(Path::new("").into()));
776
777#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
778pub struct RepoPath(pub Arc<Path>);
779
780impl RepoPath {
781 pub fn new(path: PathBuf) -> Self {
782 debug_assert!(path.is_relative(), "Repo paths must be relative");
783
784 RepoPath(path.into())
785 }
786
787 pub fn from_str(path: &str) -> Self {
788 let path = Path::new(path);
789 debug_assert!(path.is_relative(), "Repo paths must be relative");
790
791 RepoPath(path.into())
792 }
793}
794
795impl std::fmt::Display for RepoPath {
796 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
797 self.0.to_string_lossy().fmt(f)
798 }
799}
800
801impl From<&Path> for RepoPath {
802 fn from(value: &Path) -> Self {
803 RepoPath::new(value.into())
804 }
805}
806
807impl From<Arc<Path>> for RepoPath {
808 fn from(value: Arc<Path>) -> Self {
809 RepoPath(value)
810 }
811}
812
813impl From<PathBuf> for RepoPath {
814 fn from(value: PathBuf) -> Self {
815 RepoPath::new(value)
816 }
817}
818
819impl From<&str> for RepoPath {
820 fn from(value: &str) -> Self {
821 Self::from_str(value)
822 }
823}
824
825impl Default for RepoPath {
826 fn default() -> Self {
827 RepoPath(Path::new("").into())
828 }
829}
830
831impl AsRef<Path> for RepoPath {
832 fn as_ref(&self) -> &Path {
833 self.0.as_ref()
834 }
835}
836
837impl std::ops::Deref for RepoPath {
838 type Target = Path;
839
840 fn deref(&self) -> &Self::Target {
841 &self.0
842 }
843}
844
845impl Borrow<Path> for RepoPath {
846 fn borrow(&self) -> &Path {
847 self.0.as_ref()
848 }
849}
850
851#[derive(Debug)]
852pub struct RepoPathDescendants<'a>(pub &'a Path);
853
854impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
855 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
856 if key.starts_with(self.0) {
857 Ordering::Greater
858 } else {
859 self.0.cmp(key)
860 }
861 }
862}
863
864fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
865 let mut branches = Vec::new();
866 for line in input.split('\n') {
867 if line.is_empty() {
868 continue;
869 }
870 let mut fields = line.split('\x00');
871 let is_current_branch = fields.next().context("no HEAD")? == "*";
872 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
873 let ref_name: SharedString = fields
874 .next()
875 .context("no refname")?
876 .strip_prefix("refs/heads/")
877 .context("unexpected format for refname")?
878 .to_string()
879 .into();
880 let upstream_name = fields.next().context("no upstream")?.to_string();
881 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
882 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
883 let subject: SharedString = fields
884 .next()
885 .context("no contents:subject")?
886 .to_string()
887 .into();
888
889 branches.push(Branch {
890 is_head: is_current_branch,
891 name: ref_name,
892 most_recent_commit: Some(CommitSummary {
893 sha: head_sha,
894 subject,
895 commit_timestamp: commiterdate,
896 }),
897 upstream: if upstream_name.is_empty() {
898 None
899 } else {
900 Some(Upstream {
901 ref_name: upstream_name.into(),
902 tracking: upstream_tracking,
903 })
904 },
905 })
906 }
907
908 Ok(branches)
909}
910
911fn parse_upstream_track(upstream_track: &str) -> Result<Option<UpstreamTracking>> {
912 if upstream_track == "" {
913 return Ok(Some(UpstreamTracking {
914 ahead: 0,
915 behind: 0,
916 }));
917 }
918
919 let upstream_track = upstream_track
920 .strip_prefix("[")
921 .ok_or_else(|| anyhow!("missing ["))?;
922 let upstream_track = upstream_track
923 .strip_suffix("]")
924 .ok_or_else(|| anyhow!("missing ["))?;
925 let mut ahead: u32 = 0;
926 let mut behind: u32 = 0;
927 for component in upstream_track.split(", ") {
928 if component == "gone" {
929 return Ok(None);
930 }
931 if let Some(ahead_num) = component.strip_prefix("ahead ") {
932 ahead = ahead_num.parse::<u32>()?;
933 }
934 if let Some(behind_num) = component.strip_prefix("behind ") {
935 behind = behind_num.parse::<u32>()?;
936 }
937 }
938 Ok(Some(UpstreamTracking { ahead, behind }))
939}
940
941#[test]
942fn test_branches_parsing() {
943 // suppress "help: octal escapes are not supported, `\0` is always null"
944 #[allow(clippy::octal_escapes)]
945 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
946 assert_eq!(
947 parse_branch_input(&input).unwrap(),
948 vec![Branch {
949 is_head: true,
950 name: "zed-patches".into(),
951 upstream: Some(Upstream {
952 ref_name: "refs/remotes/origin/zed-patches".into(),
953 tracking: Some(UpstreamTracking {
954 ahead: 0,
955 behind: 0
956 })
957 }),
958 most_recent_commit: Some(CommitSummary {
959 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
960 subject: "generated protobuf".into(),
961 commit_timestamp: 1733187470,
962 })
963 }]
964 )
965}