1use crate::status::FileStatus;
2use crate::SHORT_SHA_LENGTH;
3use crate::{blame::Blame, status::GitStatus};
4use anyhow::{anyhow, Context, Result};
5use askpass::{AskPassResult, AskPassSession};
6use collections::{HashMap, HashSet};
7use futures::future::BoxFuture;
8use futures::{select_biased, AsyncWriteExt, FutureExt as _};
9use git2::BranchType;
10use gpui::{AppContext, AsyncApp, SharedString};
11use parking_lot::Mutex;
12use rope::Rope;
13use schemars::JsonSchema;
14use serde::Deserialize;
15use std::borrow::Borrow;
16use std::process::Stdio;
17use std::sync::LazyLock;
18use std::{
19 cmp::Ordering,
20 path::{Component, Path, PathBuf},
21 sync::Arc,
22};
23use sum_tree::MapSeekTarget;
24use util::command::new_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
1059#[derive(Debug, Clone)]
1060pub struct FakeGitRepository {
1061 state: Arc<Mutex<FakeGitRepositoryState>>,
1062}
1063
1064#[derive(Debug, Clone)]
1065pub struct FakeGitRepositoryState {
1066 pub path: PathBuf,
1067 pub event_emitter: smol::channel::Sender<PathBuf>,
1068 pub head_contents: HashMap<RepoPath, String>,
1069 pub index_contents: HashMap<RepoPath, String>,
1070 pub blames: HashMap<RepoPath, Blame>,
1071 pub statuses: HashMap<RepoPath, FileStatus>,
1072 pub current_branch_name: Option<String>,
1073 pub branches: HashSet<String>,
1074 pub simulated_index_write_error_message: Option<String>,
1075}
1076
1077impl FakeGitRepository {
1078 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
1079 Arc::new(FakeGitRepository { state })
1080 }
1081}
1082
1083impl FakeGitRepositoryState {
1084 pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
1085 FakeGitRepositoryState {
1086 path,
1087 event_emitter,
1088 head_contents: Default::default(),
1089 index_contents: Default::default(),
1090 blames: Default::default(),
1091 statuses: Default::default(),
1092 current_branch_name: Default::default(),
1093 branches: Default::default(),
1094 simulated_index_write_error_message: None,
1095 }
1096 }
1097}
1098
1099impl GitRepository for FakeGitRepository {
1100 fn reload_index(&self) {}
1101
1102 fn load_index_text(&self, path: RepoPath, _: AsyncApp) -> BoxFuture<Option<String>> {
1103 let state = self.state.lock();
1104 let content = state.index_contents.get(path.as_ref()).cloned();
1105 async { content }.boxed()
1106 }
1107
1108 fn load_committed_text(&self, path: RepoPath, _: AsyncApp) -> BoxFuture<Option<String>> {
1109 let state = self.state.lock();
1110 let content = state.head_contents.get(path.as_ref()).cloned();
1111 async { content }.boxed()
1112 }
1113
1114 fn set_index_text(
1115 &self,
1116 path: RepoPath,
1117 content: Option<String>,
1118 _env: HashMap<String, String>,
1119 _cx: AsyncApp,
1120 ) -> BoxFuture<anyhow::Result<()>> {
1121 let mut state = self.state.lock();
1122 if let Some(message) = state.simulated_index_write_error_message.clone() {
1123 return async { Err(anyhow::anyhow!(message)) }.boxed();
1124 }
1125 if let Some(content) = content {
1126 state.index_contents.insert(path.clone(), content);
1127 } else {
1128 state.index_contents.remove(&path);
1129 }
1130 state
1131 .event_emitter
1132 .try_send(state.path.clone())
1133 .expect("Dropped repo change event");
1134 async { Ok(()) }.boxed()
1135 }
1136
1137 fn remote_url(&self, _name: &str) -> Option<String> {
1138 None
1139 }
1140
1141 fn head_sha(&self) -> Option<String> {
1142 None
1143 }
1144
1145 fn merge_head_shas(&self) -> Vec<String> {
1146 vec![]
1147 }
1148
1149 fn show(&self, _: String, _: AsyncApp) -> BoxFuture<Result<CommitDetails>> {
1150 unimplemented!()
1151 }
1152
1153 fn reset(&self, _: String, _: ResetMode, _: HashMap<String, String>) -> BoxFuture<Result<()>> {
1154 unimplemented!()
1155 }
1156
1157 fn checkout_files(
1158 &self,
1159 _: String,
1160 _: Vec<RepoPath>,
1161 _: HashMap<String, String>,
1162 ) -> BoxFuture<Result<()>> {
1163 unimplemented!()
1164 }
1165
1166 fn path(&self) -> PathBuf {
1167 let state = self.state.lock();
1168 state.path.clone()
1169 }
1170
1171 fn main_repository_path(&self) -> PathBuf {
1172 self.path()
1173 }
1174
1175 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
1176 let state = self.state.lock();
1177
1178 let mut entries = state
1179 .statuses
1180 .iter()
1181 .filter_map(|(repo_path, status)| {
1182 if path_prefixes
1183 .iter()
1184 .any(|path_prefix| repo_path.0.starts_with(path_prefix))
1185 {
1186 Some((repo_path.to_owned(), *status))
1187 } else {
1188 None
1189 }
1190 })
1191 .collect::<Vec<_>>();
1192 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
1193
1194 Ok(GitStatus {
1195 entries: entries.into(),
1196 })
1197 }
1198
1199 fn branches(&self) -> BoxFuture<Result<Vec<Branch>>> {
1200 let state = self.state.lock();
1201 let current_branch = &state.current_branch_name;
1202 let result = Ok(state
1203 .branches
1204 .iter()
1205 .map(|branch_name| Branch {
1206 is_head: Some(branch_name) == current_branch.as_ref(),
1207 name: branch_name.into(),
1208 most_recent_commit: None,
1209 upstream: None,
1210 })
1211 .collect());
1212
1213 async { result }.boxed()
1214 }
1215
1216 fn change_branch(&self, name: String, _: AsyncApp) -> BoxFuture<Result<()>> {
1217 let mut state = self.state.lock();
1218 state.current_branch_name = Some(name.to_owned());
1219 state
1220 .event_emitter
1221 .try_send(state.path.clone())
1222 .expect("Dropped repo change event");
1223 async { Ok(()) }.boxed()
1224 }
1225
1226 fn create_branch(&self, name: String, _: AsyncApp) -> BoxFuture<Result<()>> {
1227 let mut state = self.state.lock();
1228 state.branches.insert(name.to_owned());
1229 state
1230 .event_emitter
1231 .try_send(state.path.clone())
1232 .expect("Dropped repo change event");
1233 async { Ok(()) }.boxed()
1234 }
1235
1236 fn blame(
1237 &self,
1238 path: RepoPath,
1239 _content: Rope,
1240 _cx: AsyncApp,
1241 ) -> BoxFuture<Result<crate::blame::Blame>> {
1242 let state = self.state.lock();
1243 let result = state
1244 .blames
1245 .get(&path)
1246 .with_context(|| format!("failed to get blame for {:?}", path.0))
1247 .cloned();
1248 async { result }.boxed()
1249 }
1250
1251 fn stage_paths(
1252 &self,
1253 _paths: Vec<RepoPath>,
1254 _env: HashMap<String, String>,
1255 _cx: AsyncApp,
1256 ) -> BoxFuture<Result<()>> {
1257 unimplemented!()
1258 }
1259
1260 fn unstage_paths(
1261 &self,
1262 _paths: Vec<RepoPath>,
1263 _env: HashMap<String, String>,
1264 _cx: AsyncApp,
1265 ) -> BoxFuture<Result<()>> {
1266 unimplemented!()
1267 }
1268
1269 fn commit(
1270 &self,
1271 _message: SharedString,
1272 _name_and_email: Option<(SharedString, SharedString)>,
1273 _env: HashMap<String, String>,
1274 _: AsyncApp,
1275 ) -> BoxFuture<Result<()>> {
1276 unimplemented!()
1277 }
1278
1279 fn push(
1280 &self,
1281 _branch: String,
1282 _remote: String,
1283 _options: Option<PushOptions>,
1284 _ask_pass: AskPassSession,
1285 _env: HashMap<String, String>,
1286 _cx: AsyncApp,
1287 ) -> BoxFuture<Result<RemoteCommandOutput>> {
1288 unimplemented!()
1289 }
1290
1291 fn pull(
1292 &self,
1293 _branch: String,
1294 _remote: String,
1295 _ask_pass: AskPassSession,
1296 _env: HashMap<String, String>,
1297 _cx: AsyncApp,
1298 ) -> BoxFuture<Result<RemoteCommandOutput>> {
1299 unimplemented!()
1300 }
1301
1302 fn fetch(
1303 &self,
1304 _ask_pass: AskPassSession,
1305 _env: HashMap<String, String>,
1306 _cx: AsyncApp,
1307 ) -> BoxFuture<Result<RemoteCommandOutput>> {
1308 unimplemented!()
1309 }
1310
1311 fn get_remotes(
1312 &self,
1313 _branch: Option<String>,
1314 _cx: AsyncApp,
1315 ) -> BoxFuture<Result<Vec<Remote>>> {
1316 unimplemented!()
1317 }
1318
1319 fn check_for_pushed_commit(&self, _cx: AsyncApp) -> BoxFuture<Result<Vec<SharedString>>> {
1320 unimplemented!()
1321 }
1322
1323 fn diff(&self, _diff: DiffType, _cx: AsyncApp) -> BoxFuture<Result<String>> {
1324 unimplemented!()
1325 }
1326}
1327
1328fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1329 match relative_file_path.components().next() {
1330 None => anyhow::bail!("repo path should not be empty"),
1331 Some(Component::Prefix(_)) => anyhow::bail!(
1332 "repo path `{}` should be relative, not a windows prefix",
1333 relative_file_path.to_string_lossy()
1334 ),
1335 Some(Component::RootDir) => {
1336 anyhow::bail!(
1337 "repo path `{}` should be relative",
1338 relative_file_path.to_string_lossy()
1339 )
1340 }
1341 Some(Component::CurDir) => {
1342 anyhow::bail!(
1343 "repo path `{}` should not start with `.`",
1344 relative_file_path.to_string_lossy()
1345 )
1346 }
1347 Some(Component::ParentDir) => {
1348 anyhow::bail!(
1349 "repo path `{}` should not start with `..`",
1350 relative_file_path.to_string_lossy()
1351 )
1352 }
1353 _ => Ok(()),
1354 }
1355}
1356
1357pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1358 LazyLock::new(|| RepoPath(Path::new("").into()));
1359
1360#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1361pub struct RepoPath(pub Arc<Path>);
1362
1363impl RepoPath {
1364 pub fn new(path: PathBuf) -> Self {
1365 debug_assert!(path.is_relative(), "Repo paths must be relative");
1366
1367 RepoPath(path.into())
1368 }
1369
1370 pub fn from_str(path: &str) -> Self {
1371 let path = Path::new(path);
1372 debug_assert!(path.is_relative(), "Repo paths must be relative");
1373
1374 RepoPath(path.into())
1375 }
1376}
1377
1378impl std::fmt::Display for RepoPath {
1379 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1380 self.0.to_string_lossy().fmt(f)
1381 }
1382}
1383
1384impl From<&Path> for RepoPath {
1385 fn from(value: &Path) -> Self {
1386 RepoPath::new(value.into())
1387 }
1388}
1389
1390impl From<Arc<Path>> for RepoPath {
1391 fn from(value: Arc<Path>) -> Self {
1392 RepoPath(value)
1393 }
1394}
1395
1396impl From<PathBuf> for RepoPath {
1397 fn from(value: PathBuf) -> Self {
1398 RepoPath::new(value)
1399 }
1400}
1401
1402impl From<&str> for RepoPath {
1403 fn from(value: &str) -> Self {
1404 Self::from_str(value)
1405 }
1406}
1407
1408impl Default for RepoPath {
1409 fn default() -> Self {
1410 RepoPath(Path::new("").into())
1411 }
1412}
1413
1414impl AsRef<Path> for RepoPath {
1415 fn as_ref(&self) -> &Path {
1416 self.0.as_ref()
1417 }
1418}
1419
1420impl std::ops::Deref for RepoPath {
1421 type Target = Path;
1422
1423 fn deref(&self) -> &Self::Target {
1424 &self.0
1425 }
1426}
1427
1428impl Borrow<Path> for RepoPath {
1429 fn borrow(&self) -> &Path {
1430 self.0.as_ref()
1431 }
1432}
1433
1434#[derive(Debug)]
1435pub struct RepoPathDescendants<'a>(pub &'a Path);
1436
1437impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1438 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1439 if key.starts_with(self.0) {
1440 Ordering::Greater
1441 } else {
1442 self.0.cmp(key)
1443 }
1444 }
1445}
1446
1447fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1448 let mut branches = Vec::new();
1449 for line in input.split('\n') {
1450 if line.is_empty() {
1451 continue;
1452 }
1453 let mut fields = line.split('\x00');
1454 let is_current_branch = fields.next().context("no HEAD")? == "*";
1455 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1456 let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
1457 let ref_name: SharedString = fields
1458 .next()
1459 .context("no refname")?
1460 .strip_prefix("refs/heads/")
1461 .context("unexpected format for refname")?
1462 .to_string()
1463 .into();
1464 let upstream_name = fields.next().context("no upstream")?.to_string();
1465 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1466 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1467 let subject: SharedString = fields
1468 .next()
1469 .context("no contents:subject")?
1470 .to_string()
1471 .into();
1472
1473 branches.push(Branch {
1474 is_head: is_current_branch,
1475 name: ref_name,
1476 most_recent_commit: Some(CommitSummary {
1477 sha: head_sha,
1478 subject,
1479 commit_timestamp: commiterdate,
1480 has_parent: !parent_sha.is_empty(),
1481 }),
1482 upstream: if upstream_name.is_empty() {
1483 None
1484 } else {
1485 Some(Upstream {
1486 ref_name: upstream_name.into(),
1487 tracking: upstream_tracking,
1488 })
1489 },
1490 })
1491 }
1492
1493 Ok(branches)
1494}
1495
1496fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1497 if upstream_track == "" {
1498 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1499 ahead: 0,
1500 behind: 0,
1501 }));
1502 }
1503
1504 let upstream_track = upstream_track
1505 .strip_prefix("[")
1506 .ok_or_else(|| anyhow!("missing ["))?;
1507 let upstream_track = upstream_track
1508 .strip_suffix("]")
1509 .ok_or_else(|| anyhow!("missing ["))?;
1510 let mut ahead: u32 = 0;
1511 let mut behind: u32 = 0;
1512 for component in upstream_track.split(", ") {
1513 if component == "gone" {
1514 return Ok(UpstreamTracking::Gone);
1515 }
1516 if let Some(ahead_num) = component.strip_prefix("ahead ") {
1517 ahead = ahead_num.parse::<u32>()?;
1518 }
1519 if let Some(behind_num) = component.strip_prefix("behind ") {
1520 behind = behind_num.parse::<u32>()?;
1521 }
1522 }
1523 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1524 ahead,
1525 behind,
1526 }))
1527}
1528
1529#[test]
1530fn test_branches_parsing() {
1531 // suppress "help: octal escapes are not supported, `\0` is always null"
1532 #[allow(clippy::octal_escapes)]
1533 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1534 assert_eq!(
1535 parse_branch_input(&input).unwrap(),
1536 vec![Branch {
1537 is_head: true,
1538 name: "zed-patches".into(),
1539 upstream: Some(Upstream {
1540 ref_name: "refs/remotes/origin/zed-patches".into(),
1541 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1542 ahead: 0,
1543 behind: 0
1544 })
1545 }),
1546 most_recent_commit: Some(CommitSummary {
1547 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1548 subject: "generated protobuf".into(),
1549 commit_timestamp: 1733187470,
1550 has_parent: false,
1551 })
1552 }]
1553 )
1554}