1use crate::status::FileStatus;
2use crate::{blame::Blame, status::GitStatus};
3use anyhow::{anyhow, Context, Result};
4use askpass::{AskPassResult, AskPassSession};
5use collections::{HashMap, HashSet};
6use futures::{select_biased, FutureExt as _};
7use git2::BranchType;
8use gpui::SharedString;
9use parking_lot::Mutex;
10use rope::Rope;
11use schemars::JsonSchema;
12use serde::Deserialize;
13use std::borrow::Borrow;
14use std::io::Write as _;
15use std::process::Stdio;
16use std::sync::LazyLock;
17use std::{
18 cmp::Ordering,
19 path::{Component, Path, PathBuf},
20 sync::Arc,
21};
22use sum_tree::MapSeekTarget;
23use util::command::{new_smol_command, new_std_command};
24use util::ResultExt;
25
26pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
27
28#[derive(Clone, Debug, Hash, PartialEq, Eq)]
29pub struct Branch {
30 pub is_head: bool,
31 pub name: SharedString,
32 pub upstream: Option<Upstream>,
33 pub most_recent_commit: Option<CommitSummary>,
34}
35
36impl Branch {
37 pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
38 self.upstream
39 .as_ref()
40 .and_then(|upstream| upstream.tracking.status())
41 }
42
43 pub fn priority_key(&self) -> (bool, Option<i64>) {
44 (
45 self.is_head,
46 self.most_recent_commit
47 .as_ref()
48 .map(|commit| commit.commit_timestamp),
49 )
50 }
51}
52
53#[derive(Clone, Debug, Hash, PartialEq, Eq)]
54pub struct Upstream {
55 pub ref_name: SharedString,
56 pub tracking: UpstreamTracking,
57}
58
59impl Upstream {
60 pub fn remote_name(&self) -> Option<&str> {
61 self.ref_name
62 .strip_prefix("refs/remotes/")
63 .and_then(|stripped| stripped.split("/").next())
64 }
65}
66
67#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
68pub enum UpstreamTracking {
69 /// Remote ref not present in local repository.
70 Gone,
71 /// Remote ref present in local repository (fetched from remote).
72 Tracked(UpstreamTrackingStatus),
73}
74
75impl From<UpstreamTrackingStatus> for UpstreamTracking {
76 fn from(status: UpstreamTrackingStatus) -> Self {
77 UpstreamTracking::Tracked(status)
78 }
79}
80
81impl UpstreamTracking {
82 pub fn is_gone(&self) -> bool {
83 matches!(self, UpstreamTracking::Gone)
84 }
85
86 pub fn status(&self) -> Option<UpstreamTrackingStatus> {
87 match self {
88 UpstreamTracking::Gone => None,
89 UpstreamTracking::Tracked(status) => Some(*status),
90 }
91 }
92}
93
94#[derive(Debug)]
95pub struct RemoteCommandOutput {
96 pub stdout: String,
97 pub stderr: String,
98}
99
100impl RemoteCommandOutput {
101 pub fn is_empty(&self) -> bool {
102 self.stdout.is_empty() && self.stderr.is_empty()
103 }
104}
105
106#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
107pub struct UpstreamTrackingStatus {
108 pub ahead: u32,
109 pub behind: u32,
110}
111
112#[derive(Clone, Debug, Hash, PartialEq, Eq)]
113pub struct CommitSummary {
114 pub sha: SharedString,
115 pub subject: SharedString,
116 /// This is a unix timestamp
117 pub commit_timestamp: i64,
118}
119
120#[derive(Clone, Debug, Hash, PartialEq, Eq)]
121pub struct CommitDetails {
122 pub sha: SharedString,
123 pub message: SharedString,
124 pub commit_timestamp: i64,
125 pub committer_email: SharedString,
126 pub committer_name: SharedString,
127}
128
129#[derive(Debug, Clone, Hash, PartialEq, Eq)]
130pub struct Remote {
131 pub name: SharedString,
132}
133
134pub enum ResetMode {
135 // reset the branch pointer, leave index and worktree unchanged
136 // (this will make it look like things that were committed are now
137 // staged)
138 Soft,
139 // reset the branch pointer and index, leave worktree unchanged
140 // (this makes it look as though things that were committed are now
141 // unstaged)
142 Mixed,
143}
144
145pub trait GitRepository: Send + Sync {
146 fn reload_index(&self);
147
148 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
149 ///
150 /// Also returns `None` for symlinks.
151 fn load_index_text(&self, path: &RepoPath) -> Option<String>;
152
153 /// 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.
154 ///
155 /// Also returns `None` for symlinks.
156 fn load_committed_text(&self, path: &RepoPath) -> Option<String>;
157
158 fn set_index_text(
159 &self,
160 path: &RepoPath,
161 content: Option<String>,
162 env: &HashMap<String, String>,
163 ) -> anyhow::Result<()>;
164
165 /// Returns the URL of the remote with the given name.
166 fn remote_url(&self, name: &str) -> Option<String>;
167
168 /// Returns the SHA of the current HEAD.
169 fn head_sha(&self) -> Option<String>;
170
171 fn merge_head_shas(&self) -> Vec<String>;
172
173 /// Returns the list of git statuses, sorted by path
174 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
175
176 fn branches(&self) -> Result<Vec<Branch>>;
177 fn change_branch(&self, _: &str) -> Result<()>;
178 fn create_branch(&self, _: &str) -> Result<()>;
179 fn branch_exits(&self, _: &str) -> Result<bool>;
180
181 fn reset(&self, commit: &str, mode: ResetMode, env: &HashMap<String, String>) -> Result<()>;
182 fn checkout_files(
183 &self,
184 commit: &str,
185 paths: &[RepoPath],
186 env: &HashMap<String, String>,
187 ) -> Result<()>;
188
189 fn show(&self, commit: &str) -> Result<CommitDetails>;
190
191 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
192
193 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
194 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
195 fn path(&self) -> PathBuf;
196
197 /// Returns the absolute path to the ".git" dir for the main repository, typically a `.git`
198 /// folder. For worktrees, this will be the path to the repository the worktree was created
199 /// from. Otherwise, this is the same value as `path()`.
200 ///
201 /// Git documentation calls this the "commondir", and for git CLI is overridden by
202 /// `GIT_COMMON_DIR`.
203 fn main_repository_path(&self) -> PathBuf;
204
205 /// Updates the index to match the worktree at the given paths.
206 ///
207 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
208 fn stage_paths(&self, paths: &[RepoPath], env: &HashMap<String, String>) -> Result<()>;
209 /// Updates the index to match HEAD at the given paths.
210 ///
211 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
212 fn unstage_paths(&self, paths: &[RepoPath], env: &HashMap<String, String>) -> Result<()>;
213
214 fn commit(
215 &self,
216 message: &str,
217 name_and_email: Option<(&str, &str)>,
218 env: &HashMap<String, String>,
219 ) -> Result<()>;
220
221 fn push(
222 &self,
223 branch_name: &str,
224 upstream_name: &str,
225 options: Option<PushOptions>,
226 askpass: AskPassSession,
227 env: &HashMap<String, String>,
228 ) -> Result<RemoteCommandOutput>;
229
230 fn pull(
231 &self,
232 branch_name: &str,
233 upstream_name: &str,
234 askpass: AskPassSession,
235 env: &HashMap<String, String>,
236 ) -> Result<RemoteCommandOutput>;
237 fn fetch(
238 &self,
239 askpass: AskPassSession,
240 env: &HashMap<String, String>,
241 ) -> Result<RemoteCommandOutput>;
242
243 fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>>;
244
245 /// returns a list of remote branches that contain HEAD
246 fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>>;
247
248 /// Run git diff
249 fn diff(&self, diff: DiffType) -> Result<String>;
250}
251
252pub enum DiffType {
253 HeadToIndex,
254 HeadToWorktree,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
258pub enum PushOptions {
259 SetUpstream,
260 Force,
261}
262
263impl std::fmt::Debug for dyn GitRepository {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 f.debug_struct("dyn GitRepository<...>").finish()
266 }
267}
268
269pub struct RealGitRepository {
270 pub repository: Mutex<git2::Repository>,
271 pub git_binary_path: PathBuf,
272}
273
274impl RealGitRepository {
275 pub fn new(repository: git2::Repository, git_binary_path: Option<PathBuf>) -> Self {
276 Self {
277 repository: Mutex::new(repository),
278 git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
279 }
280 }
281
282 fn working_directory(&self) -> Result<PathBuf> {
283 self.repository
284 .lock()
285 .workdir()
286 .context("failed to read git work directory")
287 .map(Path::to_path_buf)
288 }
289}
290
291// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
292const GIT_MODE_SYMLINK: u32 = 0o120000;
293
294impl GitRepository for RealGitRepository {
295 fn reload_index(&self) {
296 if let Ok(mut index) = self.repository.lock().index() {
297 _ = index.read(false);
298 }
299 }
300
301 fn path(&self) -> PathBuf {
302 let repo = self.repository.lock();
303 repo.path().into()
304 }
305
306 fn main_repository_path(&self) -> PathBuf {
307 let repo = self.repository.lock();
308 repo.commondir().into()
309 }
310
311 fn show(&self, commit: &str) -> Result<CommitDetails> {
312 let repo = self.repository.lock();
313 let Ok(commit) = repo.revparse_single(commit)?.into_commit() else {
314 anyhow::bail!("{} is not a commit", commit);
315 };
316 let details = CommitDetails {
317 sha: commit.id().to_string().into(),
318 message: String::from_utf8_lossy(commit.message_raw_bytes())
319 .to_string()
320 .into(),
321 commit_timestamp: commit.time().seconds(),
322 committer_email: String::from_utf8_lossy(commit.committer().email_bytes())
323 .to_string()
324 .into(),
325 committer_name: String::from_utf8_lossy(commit.committer().name_bytes())
326 .to_string()
327 .into(),
328 };
329 Ok(details)
330 }
331
332 fn reset(&self, commit: &str, mode: ResetMode, env: &HashMap<String, String>) -> Result<()> {
333 let working_directory = self.working_directory()?;
334
335 let mode_flag = match mode {
336 ResetMode::Mixed => "--mixed",
337 ResetMode::Soft => "--soft",
338 };
339
340 let output = new_std_command(&self.git_binary_path)
341 .envs(env)
342 .current_dir(&working_directory)
343 .args(["reset", mode_flag, commit])
344 .output()?;
345 if !output.status.success() {
346 return Err(anyhow!(
347 "Failed to reset:\n{}",
348 String::from_utf8_lossy(&output.stderr)
349 ));
350 }
351 Ok(())
352 }
353
354 fn checkout_files(
355 &self,
356 commit: &str,
357 paths: &[RepoPath],
358 env: &HashMap<String, String>,
359 ) -> Result<()> {
360 if paths.is_empty() {
361 return Ok(());
362 }
363 let working_directory = self.working_directory()?;
364
365 let output = new_std_command(&self.git_binary_path)
366 .current_dir(&working_directory)
367 .envs(env)
368 .args(["checkout", commit, "--"])
369 .args(paths.iter().map(|path| path.as_ref()))
370 .output()?;
371 if !output.status.success() {
372 return Err(anyhow!(
373 "Failed to checkout files:\n{}",
374 String::from_utf8_lossy(&output.stderr)
375 ));
376 }
377 Ok(())
378 }
379
380 fn load_index_text(&self, path: &RepoPath) -> Option<String> {
381 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
382 const STAGE_NORMAL: i32 = 0;
383 let index = repo.index()?;
384
385 // This check is required because index.get_path() unwraps internally :(
386 check_path_to_repo_path_errors(path)?;
387
388 let oid = match index.get_path(path, STAGE_NORMAL) {
389 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
390 _ => return Ok(None),
391 };
392
393 let content = repo.find_blob(oid)?.content().to_owned();
394 Ok(Some(String::from_utf8(content)?))
395 }
396
397 match logic(&self.repository.lock(), path) {
398 Ok(value) => return value,
399 Err(err) => log::error!("Error loading index text: {:?}", err),
400 }
401 None
402 }
403
404 fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
405 let repo = self.repository.lock();
406 let head = repo.head().ok()?.peel_to_tree().log_err()?;
407 let entry = head.get_path(path).ok()?;
408 if entry.filemode() == i32::from(git2::FileMode::Link) {
409 return None;
410 }
411 let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
412 let content = String::from_utf8(content).log_err()?;
413 Some(content)
414 }
415
416 fn set_index_text(
417 &self,
418 path: &RepoPath,
419 content: Option<String>,
420 env: &HashMap<String, String>,
421 ) -> anyhow::Result<()> {
422 let working_directory = self.working_directory()?;
423 if let Some(content) = content {
424 let mut child = new_std_command(&self.git_binary_path)
425 .current_dir(&working_directory)
426 .envs(env)
427 .args(["hash-object", "-w", "--stdin"])
428 .stdin(Stdio::piped())
429 .stdout(Stdio::piped())
430 .spawn()?;
431 child.stdin.take().unwrap().write_all(content.as_bytes())?;
432 let output = child.wait_with_output()?.stdout;
433 let sha = String::from_utf8(output)?;
434
435 log::debug!("indexing SHA: {sha}, path {path:?}");
436
437 let output = new_std_command(&self.git_binary_path)
438 .current_dir(&working_directory)
439 .envs(env)
440 .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
441 .arg(path.as_ref())
442 .output()?;
443
444 if !output.status.success() {
445 return Err(anyhow!(
446 "Failed to stage:\n{}",
447 String::from_utf8_lossy(&output.stderr)
448 ));
449 }
450 } else {
451 let output = new_std_command(&self.git_binary_path)
452 .current_dir(&working_directory)
453 .envs(env)
454 .args(["update-index", "--force-remove"])
455 .arg(path.as_ref())
456 .output()?;
457
458 if !output.status.success() {
459 return Err(anyhow!(
460 "Failed to unstage:\n{}",
461 String::from_utf8_lossy(&output.stderr)
462 ));
463 }
464 }
465
466 Ok(())
467 }
468
469 fn remote_url(&self, name: &str) -> Option<String> {
470 let repo = self.repository.lock();
471 let remote = repo.find_remote(name).ok()?;
472 remote.url().map(|url| url.to_string())
473 }
474
475 fn head_sha(&self) -> Option<String> {
476 Some(self.repository.lock().head().ok()?.target()?.to_string())
477 }
478
479 fn merge_head_shas(&self) -> Vec<String> {
480 let mut shas = Vec::default();
481 self.repository
482 .lock()
483 .mergehead_foreach(|oid| {
484 shas.push(oid.to_string());
485 true
486 })
487 .ok();
488 if let Some(oid) = self
489 .repository
490 .lock()
491 .find_reference("CHERRY_PICK_HEAD")
492 .ok()
493 .and_then(|reference| reference.target())
494 {
495 shas.push(oid.to_string())
496 }
497 shas
498 }
499
500 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
501 let working_directory = self
502 .repository
503 .lock()
504 .workdir()
505 .context("failed to read git work directory")?
506 .to_path_buf();
507 GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
508 }
509
510 fn branch_exits(&self, name: &str) -> Result<bool> {
511 let repo = self.repository.lock();
512 let branch = repo.find_branch(name, BranchType::Local);
513 match branch {
514 Ok(_) => Ok(true),
515 Err(e) => match e.code() {
516 git2::ErrorCode::NotFound => Ok(false),
517 _ => Err(anyhow!(e)),
518 },
519 }
520 }
521
522 fn branches(&self) -> Result<Vec<Branch>> {
523 let working_directory = self
524 .repository
525 .lock()
526 .workdir()
527 .context("failed to read git work directory")?
528 .to_path_buf();
529 let fields = [
530 "%(HEAD)",
531 "%(objectname)",
532 "%(refname)",
533 "%(upstream)",
534 "%(upstream:track)",
535 "%(committerdate:unix)",
536 "%(contents:subject)",
537 ]
538 .join("%00");
539 let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
540
541 let output = new_std_command(&self.git_binary_path)
542 .current_dir(&working_directory)
543 .args(args)
544 .output()?;
545
546 if !output.status.success() {
547 return Err(anyhow!(
548 "Failed to git git branches:\n{}",
549 String::from_utf8_lossy(&output.stderr)
550 ));
551 }
552
553 let input = String::from_utf8_lossy(&output.stdout);
554
555 let mut branches = parse_branch_input(&input)?;
556 if branches.is_empty() {
557 let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
558
559 let output = new_std_command(&self.git_binary_path)
560 .current_dir(&working_directory)
561 .args(args)
562 .output()?;
563
564 // git symbolic-ref returns a non-0 exit code if HEAD points
565 // to something other than a branch
566 if output.status.success() {
567 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
568
569 branches.push(Branch {
570 name: name.into(),
571 is_head: true,
572 upstream: None,
573 most_recent_commit: None,
574 });
575 }
576 }
577
578 Ok(branches)
579 }
580
581 fn change_branch(&self, name: &str) -> Result<()> {
582 let repo = self.repository.lock();
583 let revision = repo.find_branch(name, BranchType::Local)?;
584 let revision = revision.get();
585 let as_tree = revision.peel_to_tree()?;
586 repo.checkout_tree(as_tree.as_object(), None)?;
587 repo.set_head(
588 revision
589 .name()
590 .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
591 )?;
592 Ok(())
593 }
594
595 fn create_branch(&self, name: &str) -> Result<()> {
596 let repo = self.repository.lock();
597 let current_commit = repo.head()?.peel_to_commit()?;
598 repo.branch(name, ¤t_commit, false)?;
599 Ok(())
600 }
601
602 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
603 let working_directory = self
604 .repository
605 .lock()
606 .workdir()
607 .with_context(|| format!("failed to get git working directory for file {:?}", path))?
608 .to_path_buf();
609
610 const REMOTE_NAME: &str = "origin";
611 let remote_url = self.remote_url(REMOTE_NAME);
612
613 crate::blame::Blame::for_path(
614 &self.git_binary_path,
615 &working_directory,
616 path,
617 &content,
618 remote_url,
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}