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