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