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