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