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