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