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}
866
867impl FakeGitRepository {
868 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
869 Arc::new(FakeGitRepository { state })
870 }
871}
872
873impl FakeGitRepositoryState {
874 pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
875 FakeGitRepositoryState {
876 path,
877 event_emitter,
878 head_contents: Default::default(),
879 index_contents: Default::default(),
880 blames: Default::default(),
881 statuses: Default::default(),
882 current_branch_name: Default::default(),
883 branches: Default::default(),
884 }
885 }
886}
887
888impl GitRepository for FakeGitRepository {
889 fn reload_index(&self) {}
890
891 fn load_index_text(&self, path: &RepoPath) -> Option<String> {
892 let state = self.state.lock();
893 state.index_contents.get(path.as_ref()).cloned()
894 }
895
896 fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
897 let state = self.state.lock();
898 state.head_contents.get(path.as_ref()).cloned()
899 }
900
901 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
902 let mut state = self.state.lock();
903 if let Some(content) = content {
904 state.index_contents.insert(path.clone(), content);
905 } else {
906 state.index_contents.remove(path);
907 }
908 state
909 .event_emitter
910 .try_send(state.path.clone())
911 .expect("Dropped repo change event");
912 Ok(())
913 }
914
915 fn remote_url(&self, _name: &str) -> Option<String> {
916 None
917 }
918
919 fn head_sha(&self) -> Option<String> {
920 None
921 }
922
923 fn merge_head_shas(&self) -> Vec<String> {
924 vec![]
925 }
926
927 fn show(&self, _: &str) -> Result<CommitDetails> {
928 unimplemented!()
929 }
930
931 fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
932 unimplemented!()
933 }
934
935 fn checkout_files(&self, _: &str, _: &[RepoPath]) -> Result<()> {
936 unimplemented!()
937 }
938
939 fn path(&self) -> PathBuf {
940 let state = self.state.lock();
941 state.path.clone()
942 }
943
944 fn main_repository_path(&self) -> PathBuf {
945 self.path()
946 }
947
948 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
949 let state = self.state.lock();
950
951 let mut entries = state
952 .statuses
953 .iter()
954 .filter_map(|(repo_path, status)| {
955 if path_prefixes
956 .iter()
957 .any(|path_prefix| repo_path.0.starts_with(path_prefix))
958 {
959 Some((repo_path.to_owned(), *status))
960 } else {
961 None
962 }
963 })
964 .collect::<Vec<_>>();
965 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
966
967 Ok(GitStatus {
968 entries: entries.into(),
969 })
970 }
971
972 fn branches(&self) -> Result<Vec<Branch>> {
973 let state = self.state.lock();
974 let current_branch = &state.current_branch_name;
975 Ok(state
976 .branches
977 .iter()
978 .map(|branch_name| Branch {
979 is_head: Some(branch_name) == current_branch.as_ref(),
980 name: branch_name.into(),
981 most_recent_commit: None,
982 upstream: None,
983 })
984 .collect())
985 }
986
987 fn branch_exits(&self, name: &str) -> Result<bool> {
988 let state = self.state.lock();
989 Ok(state.branches.contains(name))
990 }
991
992 fn change_branch(&self, name: &str) -> Result<()> {
993 let mut state = self.state.lock();
994 state.current_branch_name = Some(name.to_owned());
995 state
996 .event_emitter
997 .try_send(state.path.clone())
998 .expect("Dropped repo change event");
999 Ok(())
1000 }
1001
1002 fn create_branch(&self, name: &str) -> Result<()> {
1003 let mut state = self.state.lock();
1004 state.branches.insert(name.to_owned());
1005 state
1006 .event_emitter
1007 .try_send(state.path.clone())
1008 .expect("Dropped repo change event");
1009 Ok(())
1010 }
1011
1012 fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
1013 let state = self.state.lock();
1014 state
1015 .blames
1016 .get(path)
1017 .with_context(|| format!("failed to get blame for {:?}", path))
1018 .cloned()
1019 }
1020
1021 fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
1022 unimplemented!()
1023 }
1024
1025 fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
1026 unimplemented!()
1027 }
1028
1029 fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
1030 unimplemented!()
1031 }
1032
1033 fn push(
1034 &self,
1035 _branch: &str,
1036 _remote: &str,
1037 _options: Option<PushOptions>,
1038 ) -> Result<RemoteCommandOutput> {
1039 unimplemented!()
1040 }
1041
1042 fn pull(&self, _branch: &str, _remote: &str) -> Result<RemoteCommandOutput> {
1043 unimplemented!()
1044 }
1045
1046 fn fetch(&self) -> Result<RemoteCommandOutput> {
1047 unimplemented!()
1048 }
1049
1050 fn get_remotes(&self, _branch: Option<&str>) -> Result<Vec<Remote>> {
1051 unimplemented!()
1052 }
1053
1054 fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
1055 unimplemented!()
1056 }
1057}
1058
1059fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1060 match relative_file_path.components().next() {
1061 None => anyhow::bail!("repo path should not be empty"),
1062 Some(Component::Prefix(_)) => anyhow::bail!(
1063 "repo path `{}` should be relative, not a windows prefix",
1064 relative_file_path.to_string_lossy()
1065 ),
1066 Some(Component::RootDir) => {
1067 anyhow::bail!(
1068 "repo path `{}` should be relative",
1069 relative_file_path.to_string_lossy()
1070 )
1071 }
1072 Some(Component::CurDir) => {
1073 anyhow::bail!(
1074 "repo path `{}` should not start with `.`",
1075 relative_file_path.to_string_lossy()
1076 )
1077 }
1078 Some(Component::ParentDir) => {
1079 anyhow::bail!(
1080 "repo path `{}` should not start with `..`",
1081 relative_file_path.to_string_lossy()
1082 )
1083 }
1084 _ => Ok(()),
1085 }
1086}
1087
1088pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1089 LazyLock::new(|| RepoPath(Path::new("").into()));
1090
1091#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1092pub struct RepoPath(pub Arc<Path>);
1093
1094impl RepoPath {
1095 pub fn new(path: PathBuf) -> Self {
1096 debug_assert!(path.is_relative(), "Repo paths must be relative");
1097
1098 RepoPath(path.into())
1099 }
1100
1101 pub fn from_str(path: &str) -> Self {
1102 let path = Path::new(path);
1103 debug_assert!(path.is_relative(), "Repo paths must be relative");
1104
1105 RepoPath(path.into())
1106 }
1107}
1108
1109impl std::fmt::Display for RepoPath {
1110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1111 self.0.to_string_lossy().fmt(f)
1112 }
1113}
1114
1115impl From<&Path> for RepoPath {
1116 fn from(value: &Path) -> Self {
1117 RepoPath::new(value.into())
1118 }
1119}
1120
1121impl From<Arc<Path>> for RepoPath {
1122 fn from(value: Arc<Path>) -> Self {
1123 RepoPath(value)
1124 }
1125}
1126
1127impl From<PathBuf> for RepoPath {
1128 fn from(value: PathBuf) -> Self {
1129 RepoPath::new(value)
1130 }
1131}
1132
1133impl From<&str> for RepoPath {
1134 fn from(value: &str) -> Self {
1135 Self::from_str(value)
1136 }
1137}
1138
1139impl Default for RepoPath {
1140 fn default() -> Self {
1141 RepoPath(Path::new("").into())
1142 }
1143}
1144
1145impl AsRef<Path> for RepoPath {
1146 fn as_ref(&self) -> &Path {
1147 self.0.as_ref()
1148 }
1149}
1150
1151impl std::ops::Deref for RepoPath {
1152 type Target = Path;
1153
1154 fn deref(&self) -> &Self::Target {
1155 &self.0
1156 }
1157}
1158
1159impl Borrow<Path> for RepoPath {
1160 fn borrow(&self) -> &Path {
1161 self.0.as_ref()
1162 }
1163}
1164
1165#[derive(Debug)]
1166pub struct RepoPathDescendants<'a>(pub &'a Path);
1167
1168impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1169 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1170 if key.starts_with(self.0) {
1171 Ordering::Greater
1172 } else {
1173 self.0.cmp(key)
1174 }
1175 }
1176}
1177
1178fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1179 let mut branches = Vec::new();
1180 for line in input.split('\n') {
1181 if line.is_empty() {
1182 continue;
1183 }
1184 let mut fields = line.split('\x00');
1185 let is_current_branch = fields.next().context("no HEAD")? == "*";
1186 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1187 let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
1188 let ref_name: SharedString = fields
1189 .next()
1190 .context("no refname")?
1191 .strip_prefix("refs/heads/")
1192 .context("unexpected format for refname")?
1193 .to_string()
1194 .into();
1195 let upstream_name = fields.next().context("no upstream")?.to_string();
1196 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1197 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1198 let subject: SharedString = fields
1199 .next()
1200 .context("no contents:subject")?
1201 .to_string()
1202 .into();
1203
1204 branches.push(Branch {
1205 is_head: is_current_branch,
1206 name: ref_name,
1207 most_recent_commit: Some(CommitSummary {
1208 sha: head_sha,
1209 subject,
1210 commit_timestamp: commiterdate,
1211 has_parent: !parent_sha.is_empty(),
1212 }),
1213 upstream: if upstream_name.is_empty() {
1214 None
1215 } else {
1216 Some(Upstream {
1217 ref_name: upstream_name.into(),
1218 tracking: upstream_tracking,
1219 })
1220 },
1221 })
1222 }
1223
1224 Ok(branches)
1225}
1226
1227fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1228 if upstream_track == "" {
1229 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1230 ahead: 0,
1231 behind: 0,
1232 }));
1233 }
1234
1235 let upstream_track = upstream_track
1236 .strip_prefix("[")
1237 .ok_or_else(|| anyhow!("missing ["))?;
1238 let upstream_track = upstream_track
1239 .strip_suffix("]")
1240 .ok_or_else(|| anyhow!("missing ["))?;
1241 let mut ahead: u32 = 0;
1242 let mut behind: u32 = 0;
1243 for component in upstream_track.split(", ") {
1244 if component == "gone" {
1245 return Ok(UpstreamTracking::Gone);
1246 }
1247 if let Some(ahead_num) = component.strip_prefix("ahead ") {
1248 ahead = ahead_num.parse::<u32>()?;
1249 }
1250 if let Some(behind_num) = component.strip_prefix("behind ") {
1251 behind = behind_num.parse::<u32>()?;
1252 }
1253 }
1254 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1255 ahead,
1256 behind,
1257 }))
1258}
1259
1260#[test]
1261fn test_branches_parsing() {
1262 // suppress "help: octal escapes are not supported, `\0` is always null"
1263 #[allow(clippy::octal_escapes)]
1264 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1265 assert_eq!(
1266 parse_branch_input(&input).unwrap(),
1267 vec![Branch {
1268 is_head: true,
1269 name: "zed-patches".into(),
1270 upstream: Some(Upstream {
1271 ref_name: "refs/remotes/origin/zed-patches".into(),
1272 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1273 ahead: 0,
1274 behind: 0
1275 })
1276 }),
1277 most_recent_commit: Some(CommitSummary {
1278 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1279 subject: "generated protobuf".into(),
1280 commit_timestamp: 1733187470,
1281 has_parent: false,
1282 })
1283 }]
1284 )
1285}