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