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