1use crate::status::FileStatus;
2use crate::GitHostingProviderRegistry;
3use crate::{blame::Blame, status::GitStatus};
4use anyhow::{anyhow, Context, Result};
5use askpass::{AskPassResult, AskPassSession};
6use collections::{HashMap, HashSet};
7use futures::{select_biased, FutureExt as _};
8use git2::BranchType;
9use gpui::SharedString;
10use parking_lot::Mutex;
11use rope::Rope;
12use schemars::JsonSchema;
13use serde::Deserialize;
14use std::borrow::Borrow;
15use std::io::Write as _;
16use std::process::Stdio;
17use std::sync::LazyLock;
18use std::{
19 cmp::Ordering,
20 path::{Component, Path, PathBuf},
21 sync::Arc,
22};
23use sum_tree::MapSeekTarget;
24use util::command::{new_smol_command, new_std_command};
25use util::ResultExt;
26
27pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
28
29#[derive(Clone, Debug, Hash, PartialEq, Eq)]
30pub struct Branch {
31 pub is_head: bool,
32 pub name: SharedString,
33 pub upstream: Option<Upstream>,
34 pub most_recent_commit: Option<CommitSummary>,
35}
36
37impl Branch {
38 pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
39 self.upstream
40 .as_ref()
41 .and_then(|upstream| upstream.tracking.status())
42 }
43
44 pub fn priority_key(&self) -> (bool, Option<i64>) {
45 (
46 self.is_head,
47 self.most_recent_commit
48 .as_ref()
49 .map(|commit| commit.commit_timestamp),
50 )
51 }
52}
53
54#[derive(Clone, Debug, Hash, PartialEq, Eq)]
55pub struct Upstream {
56 pub ref_name: SharedString,
57 pub tracking: UpstreamTracking,
58}
59
60#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
61pub enum UpstreamTracking {
62 /// Remote ref not present in local repository.
63 Gone,
64 /// Remote ref present in local repository (fetched from remote).
65 Tracked(UpstreamTrackingStatus),
66}
67
68impl From<UpstreamTrackingStatus> for UpstreamTracking {
69 fn from(status: UpstreamTrackingStatus) -> Self {
70 UpstreamTracking::Tracked(status)
71 }
72}
73
74impl UpstreamTracking {
75 pub fn is_gone(&self) -> bool {
76 matches!(self, UpstreamTracking::Gone)
77 }
78
79 pub fn status(&self) -> Option<UpstreamTrackingStatus> {
80 match self {
81 UpstreamTracking::Gone => None,
82 UpstreamTracking::Tracked(status) => Some(*status),
83 }
84 }
85}
86
87#[derive(Debug)]
88pub struct RemoteCommandOutput {
89 pub stdout: String,
90 pub stderr: String,
91}
92
93impl RemoteCommandOutput {
94 pub fn is_empty(&self) -> bool {
95 self.stdout.is_empty() && self.stderr.is_empty()
96 }
97}
98
99#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
100pub struct UpstreamTrackingStatus {
101 pub ahead: u32,
102 pub behind: u32,
103}
104
105#[derive(Clone, Debug, Hash, PartialEq, Eq)]
106pub struct CommitSummary {
107 pub sha: SharedString,
108 pub subject: SharedString,
109 /// This is a unix timestamp
110 pub commit_timestamp: i64,
111}
112
113#[derive(Clone, Debug, Hash, PartialEq, Eq)]
114pub struct CommitDetails {
115 pub sha: SharedString,
116 pub message: SharedString,
117 pub commit_timestamp: i64,
118 pub committer_email: SharedString,
119 pub committer_name: SharedString,
120}
121
122#[derive(Debug, Clone, Hash, PartialEq, Eq)]
123pub struct Remote {
124 pub name: SharedString,
125}
126
127pub enum ResetMode {
128 // reset the branch pointer, leave index and worktree unchanged
129 // (this will make it look like things that were committed are now
130 // staged)
131 Soft,
132 // reset the branch pointer and index, leave worktree unchanged
133 // (this makes it look as though things that were committed are now
134 // unstaged)
135 Mixed,
136}
137
138pub trait GitRepository: Send + Sync {
139 fn reload_index(&self);
140
141 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
142 ///
143 /// Also returns `None` for symlinks.
144 fn load_index_text(&self, path: &RepoPath) -> Option<String>;
145
146 /// 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.
147 ///
148 /// Also returns `None` for symlinks.
149 fn load_committed_text(&self, path: &RepoPath) -> Option<String>;
150
151 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()>;
152
153 /// Returns the URL of the remote with the given name.
154 fn remote_url(&self, name: &str) -> Option<String>;
155
156 /// Returns the SHA of the current HEAD.
157 fn head_sha(&self) -> Option<String>;
158
159 fn merge_head_shas(&self) -> Vec<String>;
160
161 /// Returns the list of git statuses, sorted by path
162 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
163
164 fn branches(&self) -> Result<Vec<Branch>>;
165 fn change_branch(&self, _: &str) -> Result<()>;
166 fn create_branch(&self, _: &str) -> Result<()>;
167 fn branch_exits(&self, _: &str) -> Result<bool>;
168
169 fn reset(&self, commit: &str, mode: ResetMode) -> Result<()>;
170 fn checkout_files(&self, commit: &str, paths: &[RepoPath]) -> Result<()>;
171
172 fn show(&self, commit: &str) -> Result<CommitDetails>;
173
174 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
175
176 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
177 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
178 fn path(&self) -> PathBuf;
179
180 /// Returns the absolute path to the ".git" dir for the main repository, typically a `.git`
181 /// folder. For worktrees, this will be the path to the repository the worktree was created
182 /// from. Otherwise, this is the same value as `path()`.
183 ///
184 /// Git documentation calls this the "commondir", and for git CLI is overridden by
185 /// `GIT_COMMON_DIR`.
186 fn main_repository_path(&self) -> PathBuf;
187
188 /// Updates the index to match the worktree at the given paths.
189 ///
190 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
191 fn stage_paths(&self, paths: &[RepoPath]) -> Result<()>;
192 /// Updates the index to match HEAD at the given paths.
193 ///
194 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
195 fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()>;
196
197 fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()>;
198
199 fn push(
200 &self,
201 branch_name: &str,
202 upstream_name: &str,
203 options: Option<PushOptions>,
204 askpass: AskPassSession,
205 ) -> Result<RemoteCommandOutput>;
206
207 fn pull(
208 &self,
209 branch_name: &str,
210 upstream_name: &str,
211 askpass: AskPassSession,
212 ) -> Result<RemoteCommandOutput>;
213 fn fetch(&self, askpass: AskPassSession) -> Result<RemoteCommandOutput>;
214
215 fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>>;
216
217 /// returns a list of remote branches that contain HEAD
218 fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>>;
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
222pub enum PushOptions {
223 SetUpstream,
224 Force,
225}
226
227impl std::fmt::Debug for dyn GitRepository {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 f.debug_struct("dyn GitRepository<...>").finish()
230 }
231}
232
233pub struct RealGitRepository {
234 pub repository: Mutex<git2::Repository>,
235 pub git_binary_path: PathBuf,
236 hosting_provider_registry: Arc<GitHostingProviderRegistry>,
237}
238
239impl RealGitRepository {
240 pub fn new(
241 repository: git2::Repository,
242 git_binary_path: Option<PathBuf>,
243 hosting_provider_registry: Arc<GitHostingProviderRegistry>,
244 ) -> Self {
245 Self {
246 repository: Mutex::new(repository),
247 git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
248 hosting_provider_registry,
249 }
250 }
251
252 fn working_directory(&self) -> Result<PathBuf> {
253 self.repository
254 .lock()
255 .workdir()
256 .context("failed to read git work directory")
257 .map(Path::to_path_buf)
258 }
259}
260
261// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
262const GIT_MODE_SYMLINK: u32 = 0o120000;
263
264impl GitRepository for RealGitRepository {
265 fn reload_index(&self) {
266 if let Ok(mut index) = self.repository.lock().index() {
267 _ = index.read(false);
268 }
269 }
270
271 fn path(&self) -> PathBuf {
272 let repo = self.repository.lock();
273 repo.path().into()
274 }
275
276 fn main_repository_path(&self) -> PathBuf {
277 let repo = self.repository.lock();
278 repo.commondir().into()
279 }
280
281 fn show(&self, commit: &str) -> Result<CommitDetails> {
282 let repo = self.repository.lock();
283 let Ok(commit) = repo.revparse_single(commit)?.into_commit() else {
284 anyhow::bail!("{} is not a commit", commit);
285 };
286 let details = CommitDetails {
287 sha: commit.id().to_string().into(),
288 message: String::from_utf8_lossy(commit.message_raw_bytes())
289 .to_string()
290 .into(),
291 commit_timestamp: commit.time().seconds(),
292 committer_email: String::from_utf8_lossy(commit.committer().email_bytes())
293 .to_string()
294 .into(),
295 committer_name: String::from_utf8_lossy(commit.committer().name_bytes())
296 .to_string()
297 .into(),
298 };
299 Ok(details)
300 }
301
302 fn reset(&self, commit: &str, mode: ResetMode) -> Result<()> {
303 let working_directory = self.working_directory()?;
304
305 let mode_flag = match mode {
306 ResetMode::Mixed => "--mixed",
307 ResetMode::Soft => "--soft",
308 };
309
310 let output = new_std_command(&self.git_binary_path)
311 .current_dir(&working_directory)
312 .args(["reset", mode_flag, commit])
313 .output()?;
314 if !output.status.success() {
315 return Err(anyhow!(
316 "Failed to reset:\n{}",
317 String::from_utf8_lossy(&output.stderr)
318 ));
319 }
320 Ok(())
321 }
322
323 fn checkout_files(&self, commit: &str, paths: &[RepoPath]) -> Result<()> {
324 if paths.is_empty() {
325 return Ok(());
326 }
327 let working_directory = self.working_directory()?;
328
329 let output = new_std_command(&self.git_binary_path)
330 .current_dir(&working_directory)
331 .args(["checkout", commit, "--"])
332 .args(paths.iter().map(|path| path.as_ref()))
333 .output()?;
334 if !output.status.success() {
335 return Err(anyhow!(
336 "Failed to checkout files:\n{}",
337 String::from_utf8_lossy(&output.stderr)
338 ));
339 }
340 Ok(())
341 }
342
343 fn load_index_text(&self, path: &RepoPath) -> Option<String> {
344 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
345 const STAGE_NORMAL: i32 = 0;
346 let index = repo.index()?;
347
348 // This check is required because index.get_path() unwraps internally :(
349 check_path_to_repo_path_errors(path)?;
350
351 let oid = match index.get_path(path, STAGE_NORMAL) {
352 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
353 _ => return Ok(None),
354 };
355
356 let content = repo.find_blob(oid)?.content().to_owned();
357 Ok(Some(String::from_utf8(content)?))
358 }
359
360 match logic(&self.repository.lock(), path) {
361 Ok(value) => return value,
362 Err(err) => log::error!("Error loading index text: {:?}", err),
363 }
364 None
365 }
366
367 fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
368 let repo = self.repository.lock();
369 let head = repo.head().ok()?.peel_to_tree().log_err()?;
370 let entry = head.get_path(path).ok()?;
371 if entry.filemode() == i32::from(git2::FileMode::Link) {
372 return None;
373 }
374 let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
375 let content = String::from_utf8(content).log_err()?;
376 Some(content)
377 }
378
379 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
380 let working_directory = self.working_directory()?;
381 if let Some(content) = content {
382 let mut child = new_std_command(&self.git_binary_path)
383 .current_dir(&working_directory)
384 .args(["hash-object", "-w", "--stdin"])
385 .stdin(Stdio::piped())
386 .stdout(Stdio::piped())
387 .spawn()?;
388 child.stdin.take().unwrap().write_all(content.as_bytes())?;
389 let output = child.wait_with_output()?.stdout;
390 let sha = String::from_utf8(output)?;
391
392 log::debug!("indexing SHA: {sha}, path {path:?}");
393
394 let output = new_std_command(&self.git_binary_path)
395 .current_dir(&working_directory)
396 .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
397 .arg(path.as_ref())
398 .output()?;
399
400 if !output.status.success() {
401 return Err(anyhow!(
402 "Failed to stage:\n{}",
403 String::from_utf8_lossy(&output.stderr)
404 ));
405 }
406 } else {
407 let output = new_std_command(&self.git_binary_path)
408 .current_dir(&working_directory)
409 .args(["update-index", "--force-remove"])
410 .arg(path.as_ref())
411 .output()?;
412
413 if !output.status.success() {
414 return Err(anyhow!(
415 "Failed to unstage:\n{}",
416 String::from_utf8_lossy(&output.stderr)
417 ));
418 }
419 }
420
421 Ok(())
422 }
423
424 fn remote_url(&self, name: &str) -> Option<String> {
425 let repo = self.repository.lock();
426 let remote = repo.find_remote(name).ok()?;
427 remote.url().map(|url| url.to_string())
428 }
429
430 fn head_sha(&self) -> Option<String> {
431 Some(self.repository.lock().head().ok()?.target()?.to_string())
432 }
433
434 fn merge_head_shas(&self) -> Vec<String> {
435 let mut shas = Vec::default();
436 self.repository
437 .lock()
438 .mergehead_foreach(|oid| {
439 shas.push(oid.to_string());
440 true
441 })
442 .ok();
443 if let Some(oid) = self
444 .repository
445 .lock()
446 .find_reference("CHERRY_PICK_HEAD")
447 .ok()
448 .and_then(|reference| reference.target())
449 {
450 shas.push(oid.to_string())
451 }
452 shas
453 }
454
455 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
456 let working_directory = self
457 .repository
458 .lock()
459 .workdir()
460 .context("failed to read git work directory")?
461 .to_path_buf();
462 GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
463 }
464
465 fn branch_exits(&self, name: &str) -> Result<bool> {
466 let repo = self.repository.lock();
467 let branch = repo.find_branch(name, BranchType::Local);
468 match branch {
469 Ok(_) => Ok(true),
470 Err(e) => match e.code() {
471 git2::ErrorCode::NotFound => Ok(false),
472 _ => Err(anyhow!(e)),
473 },
474 }
475 }
476
477 fn branches(&self) -> Result<Vec<Branch>> {
478 let working_directory = self
479 .repository
480 .lock()
481 .workdir()
482 .context("failed to read git work directory")?
483 .to_path_buf();
484 let fields = [
485 "%(HEAD)",
486 "%(objectname)",
487 "%(refname)",
488 "%(upstream)",
489 "%(upstream:track)",
490 "%(committerdate:unix)",
491 "%(contents:subject)",
492 ]
493 .join("%00");
494 let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
495
496 let output = new_std_command(&self.git_binary_path)
497 .current_dir(&working_directory)
498 .args(args)
499 .output()?;
500
501 if !output.status.success() {
502 return Err(anyhow!(
503 "Failed to git git branches:\n{}",
504 String::from_utf8_lossy(&output.stderr)
505 ));
506 }
507
508 let input = String::from_utf8_lossy(&output.stdout);
509
510 let mut branches = parse_branch_input(&input)?;
511 if branches.is_empty() {
512 let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
513
514 let output = new_std_command(&self.git_binary_path)
515 .current_dir(&working_directory)
516 .args(args)
517 .output()?;
518
519 // git symbolic-ref returns a non-0 exit code if HEAD points
520 // to something other than a branch
521 if output.status.success() {
522 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
523
524 branches.push(Branch {
525 name: name.into(),
526 is_head: true,
527 upstream: None,
528 most_recent_commit: None,
529 });
530 }
531 }
532
533 Ok(branches)
534 }
535
536 fn change_branch(&self, name: &str) -> Result<()> {
537 let repo = self.repository.lock();
538 let revision = repo.find_branch(name, BranchType::Local)?;
539 let revision = revision.get();
540 let as_tree = revision.peel_to_tree()?;
541 repo.checkout_tree(as_tree.as_object(), None)?;
542 repo.set_head(
543 revision
544 .name()
545 .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
546 )?;
547 Ok(())
548 }
549
550 fn create_branch(&self, name: &str) -> Result<()> {
551 let repo = self.repository.lock();
552 let current_commit = repo.head()?.peel_to_commit()?;
553 repo.branch(name, ¤t_commit, false)?;
554 Ok(())
555 }
556
557 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
558 let working_directory = self
559 .repository
560 .lock()
561 .workdir()
562 .with_context(|| format!("failed to get git working directory for file {:?}", path))?
563 .to_path_buf();
564
565 const REMOTE_NAME: &str = "origin";
566 let remote_url = self.remote_url(REMOTE_NAME);
567
568 crate::blame::Blame::for_path(
569 &self.git_binary_path,
570 &working_directory,
571 path,
572 &content,
573 remote_url,
574 self.hosting_provider_registry.clone(),
575 )
576 }
577
578 fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
579 let working_directory = self.working_directory()?;
580
581 if !paths.is_empty() {
582 let output = new_std_command(&self.git_binary_path)
583 .current_dir(&working_directory)
584 .args(["update-index", "--add", "--remove", "--"])
585 .args(paths.iter().map(|p| p.as_ref()))
586 .output()?;
587
588 if !output.status.success() {
589 return Err(anyhow!(
590 "Failed to stage paths:\n{}",
591 String::from_utf8_lossy(&output.stderr)
592 ));
593 }
594 }
595 Ok(())
596 }
597
598 fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
599 let working_directory = self.working_directory()?;
600
601 if !paths.is_empty() {
602 let output = new_std_command(&self.git_binary_path)
603 .current_dir(&working_directory)
604 .args(["reset", "--quiet", "--"])
605 .args(paths.iter().map(|p| p.as_ref()))
606 .output()?;
607
608 if !output.status.success() {
609 return Err(anyhow!(
610 "Failed to unstage:\n{}",
611 String::from_utf8_lossy(&output.stderr)
612 ));
613 }
614 }
615 Ok(())
616 }
617
618 fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> {
619 let working_directory = self.working_directory()?;
620
621 let mut cmd = new_std_command(&self.git_binary_path);
622 cmd.current_dir(&working_directory)
623 .args(["commit", "--quiet", "-m"])
624 .arg(message)
625 .arg("--cleanup=strip");
626
627 if let Some((name, email)) = name_and_email {
628 cmd.arg("--author").arg(&format!("{name} <{email}>"));
629 }
630
631 let output = cmd.output()?;
632
633 if !output.status.success() {
634 return Err(anyhow!(
635 "Failed to commit:\n{}",
636 String::from_utf8_lossy(&output.stderr)
637 ));
638 }
639 Ok(())
640 }
641
642 fn push(
643 &self,
644 branch_name: &str,
645 remote_name: &str,
646 options: Option<PushOptions>,
647 ask_pass: AskPassSession,
648 ) -> Result<RemoteCommandOutput> {
649 let working_directory = self.working_directory()?;
650
651 let mut command = new_smol_command("git");
652 command
653 .env("GIT_ASKPASS", ask_pass.script_path())
654 .env("SSH_ASKPASS", ask_pass.script_path())
655 .env("SSH_ASKPASS_REQUIRE", "force")
656 .current_dir(&working_directory)
657 .args(["push"])
658 .args(options.map(|option| match option {
659 PushOptions::SetUpstream => "--set-upstream",
660 PushOptions::Force => "--force-with-lease",
661 }))
662 .arg(remote_name)
663 .arg(format!("{}:{}", branch_name, branch_name));
664 let git_process = command.spawn()?;
665
666 run_remote_command(ask_pass, git_process)
667 }
668
669 fn pull(
670 &self,
671 branch_name: &str,
672 remote_name: &str,
673 ask_pass: AskPassSession,
674 ) -> Result<RemoteCommandOutput> {
675 let working_directory = self.working_directory()?;
676
677 let mut command = new_smol_command("git");
678 command
679 .env("GIT_ASKPASS", ask_pass.script_path())
680 .env("SSH_ASKPASS", ask_pass.script_path())
681 .env("SSH_ASKPASS_REQUIRE", "force")
682 .current_dir(&working_directory)
683 .args(["pull"])
684 .arg(remote_name)
685 .arg(branch_name);
686 let git_process = command.spawn()?;
687
688 run_remote_command(ask_pass, git_process)
689 }
690
691 fn fetch(&self, ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
692 let working_directory = self.working_directory()?;
693
694 let mut command = new_smol_command("git");
695 command
696 .env("GIT_ASKPASS", ask_pass.script_path())
697 .env("SSH_ASKPASS", ask_pass.script_path())
698 .env("SSH_ASKPASS_REQUIRE", "force")
699 .current_dir(&working_directory)
700 .args(["fetch", "--all"]);
701 let git_process = command.spawn()?;
702
703 run_remote_command(ask_pass, git_process)
704 }
705
706 fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>> {
707 let working_directory = self.working_directory()?;
708
709 if let Some(branch_name) = branch_name {
710 let output = new_std_command(&self.git_binary_path)
711 .current_dir(&working_directory)
712 .args(["config", "--get"])
713 .arg(format!("branch.{}.remote", branch_name))
714 .output()?;
715
716 if output.status.success() {
717 let remote_name = String::from_utf8_lossy(&output.stdout);
718
719 return Ok(vec![Remote {
720 name: remote_name.trim().to_string().into(),
721 }]);
722 }
723 }
724
725 let output = new_std_command(&self.git_binary_path)
726 .current_dir(&working_directory)
727 .args(["remote"])
728 .output()?;
729
730 if output.status.success() {
731 let remote_names = String::from_utf8_lossy(&output.stdout)
732 .split('\n')
733 .filter(|name| !name.is_empty())
734 .map(|name| Remote {
735 name: name.trim().to_string().into(),
736 })
737 .collect();
738
739 return Ok(remote_names);
740 } else {
741 return Err(anyhow!(
742 "Failed to get remotes:\n{}",
743 String::from_utf8_lossy(&output.stderr)
744 ));
745 }
746 }
747
748 fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
749 let working_directory = self.working_directory()?;
750 let git_cmd = |args: &[&str]| -> Result<String> {
751 let output = new_std_command(&self.git_binary_path)
752 .current_dir(&working_directory)
753 .args(args)
754 .output()?;
755 if output.status.success() {
756 Ok(String::from_utf8(output.stdout)?)
757 } else {
758 Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
759 }
760 };
761
762 let head = git_cmd(&["rev-parse", "HEAD"])
763 .context("Failed to get HEAD")?
764 .trim()
765 .to_owned();
766
767 let mut remote_branches = vec![];
768 let mut add_if_matching = |remote_head: &str| {
769 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]) {
770 if merge_base.trim() == head {
771 if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
772 remote_branches.push(s.to_owned().into());
773 }
774 }
775 }
776 };
777
778 // check the main branch of each remote
779 let remotes = git_cmd(&["remote"]).context("Failed to get remotes")?;
780 for remote in remotes.lines() {
781 if let Ok(remote_head) =
782 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")])
783 {
784 add_if_matching(remote_head.trim());
785 }
786 }
787
788 // ... and the remote branch that the checked-out one is tracking
789 if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]) {
790 add_if_matching(remote_head.trim());
791 }
792
793 Ok(remote_branches)
794 }
795}
796
797fn run_remote_command(
798 mut ask_pass: AskPassSession,
799 git_process: smol::process::Child,
800) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
801 smol::block_on(async {
802 select_biased! {
803 result = ask_pass.run().fuse() => {
804 match result {
805 AskPassResult::CancelledByUser => {
806 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
807 }
808 AskPassResult::Timedout => {
809 Err(anyhow!("Connecting to host timed out"))?
810 }
811 }
812 }
813 output = git_process.output().fuse() => {
814 let output = output?;
815 if !output.status.success() {
816 Err(anyhow!(
817 "Operation failed:\n{}",
818 String::from_utf8_lossy(&output.stderr)
819 ))
820 } else {
821 Ok(RemoteCommandOutput {
822 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
823 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
824 })
825 }
826 }
827 }
828 })
829}
830
831#[derive(Debug, Clone)]
832pub struct FakeGitRepository {
833 state: Arc<Mutex<FakeGitRepositoryState>>,
834}
835
836#[derive(Debug, Clone)]
837pub struct FakeGitRepositoryState {
838 pub path: PathBuf,
839 pub event_emitter: smol::channel::Sender<PathBuf>,
840 pub head_contents: HashMap<RepoPath, String>,
841 pub index_contents: HashMap<RepoPath, String>,
842 pub blames: HashMap<RepoPath, Blame>,
843 pub statuses: HashMap<RepoPath, FileStatus>,
844 pub current_branch_name: Option<String>,
845 pub branches: HashSet<String>,
846 pub simulated_index_write_error_message: Option<String>,
847}
848
849impl FakeGitRepository {
850 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
851 Arc::new(FakeGitRepository { state })
852 }
853}
854
855impl FakeGitRepositoryState {
856 pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
857 FakeGitRepositoryState {
858 path,
859 event_emitter,
860 head_contents: Default::default(),
861 index_contents: Default::default(),
862 blames: Default::default(),
863 statuses: Default::default(),
864 current_branch_name: Default::default(),
865 branches: Default::default(),
866 simulated_index_write_error_message: None,
867 }
868 }
869}
870
871impl GitRepository for FakeGitRepository {
872 fn reload_index(&self) {}
873
874 fn load_index_text(&self, path: &RepoPath) -> Option<String> {
875 let state = self.state.lock();
876 state.index_contents.get(path.as_ref()).cloned()
877 }
878
879 fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
880 let state = self.state.lock();
881 state.head_contents.get(path.as_ref()).cloned()
882 }
883
884 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
885 let mut state = self.state.lock();
886 if let Some(message) = state.simulated_index_write_error_message.clone() {
887 return Err(anyhow::anyhow!(message));
888 }
889 if let Some(content) = content {
890 state.index_contents.insert(path.clone(), content);
891 } else {
892 state.index_contents.remove(path);
893 }
894 state
895 .event_emitter
896 .try_send(state.path.clone())
897 .expect("Dropped repo change event");
898 Ok(())
899 }
900
901 fn remote_url(&self, _name: &str) -> Option<String> {
902 None
903 }
904
905 fn head_sha(&self) -> Option<String> {
906 None
907 }
908
909 fn merge_head_shas(&self) -> Vec<String> {
910 vec![]
911 }
912
913 fn show(&self, _: &str) -> Result<CommitDetails> {
914 unimplemented!()
915 }
916
917 fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
918 unimplemented!()
919 }
920
921 fn checkout_files(&self, _: &str, _: &[RepoPath]) -> Result<()> {
922 unimplemented!()
923 }
924
925 fn path(&self) -> PathBuf {
926 let state = self.state.lock();
927 state.path.clone()
928 }
929
930 fn main_repository_path(&self) -> PathBuf {
931 self.path()
932 }
933
934 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
935 let state = self.state.lock();
936
937 let mut entries = state
938 .statuses
939 .iter()
940 .filter_map(|(repo_path, status)| {
941 if path_prefixes
942 .iter()
943 .any(|path_prefix| repo_path.0.starts_with(path_prefix))
944 {
945 Some((repo_path.to_owned(), *status))
946 } else {
947 None
948 }
949 })
950 .collect::<Vec<_>>();
951 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
952
953 Ok(GitStatus {
954 entries: entries.into(),
955 })
956 }
957
958 fn branches(&self) -> Result<Vec<Branch>> {
959 let state = self.state.lock();
960 let current_branch = &state.current_branch_name;
961 Ok(state
962 .branches
963 .iter()
964 .map(|branch_name| Branch {
965 is_head: Some(branch_name) == current_branch.as_ref(),
966 name: branch_name.into(),
967 most_recent_commit: None,
968 upstream: None,
969 })
970 .collect())
971 }
972
973 fn branch_exits(&self, name: &str) -> Result<bool> {
974 let state = self.state.lock();
975 Ok(state.branches.contains(name))
976 }
977
978 fn change_branch(&self, name: &str) -> Result<()> {
979 let mut state = self.state.lock();
980 state.current_branch_name = Some(name.to_owned());
981 state
982 .event_emitter
983 .try_send(state.path.clone())
984 .expect("Dropped repo change event");
985 Ok(())
986 }
987
988 fn create_branch(&self, name: &str) -> Result<()> {
989 let mut state = self.state.lock();
990 state.branches.insert(name.to_owned());
991 state
992 .event_emitter
993 .try_send(state.path.clone())
994 .expect("Dropped repo change event");
995 Ok(())
996 }
997
998 fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
999 let state = self.state.lock();
1000 state
1001 .blames
1002 .get(path)
1003 .with_context(|| format!("failed to get blame for {:?}", path))
1004 .cloned()
1005 }
1006
1007 fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
1008 unimplemented!()
1009 }
1010
1011 fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
1012 unimplemented!()
1013 }
1014
1015 fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
1016 unimplemented!()
1017 }
1018
1019 fn push(
1020 &self,
1021 _branch: &str,
1022 _remote: &str,
1023 _options: Option<PushOptions>,
1024 _ask_pass: AskPassSession,
1025 ) -> Result<RemoteCommandOutput> {
1026 unimplemented!()
1027 }
1028
1029 fn pull(
1030 &self,
1031 _branch: &str,
1032 _remote: &str,
1033 _ask_pass: AskPassSession,
1034 ) -> Result<RemoteCommandOutput> {
1035 unimplemented!()
1036 }
1037
1038 fn fetch(&self, _ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
1039 unimplemented!()
1040 }
1041
1042 fn get_remotes(&self, _branch: Option<&str>) -> Result<Vec<Remote>> {
1043 unimplemented!()
1044 }
1045
1046 fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
1047 unimplemented!()
1048 }
1049}
1050
1051fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1052 match relative_file_path.components().next() {
1053 None => anyhow::bail!("repo path should not be empty"),
1054 Some(Component::Prefix(_)) => anyhow::bail!(
1055 "repo path `{}` should be relative, not a windows prefix",
1056 relative_file_path.to_string_lossy()
1057 ),
1058 Some(Component::RootDir) => {
1059 anyhow::bail!(
1060 "repo path `{}` should be relative",
1061 relative_file_path.to_string_lossy()
1062 )
1063 }
1064 Some(Component::CurDir) => {
1065 anyhow::bail!(
1066 "repo path `{}` should not start with `.`",
1067 relative_file_path.to_string_lossy()
1068 )
1069 }
1070 Some(Component::ParentDir) => {
1071 anyhow::bail!(
1072 "repo path `{}` should not start with `..`",
1073 relative_file_path.to_string_lossy()
1074 )
1075 }
1076 _ => Ok(()),
1077 }
1078}
1079
1080pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1081 LazyLock::new(|| RepoPath(Path::new("").into()));
1082
1083#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1084pub struct RepoPath(pub Arc<Path>);
1085
1086impl RepoPath {
1087 pub fn new(path: PathBuf) -> Self {
1088 debug_assert!(path.is_relative(), "Repo paths must be relative");
1089
1090 RepoPath(path.into())
1091 }
1092
1093 pub fn from_str(path: &str) -> Self {
1094 let path = Path::new(path);
1095 debug_assert!(path.is_relative(), "Repo paths must be relative");
1096
1097 RepoPath(path.into())
1098 }
1099}
1100
1101impl std::fmt::Display for RepoPath {
1102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1103 self.0.to_string_lossy().fmt(f)
1104 }
1105}
1106
1107impl From<&Path> for RepoPath {
1108 fn from(value: &Path) -> Self {
1109 RepoPath::new(value.into())
1110 }
1111}
1112
1113impl From<Arc<Path>> for RepoPath {
1114 fn from(value: Arc<Path>) -> Self {
1115 RepoPath(value)
1116 }
1117}
1118
1119impl From<PathBuf> for RepoPath {
1120 fn from(value: PathBuf) -> Self {
1121 RepoPath::new(value)
1122 }
1123}
1124
1125impl From<&str> for RepoPath {
1126 fn from(value: &str) -> Self {
1127 Self::from_str(value)
1128 }
1129}
1130
1131impl Default for RepoPath {
1132 fn default() -> Self {
1133 RepoPath(Path::new("").into())
1134 }
1135}
1136
1137impl AsRef<Path> for RepoPath {
1138 fn as_ref(&self) -> &Path {
1139 self.0.as_ref()
1140 }
1141}
1142
1143impl std::ops::Deref for RepoPath {
1144 type Target = Path;
1145
1146 fn deref(&self) -> &Self::Target {
1147 &self.0
1148 }
1149}
1150
1151impl Borrow<Path> for RepoPath {
1152 fn borrow(&self) -> &Path {
1153 self.0.as_ref()
1154 }
1155}
1156
1157#[derive(Debug)]
1158pub struct RepoPathDescendants<'a>(pub &'a Path);
1159
1160impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1161 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1162 if key.starts_with(self.0) {
1163 Ordering::Greater
1164 } else {
1165 self.0.cmp(key)
1166 }
1167 }
1168}
1169
1170fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1171 let mut branches = Vec::new();
1172 for line in input.split('\n') {
1173 if line.is_empty() {
1174 continue;
1175 }
1176 let mut fields = line.split('\x00');
1177 let is_current_branch = fields.next().context("no HEAD")? == "*";
1178 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1179 let ref_name: SharedString = fields
1180 .next()
1181 .context("no refname")?
1182 .strip_prefix("refs/heads/")
1183 .context("unexpected format for refname")?
1184 .to_string()
1185 .into();
1186 let upstream_name = fields.next().context("no upstream")?.to_string();
1187 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1188 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1189 let subject: SharedString = fields
1190 .next()
1191 .context("no contents:subject")?
1192 .to_string()
1193 .into();
1194
1195 branches.push(Branch {
1196 is_head: is_current_branch,
1197 name: ref_name,
1198 most_recent_commit: Some(CommitSummary {
1199 sha: head_sha,
1200 subject,
1201 commit_timestamp: commiterdate,
1202 }),
1203 upstream: if upstream_name.is_empty() {
1204 None
1205 } else {
1206 Some(Upstream {
1207 ref_name: upstream_name.into(),
1208 tracking: upstream_tracking,
1209 })
1210 },
1211 })
1212 }
1213
1214 Ok(branches)
1215}
1216
1217fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1218 if upstream_track == "" {
1219 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1220 ahead: 0,
1221 behind: 0,
1222 }));
1223 }
1224
1225 let upstream_track = upstream_track
1226 .strip_prefix("[")
1227 .ok_or_else(|| anyhow!("missing ["))?;
1228 let upstream_track = upstream_track
1229 .strip_suffix("]")
1230 .ok_or_else(|| anyhow!("missing ["))?;
1231 let mut ahead: u32 = 0;
1232 let mut behind: u32 = 0;
1233 for component in upstream_track.split(", ") {
1234 if component == "gone" {
1235 return Ok(UpstreamTracking::Gone);
1236 }
1237 if let Some(ahead_num) = component.strip_prefix("ahead ") {
1238 ahead = ahead_num.parse::<u32>()?;
1239 }
1240 if let Some(behind_num) = component.strip_prefix("behind ") {
1241 behind = behind_num.parse::<u32>()?;
1242 }
1243 }
1244 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1245 ahead,
1246 behind,
1247 }))
1248}
1249
1250#[test]
1251fn test_branches_parsing() {
1252 // suppress "help: octal escapes are not supported, `\0` is always null"
1253 #[allow(clippy::octal_escapes)]
1254 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1255 assert_eq!(
1256 parse_branch_input(&input).unwrap(),
1257 vec![Branch {
1258 is_head: true,
1259 name: "zed-patches".into(),
1260 upstream: Some(Upstream {
1261 ref_name: "refs/remotes/origin/zed-patches".into(),
1262 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1263 ahead: 0,
1264 behind: 0
1265 })
1266 }),
1267 most_recent_commit: Some(CommitSummary {
1268 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1269 subject: "generated protobuf".into(),
1270 commit_timestamp: 1733187470,
1271 })
1272 }]
1273 )
1274}