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