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