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