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