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