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