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 if let Some(oid) = self
431 .repository
432 .lock()
433 .find_reference("CHERRY_PICK_HEAD")
434 .ok()
435 .and_then(|reference| reference.target())
436 {
437 shas.push(oid.to_string())
438 }
439 shas
440 }
441
442 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
443 let working_directory = self
444 .repository
445 .lock()
446 .workdir()
447 .context("failed to read git work directory")?
448 .to_path_buf();
449 GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
450 }
451
452 fn branch_exits(&self, name: &str) -> Result<bool> {
453 let repo = self.repository.lock();
454 let branch = repo.find_branch(name, BranchType::Local);
455 match branch {
456 Ok(_) => Ok(true),
457 Err(e) => match e.code() {
458 git2::ErrorCode::NotFound => Ok(false),
459 _ => Err(anyhow!(e)),
460 },
461 }
462 }
463
464 fn branches(&self) -> Result<Vec<Branch>> {
465 let working_directory = self
466 .repository
467 .lock()
468 .workdir()
469 .context("failed to read git work directory")?
470 .to_path_buf();
471 let fields = [
472 "%(HEAD)",
473 "%(objectname)",
474 "%(refname)",
475 "%(upstream)",
476 "%(upstream:track)",
477 "%(committerdate:unix)",
478 "%(contents:subject)",
479 ]
480 .join("%00");
481 let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
482
483 let output = new_std_command(&self.git_binary_path)
484 .current_dir(&working_directory)
485 .args(args)
486 .output()?;
487
488 if !output.status.success() {
489 return Err(anyhow!(
490 "Failed to git git branches:\n{}",
491 String::from_utf8_lossy(&output.stderr)
492 ));
493 }
494
495 let input = String::from_utf8_lossy(&output.stdout);
496
497 let mut branches = parse_branch_input(&input)?;
498 if branches.is_empty() {
499 let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
500
501 let output = new_std_command(&self.git_binary_path)
502 .current_dir(&working_directory)
503 .args(args)
504 .output()?;
505
506 // git symbolic-ref returns a non-0 exit code if HEAD points
507 // to something other than a branch
508 if output.status.success() {
509 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
510
511 branches.push(Branch {
512 name: name.into(),
513 is_head: true,
514 upstream: None,
515 most_recent_commit: None,
516 });
517 }
518 }
519
520 Ok(branches)
521 }
522
523 fn change_branch(&self, name: &str) -> Result<()> {
524 let repo = self.repository.lock();
525 let revision = repo.find_branch(name, BranchType::Local)?;
526 let revision = revision.get();
527 let as_tree = revision.peel_to_tree()?;
528 repo.checkout_tree(as_tree.as_object(), None)?;
529 repo.set_head(
530 revision
531 .name()
532 .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
533 )?;
534 Ok(())
535 }
536
537 fn create_branch(&self, name: &str) -> Result<()> {
538 let repo = self.repository.lock();
539 let current_commit = repo.head()?.peel_to_commit()?;
540 repo.branch(name, ¤t_commit, false)?;
541 Ok(())
542 }
543
544 fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
545 let working_directory = self
546 .repository
547 .lock()
548 .workdir()
549 .with_context(|| format!("failed to get git working directory for file {:?}", path))?
550 .to_path_buf();
551
552 const REMOTE_NAME: &str = "origin";
553 let remote_url = self.remote_url(REMOTE_NAME);
554
555 crate::blame::Blame::for_path(
556 &self.git_binary_path,
557 &working_directory,
558 path,
559 &content,
560 remote_url,
561 self.hosting_provider_registry.clone(),
562 )
563 }
564
565 fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
566 let working_directory = self.working_directory()?;
567
568 if !paths.is_empty() {
569 let output = new_std_command(&self.git_binary_path)
570 .current_dir(&working_directory)
571 .args(["update-index", "--add", "--remove", "--"])
572 .args(paths.iter().map(|p| p.as_ref()))
573 .output()?;
574
575 // TODO: Get remote response out of this and show it to the user
576 if !output.status.success() {
577 return Err(anyhow!(
578 "Failed to stage paths:\n{}",
579 String::from_utf8_lossy(&output.stderr)
580 ));
581 }
582 }
583 Ok(())
584 }
585
586 fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
587 let working_directory = self.working_directory()?;
588
589 if !paths.is_empty() {
590 let output = new_std_command(&self.git_binary_path)
591 .current_dir(&working_directory)
592 .args(["reset", "--quiet", "--"])
593 .args(paths.iter().map(|p| p.as_ref()))
594 .output()?;
595
596 // TODO: Get remote response out of this and show it to the user
597 if !output.status.success() {
598 return Err(anyhow!(
599 "Failed to unstage:\n{}",
600 String::from_utf8_lossy(&output.stderr)
601 ));
602 }
603 }
604 Ok(())
605 }
606
607 fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> {
608 let working_directory = self.working_directory()?;
609
610 let mut cmd = new_std_command(&self.git_binary_path);
611 cmd.current_dir(&working_directory)
612 .args(["commit", "--quiet", "-m"])
613 .arg(message)
614 .arg("--cleanup=strip");
615
616 if let Some((name, email)) = name_and_email {
617 cmd.arg("--author").arg(&format!("{name} <{email}>"));
618 }
619
620 let output = cmd.output()?;
621
622 // TODO: Get remote response out of this and show it to the user
623 if !output.status.success() {
624 return Err(anyhow!(
625 "Failed to commit:\n{}",
626 String::from_utf8_lossy(&output.stderr)
627 ));
628 }
629 Ok(())
630 }
631
632 fn push(
633 &self,
634 branch_name: &str,
635 remote_name: &str,
636 options: Option<PushOptions>,
637 ) -> Result<RemoteCommandOutput> {
638 let working_directory = self.working_directory()?;
639
640 // We do this on every operation to ensure that the askpass script exists and is executable.
641 #[cfg(not(windows))]
642 let (askpass_script_path, _temp_dir) = setup_askpass()?;
643
644 let mut command = new_std_command("git");
645 command
646 .current_dir(&working_directory)
647 .args(["push"])
648 .args(options.map(|option| match option {
649 PushOptions::SetUpstream => "--set-upstream",
650 PushOptions::Force => "--force-with-lease",
651 }))
652 .arg(remote_name)
653 .arg(format!("{}:{}", branch_name, branch_name));
654
655 #[cfg(not(windows))]
656 {
657 command.env("GIT_ASKPASS", askpass_script_path);
658 }
659
660 let output = command.output()?;
661
662 if !output.status.success() {
663 return Err(anyhow!(
664 "Failed to push:\n{}",
665 String::from_utf8_lossy(&output.stderr)
666 ));
667 } else {
668 return Ok(RemoteCommandOutput {
669 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
670 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
671 });
672 }
673 }
674
675 fn pull(&self, branch_name: &str, remote_name: &str) -> Result<RemoteCommandOutput> {
676 let working_directory = self.working_directory()?;
677
678 // We do this on every operation to ensure that the askpass script exists and is executable.
679 #[cfg(not(windows))]
680 let (askpass_script_path, _temp_dir) = setup_askpass()?;
681
682 let mut command = new_std_command("git");
683 command
684 .current_dir(&working_directory)
685 .args(["pull"])
686 .arg(remote_name)
687 .arg(branch_name);
688
689 #[cfg(not(windows))]
690 {
691 command.env("GIT_ASKPASS", askpass_script_path);
692 }
693
694 let output = command.output()?;
695
696 if !output.status.success() {
697 return Err(anyhow!(
698 "Failed to pull:\n{}",
699 String::from_utf8_lossy(&output.stderr)
700 ));
701 } else {
702 return Ok(RemoteCommandOutput {
703 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
704 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
705 });
706 }
707 }
708
709 fn fetch(&self) -> Result<RemoteCommandOutput> {
710 let working_directory = self.working_directory()?;
711
712 // We do this on every operation to ensure that the askpass script exists and is executable.
713 #[cfg(not(windows))]
714 let (askpass_script_path, _temp_dir) = setup_askpass()?;
715
716 let mut command = new_std_command("git");
717 command
718 .current_dir(&working_directory)
719 .args(["fetch", "--all"]);
720
721 #[cfg(not(windows))]
722 {
723 command.env("GIT_ASKPASS", askpass_script_path);
724 }
725
726 let output = command.output()?;
727
728 if !output.status.success() {
729 return Err(anyhow!(
730 "Failed to fetch:\n{}",
731 String::from_utf8_lossy(&output.stderr)
732 ));
733 } else {
734 return Ok(RemoteCommandOutput {
735 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
736 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
737 });
738 }
739 }
740
741 fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>> {
742 let working_directory = self.working_directory()?;
743
744 if let Some(branch_name) = branch_name {
745 let output = new_std_command(&self.git_binary_path)
746 .current_dir(&working_directory)
747 .args(["config", "--get"])
748 .arg(format!("branch.{}.remote", branch_name))
749 .output()?;
750
751 if output.status.success() {
752 let remote_name = String::from_utf8_lossy(&output.stdout);
753
754 return Ok(vec![Remote {
755 name: remote_name.trim().to_string().into(),
756 }]);
757 }
758 }
759
760 let output = new_std_command(&self.git_binary_path)
761 .current_dir(&working_directory)
762 .args(["remote"])
763 .output()?;
764
765 if output.status.success() {
766 let remote_names = String::from_utf8_lossy(&output.stdout)
767 .split('\n')
768 .filter(|name| !name.is_empty())
769 .map(|name| Remote {
770 name: name.trim().to_string().into(),
771 })
772 .collect();
773
774 return Ok(remote_names);
775 } else {
776 return Err(anyhow!(
777 "Failed to get remotes:\n{}",
778 String::from_utf8_lossy(&output.stderr)
779 ));
780 }
781 }
782}
783
784#[cfg(not(windows))]
785fn setup_askpass() -> Result<(PathBuf, tempfile::TempDir), anyhow::Error> {
786 let temp_dir = tempfile::Builder::new()
787 .prefix("zed-git-askpass")
788 .tempdir()?;
789 let askpass_script = "#!/bin/sh\necho ''";
790 let askpass_script_path = temp_dir.path().join("git-askpass.sh");
791 std::fs::write(&askpass_script_path, askpass_script)?;
792 std::fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755))?;
793 Ok((askpass_script_path, temp_dir))
794}
795
796#[derive(Debug, Clone)]
797pub struct FakeGitRepository {
798 state: Arc<Mutex<FakeGitRepositoryState>>,
799}
800
801#[derive(Debug, Clone)]
802pub struct FakeGitRepositoryState {
803 pub path: PathBuf,
804 pub event_emitter: smol::channel::Sender<PathBuf>,
805 pub head_contents: HashMap<RepoPath, String>,
806 pub index_contents: HashMap<RepoPath, String>,
807 pub blames: HashMap<RepoPath, Blame>,
808 pub statuses: HashMap<RepoPath, FileStatus>,
809 pub current_branch_name: Option<String>,
810 pub branches: HashSet<String>,
811}
812
813impl FakeGitRepository {
814 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
815 Arc::new(FakeGitRepository { state })
816 }
817}
818
819impl FakeGitRepositoryState {
820 pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
821 FakeGitRepositoryState {
822 path,
823 event_emitter,
824 head_contents: Default::default(),
825 index_contents: Default::default(),
826 blames: Default::default(),
827 statuses: Default::default(),
828 current_branch_name: Default::default(),
829 branches: Default::default(),
830 }
831 }
832}
833
834impl GitRepository for FakeGitRepository {
835 fn reload_index(&self) {}
836
837 fn load_index_text(&self, path: &RepoPath) -> Option<String> {
838 let state = self.state.lock();
839 state.index_contents.get(path.as_ref()).cloned()
840 }
841
842 fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
843 let state = self.state.lock();
844 state.head_contents.get(path.as_ref()).cloned()
845 }
846
847 fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
848 let mut state = self.state.lock();
849 if let Some(content) = content {
850 state.index_contents.insert(path.clone(), content);
851 } else {
852 state.index_contents.remove(path);
853 }
854 state
855 .event_emitter
856 .try_send(state.path.clone())
857 .expect("Dropped repo change event");
858 Ok(())
859 }
860
861 fn remote_url(&self, _name: &str) -> Option<String> {
862 None
863 }
864
865 fn head_sha(&self) -> Option<String> {
866 None
867 }
868
869 fn merge_head_shas(&self) -> Vec<String> {
870 vec![]
871 }
872
873 fn show(&self, _: &str) -> Result<CommitDetails> {
874 unimplemented!()
875 }
876
877 fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
878 unimplemented!()
879 }
880
881 fn checkout_files(&self, _: &str, _: &[RepoPath]) -> Result<()> {
882 unimplemented!()
883 }
884
885 fn path(&self) -> PathBuf {
886 let state = self.state.lock();
887 state.path.clone()
888 }
889
890 fn main_repository_path(&self) -> PathBuf {
891 self.path()
892 }
893
894 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
895 let state = self.state.lock();
896
897 let mut entries = state
898 .statuses
899 .iter()
900 .filter_map(|(repo_path, status)| {
901 if path_prefixes
902 .iter()
903 .any(|path_prefix| repo_path.0.starts_with(path_prefix))
904 {
905 Some((repo_path.to_owned(), *status))
906 } else {
907 None
908 }
909 })
910 .collect::<Vec<_>>();
911 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
912
913 Ok(GitStatus {
914 entries: entries.into(),
915 })
916 }
917
918 fn branches(&self) -> Result<Vec<Branch>> {
919 let state = self.state.lock();
920 let current_branch = &state.current_branch_name;
921 Ok(state
922 .branches
923 .iter()
924 .map(|branch_name| Branch {
925 is_head: Some(branch_name) == current_branch.as_ref(),
926 name: branch_name.into(),
927 most_recent_commit: None,
928 upstream: None,
929 })
930 .collect())
931 }
932
933 fn branch_exits(&self, name: &str) -> Result<bool> {
934 let state = self.state.lock();
935 Ok(state.branches.contains(name))
936 }
937
938 fn change_branch(&self, name: &str) -> Result<()> {
939 let mut state = self.state.lock();
940 state.current_branch_name = Some(name.to_owned());
941 state
942 .event_emitter
943 .try_send(state.path.clone())
944 .expect("Dropped repo change event");
945 Ok(())
946 }
947
948 fn create_branch(&self, name: &str) -> Result<()> {
949 let mut state = self.state.lock();
950 state.branches.insert(name.to_owned());
951 state
952 .event_emitter
953 .try_send(state.path.clone())
954 .expect("Dropped repo change event");
955 Ok(())
956 }
957
958 fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
959 let state = self.state.lock();
960 state
961 .blames
962 .get(path)
963 .with_context(|| format!("failed to get blame for {:?}", path))
964 .cloned()
965 }
966
967 fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
968 unimplemented!()
969 }
970
971 fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
972 unimplemented!()
973 }
974
975 fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
976 unimplemented!()
977 }
978
979 fn push(
980 &self,
981 _branch: &str,
982 _remote: &str,
983 _options: Option<PushOptions>,
984 ) -> Result<RemoteCommandOutput> {
985 unimplemented!()
986 }
987
988 fn pull(&self, _branch: &str, _remote: &str) -> Result<RemoteCommandOutput> {
989 unimplemented!()
990 }
991
992 fn fetch(&self) -> Result<RemoteCommandOutput> {
993 unimplemented!()
994 }
995
996 fn get_remotes(&self, _branch: Option<&str>) -> Result<Vec<Remote>> {
997 unimplemented!()
998 }
999}
1000
1001fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1002 match relative_file_path.components().next() {
1003 None => anyhow::bail!("repo path should not be empty"),
1004 Some(Component::Prefix(_)) => anyhow::bail!(
1005 "repo path `{}` should be relative, not a windows prefix",
1006 relative_file_path.to_string_lossy()
1007 ),
1008 Some(Component::RootDir) => {
1009 anyhow::bail!(
1010 "repo path `{}` should be relative",
1011 relative_file_path.to_string_lossy()
1012 )
1013 }
1014 Some(Component::CurDir) => {
1015 anyhow::bail!(
1016 "repo path `{}` should not start with `.`",
1017 relative_file_path.to_string_lossy()
1018 )
1019 }
1020 Some(Component::ParentDir) => {
1021 anyhow::bail!(
1022 "repo path `{}` should not start with `..`",
1023 relative_file_path.to_string_lossy()
1024 )
1025 }
1026 _ => Ok(()),
1027 }
1028}
1029
1030pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1031 LazyLock::new(|| RepoPath(Path::new("").into()));
1032
1033#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1034pub struct RepoPath(pub Arc<Path>);
1035
1036impl RepoPath {
1037 pub fn new(path: PathBuf) -> Self {
1038 debug_assert!(path.is_relative(), "Repo paths must be relative");
1039
1040 RepoPath(path.into())
1041 }
1042
1043 pub fn from_str(path: &str) -> Self {
1044 let path = Path::new(path);
1045 debug_assert!(path.is_relative(), "Repo paths must be relative");
1046
1047 RepoPath(path.into())
1048 }
1049}
1050
1051impl std::fmt::Display for RepoPath {
1052 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1053 self.0.to_string_lossy().fmt(f)
1054 }
1055}
1056
1057impl From<&Path> for RepoPath {
1058 fn from(value: &Path) -> Self {
1059 RepoPath::new(value.into())
1060 }
1061}
1062
1063impl From<Arc<Path>> for RepoPath {
1064 fn from(value: Arc<Path>) -> Self {
1065 RepoPath(value)
1066 }
1067}
1068
1069impl From<PathBuf> for RepoPath {
1070 fn from(value: PathBuf) -> Self {
1071 RepoPath::new(value)
1072 }
1073}
1074
1075impl From<&str> for RepoPath {
1076 fn from(value: &str) -> Self {
1077 Self::from_str(value)
1078 }
1079}
1080
1081impl Default for RepoPath {
1082 fn default() -> Self {
1083 RepoPath(Path::new("").into())
1084 }
1085}
1086
1087impl AsRef<Path> for RepoPath {
1088 fn as_ref(&self) -> &Path {
1089 self.0.as_ref()
1090 }
1091}
1092
1093impl std::ops::Deref for RepoPath {
1094 type Target = Path;
1095
1096 fn deref(&self) -> &Self::Target {
1097 &self.0
1098 }
1099}
1100
1101impl Borrow<Path> for RepoPath {
1102 fn borrow(&self) -> &Path {
1103 self.0.as_ref()
1104 }
1105}
1106
1107#[derive(Debug)]
1108pub struct RepoPathDescendants<'a>(pub &'a Path);
1109
1110impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1111 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1112 if key.starts_with(self.0) {
1113 Ordering::Greater
1114 } else {
1115 self.0.cmp(key)
1116 }
1117 }
1118}
1119
1120fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1121 let mut branches = Vec::new();
1122 for line in input.split('\n') {
1123 if line.is_empty() {
1124 continue;
1125 }
1126 let mut fields = line.split('\x00');
1127 let is_current_branch = fields.next().context("no HEAD")? == "*";
1128 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1129 let ref_name: SharedString = fields
1130 .next()
1131 .context("no refname")?
1132 .strip_prefix("refs/heads/")
1133 .context("unexpected format for refname")?
1134 .to_string()
1135 .into();
1136 let upstream_name = fields.next().context("no upstream")?.to_string();
1137 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1138 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1139 let subject: SharedString = fields
1140 .next()
1141 .context("no contents:subject")?
1142 .to_string()
1143 .into();
1144
1145 branches.push(Branch {
1146 is_head: is_current_branch,
1147 name: ref_name,
1148 most_recent_commit: Some(CommitSummary {
1149 sha: head_sha,
1150 subject,
1151 commit_timestamp: commiterdate,
1152 }),
1153 upstream: if upstream_name.is_empty() {
1154 None
1155 } else {
1156 Some(Upstream {
1157 ref_name: upstream_name.into(),
1158 tracking: upstream_tracking,
1159 })
1160 },
1161 })
1162 }
1163
1164 Ok(branches)
1165}
1166
1167fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1168 if upstream_track == "" {
1169 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1170 ahead: 0,
1171 behind: 0,
1172 }));
1173 }
1174
1175 let upstream_track = upstream_track
1176 .strip_prefix("[")
1177 .ok_or_else(|| anyhow!("missing ["))?;
1178 let upstream_track = upstream_track
1179 .strip_suffix("]")
1180 .ok_or_else(|| anyhow!("missing ["))?;
1181 let mut ahead: u32 = 0;
1182 let mut behind: u32 = 0;
1183 for component in upstream_track.split(", ") {
1184 if component == "gone" {
1185 return Ok(UpstreamTracking::Gone);
1186 }
1187 if let Some(ahead_num) = component.strip_prefix("ahead ") {
1188 ahead = ahead_num.parse::<u32>()?;
1189 }
1190 if let Some(behind_num) = component.strip_prefix("behind ") {
1191 behind = behind_num.parse::<u32>()?;
1192 }
1193 }
1194 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1195 ahead,
1196 behind,
1197 }))
1198}
1199
1200#[test]
1201fn test_branches_parsing() {
1202 // suppress "help: octal escapes are not supported, `\0` is always null"
1203 #[allow(clippy::octal_escapes)]
1204 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1205 assert_eq!(
1206 parse_branch_input(&input).unwrap(),
1207 vec![Branch {
1208 is_head: true,
1209 name: "zed-patches".into(),
1210 upstream: Some(Upstream {
1211 ref_name: "refs/remotes/origin/zed-patches".into(),
1212 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1213 ahead: 0,
1214 behind: 0
1215 })
1216 }),
1217 most_recent_commit: Some(CommitSummary {
1218 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1219 subject: "generated protobuf".into(),
1220 commit_timestamp: 1733187470,
1221 })
1222 }]
1223 )
1224}