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