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