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 let git_binary_path = self.git_binary_path.clone();
799 cx.background_spawn(async move {
800 let mut cmd = new_smol_command(&git_binary_path);
801 cmd.current_dir(&working_directory?)
802 .envs(env)
803 .args(["commit", "--quiet", "-m"])
804 .arg(&message.to_string())
805 .arg("--cleanup=strip");
806
807 if let Some((name, email)) = name_and_email {
808 cmd.arg("--author").arg(&format!("{name} <{email}>"));
809 }
810
811 let output = cmd.output().await?;
812
813 if !output.status.success() {
814 return Err(anyhow!(
815 "Failed to commit:\n{}",
816 String::from_utf8_lossy(&output.stderr)
817 ));
818 }
819 Ok(())
820 })
821 .boxed()
822 }
823
824 fn push(
825 &self,
826 branch_name: String,
827 remote_name: String,
828 options: Option<PushOptions>,
829 ask_pass: AskPassSession,
830 env: HashMap<String, String>,
831 // note: git push *must* be started on the main thread for
832 // git-credentials manager to work (hence taking an AsyncApp)
833 _cx: AsyncApp,
834 ) -> BoxFuture<Result<RemoteCommandOutput>> {
835 let working_directory = self.working_directory();
836 async move {
837 let working_directory = working_directory?;
838
839 let mut command = new_smol_command("git");
840 command
841 .envs(env)
842 .env("GIT_ASKPASS", ask_pass.script_path())
843 .env("SSH_ASKPASS", ask_pass.script_path())
844 .env("SSH_ASKPASS_REQUIRE", "force")
845 .env("GIT_HTTP_USER_AGENT", "Zed")
846 .current_dir(&working_directory)
847 .args(["push"])
848 .args(options.map(|option| match option {
849 PushOptions::SetUpstream => "--set-upstream",
850 PushOptions::Force => "--force-with-lease",
851 }))
852 .arg(remote_name)
853 .arg(format!("{}:{}", branch_name, branch_name))
854 .stdin(smol::process::Stdio::null())
855 .stdout(smol::process::Stdio::piped())
856 .stderr(smol::process::Stdio::piped());
857 let git_process = command.spawn()?;
858
859 run_remote_command(ask_pass, git_process).await
860 }
861 .boxed()
862 }
863
864 fn pull(
865 &self,
866 branch_name: String,
867 remote_name: String,
868 ask_pass: AskPassSession,
869 env: HashMap<String, String>,
870 _cx: AsyncApp,
871 ) -> BoxFuture<Result<RemoteCommandOutput>> {
872 let working_directory = self.working_directory();
873 async {
874 let mut command = new_smol_command("git");
875 command
876 .envs(env)
877 .env("GIT_ASKPASS", ask_pass.script_path())
878 .env("SSH_ASKPASS", ask_pass.script_path())
879 .env("SSH_ASKPASS_REQUIRE", "force")
880 .current_dir(&working_directory?)
881 .args(["pull"])
882 .arg(remote_name)
883 .arg(branch_name)
884 .stdout(smol::process::Stdio::piped())
885 .stderr(smol::process::Stdio::piped());
886 let git_process = command.spawn()?;
887
888 run_remote_command(ask_pass, git_process).await
889 }
890 .boxed()
891 }
892
893 fn fetch(
894 &self,
895 ask_pass: AskPassSession,
896 env: HashMap<String, String>,
897 _cx: AsyncApp,
898 ) -> BoxFuture<Result<RemoteCommandOutput>> {
899 let working_directory = self.working_directory();
900 async {
901 let mut command = new_smol_command("git");
902 command
903 .envs(env)
904 .env("GIT_ASKPASS", ask_pass.script_path())
905 .env("SSH_ASKPASS", ask_pass.script_path())
906 .env("SSH_ASKPASS_REQUIRE", "force")
907 .current_dir(&working_directory?)
908 .args(["fetch", "--all"])
909 .stdout(smol::process::Stdio::piped())
910 .stderr(smol::process::Stdio::piped());
911 let git_process = command.spawn()?;
912
913 run_remote_command(ask_pass, git_process).await
914 }
915 .boxed()
916 }
917
918 fn get_remotes(
919 &self,
920 branch_name: Option<String>,
921 cx: AsyncApp,
922 ) -> BoxFuture<Result<Vec<Remote>>> {
923 let working_directory = self.working_directory();
924 let git_binary_path = self.git_binary_path.clone();
925 cx.background_spawn(async move {
926 let working_directory = working_directory?;
927 if let Some(branch_name) = branch_name {
928 let output = new_smol_command(&git_binary_path)
929 .current_dir(&working_directory)
930 .args(["config", "--get"])
931 .arg(format!("branch.{}.remote", branch_name))
932 .output()
933 .await?;
934
935 if output.status.success() {
936 let remote_name = String::from_utf8_lossy(&output.stdout);
937
938 return Ok(vec![Remote {
939 name: remote_name.trim().to_string().into(),
940 }]);
941 }
942 }
943
944 let output = new_smol_command(&git_binary_path)
945 .current_dir(&working_directory)
946 .args(["remote"])
947 .output()
948 .await?;
949
950 if output.status.success() {
951 let remote_names = String::from_utf8_lossy(&output.stdout)
952 .split('\n')
953 .filter(|name| !name.is_empty())
954 .map(|name| Remote {
955 name: name.trim().to_string().into(),
956 })
957 .collect();
958
959 return Ok(remote_names);
960 } else {
961 return Err(anyhow!(
962 "Failed to get remotes:\n{}",
963 String::from_utf8_lossy(&output.stderr)
964 ));
965 }
966 })
967 .boxed()
968 }
969
970 fn check_for_pushed_commit(&self, cx: AsyncApp) -> BoxFuture<Result<Vec<SharedString>>> {
971 let working_directory = self.working_directory();
972 let git_binary_path = self.git_binary_path.clone();
973 cx.background_spawn(async move {
974 let working_directory = working_directory?;
975 let git_cmd = async |args: &[&str]| -> Result<String> {
976 let output = new_smol_command(&git_binary_path)
977 .current_dir(&working_directory)
978 .args(args)
979 .output()
980 .await?;
981 if output.status.success() {
982 Ok(String::from_utf8(output.stdout)?)
983 } else {
984 Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
985 }
986 };
987
988 let head = git_cmd(&["rev-parse", "HEAD"])
989 .await
990 .context("Failed to get HEAD")?
991 .trim()
992 .to_owned();
993
994 let mut remote_branches = vec![];
995 let mut add_if_matching = async |remote_head: &str| {
996 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await {
997 if merge_base.trim() == head {
998 if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
999 remote_branches.push(s.to_owned().into());
1000 }
1001 }
1002 }
1003 };
1004
1005 // check the main branch of each remote
1006 let remotes = git_cmd(&["remote"])
1007 .await
1008 .context("Failed to get remotes")?;
1009 for remote in remotes.lines() {
1010 if let Ok(remote_head) =
1011 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1012 {
1013 add_if_matching(remote_head.trim()).await;
1014 }
1015 }
1016
1017 // ... and the remote branch that the checked-out one is tracking
1018 if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await {
1019 add_if_matching(remote_head.trim()).await;
1020 }
1021
1022 Ok(remote_branches)
1023 })
1024 .boxed()
1025 }
1026}
1027
1028async fn run_remote_command(
1029 mut ask_pass: AskPassSession,
1030 git_process: smol::process::Child,
1031) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
1032 select_biased! {
1033 result = ask_pass.run().fuse() => {
1034 match result {
1035 AskPassResult::CancelledByUser => {
1036 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1037 }
1038 AskPassResult::Timedout => {
1039 Err(anyhow!("Connecting to host timed out"))?
1040 }
1041 }
1042 }
1043 output = git_process.output().fuse() => {
1044 let output = output?;
1045 if !output.status.success() {
1046 Err(anyhow!(
1047 "{}",
1048 String::from_utf8_lossy(&output.stderr)
1049 ))
1050 } else {
1051 Ok(RemoteCommandOutput {
1052 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1053 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1054 })
1055 }
1056 }
1057 }
1058}
1059
1060#[derive(Debug, Clone)]
1061pub struct FakeGitRepository {
1062 state: Arc<Mutex<FakeGitRepositoryState>>,
1063}
1064
1065#[derive(Debug, Clone)]
1066pub struct FakeGitRepositoryState {
1067 pub path: PathBuf,
1068 pub event_emitter: smol::channel::Sender<PathBuf>,
1069 pub head_contents: HashMap<RepoPath, String>,
1070 pub index_contents: HashMap<RepoPath, String>,
1071 pub blames: HashMap<RepoPath, Blame>,
1072 pub statuses: HashMap<RepoPath, FileStatus>,
1073 pub current_branch_name: Option<String>,
1074 pub branches: HashSet<String>,
1075 pub simulated_index_write_error_message: Option<String>,
1076}
1077
1078impl FakeGitRepository {
1079 pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
1080 Arc::new(FakeGitRepository { state })
1081 }
1082}
1083
1084impl FakeGitRepositoryState {
1085 pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
1086 FakeGitRepositoryState {
1087 path,
1088 event_emitter,
1089 head_contents: Default::default(),
1090 index_contents: Default::default(),
1091 blames: Default::default(),
1092 statuses: Default::default(),
1093 current_branch_name: Default::default(),
1094 branches: Default::default(),
1095 simulated_index_write_error_message: None,
1096 }
1097 }
1098}
1099
1100impl GitRepository for FakeGitRepository {
1101 fn reload_index(&self) {}
1102
1103 fn load_index_text(&self, path: RepoPath, _: AsyncApp) -> BoxFuture<Option<String>> {
1104 let state = self.state.lock();
1105 let content = state.index_contents.get(path.as_ref()).cloned();
1106 async { content }.boxed()
1107 }
1108
1109 fn load_committed_text(&self, path: RepoPath, _: AsyncApp) -> BoxFuture<Option<String>> {
1110 let state = self.state.lock();
1111 let content = state.head_contents.get(path.as_ref()).cloned();
1112 async { content }.boxed()
1113 }
1114
1115 fn set_index_text(
1116 &self,
1117 path: RepoPath,
1118 content: Option<String>,
1119 _env: HashMap<String, String>,
1120 _cx: AsyncApp,
1121 ) -> BoxFuture<anyhow::Result<()>> {
1122 let mut state = self.state.lock();
1123 if let Some(message) = state.simulated_index_write_error_message.clone() {
1124 return async { Err(anyhow::anyhow!(message)) }.boxed();
1125 }
1126 if let Some(content) = content {
1127 state.index_contents.insert(path.clone(), content);
1128 } else {
1129 state.index_contents.remove(&path);
1130 }
1131 state
1132 .event_emitter
1133 .try_send(state.path.clone())
1134 .expect("Dropped repo change event");
1135 async { Ok(()) }.boxed()
1136 }
1137
1138 fn remote_url(&self, _name: &str) -> Option<String> {
1139 None
1140 }
1141
1142 fn head_sha(&self) -> Option<String> {
1143 None
1144 }
1145
1146 fn merge_head_shas(&self) -> Vec<String> {
1147 vec![]
1148 }
1149
1150 fn show(&self, _: String, _: AsyncApp) -> BoxFuture<Result<CommitDetails>> {
1151 unimplemented!()
1152 }
1153
1154 fn reset(&self, _: String, _: ResetMode, _: HashMap<String, String>) -> BoxFuture<Result<()>> {
1155 unimplemented!()
1156 }
1157
1158 fn checkout_files(
1159 &self,
1160 _: String,
1161 _: Vec<RepoPath>,
1162 _: HashMap<String, String>,
1163 ) -> BoxFuture<Result<()>> {
1164 unimplemented!()
1165 }
1166
1167 fn path(&self) -> PathBuf {
1168 let state = self.state.lock();
1169 state.path.clone()
1170 }
1171
1172 fn main_repository_path(&self) -> PathBuf {
1173 self.path()
1174 }
1175
1176 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
1177 let state = self.state.lock();
1178
1179 let mut entries = state
1180 .statuses
1181 .iter()
1182 .filter_map(|(repo_path, status)| {
1183 if path_prefixes
1184 .iter()
1185 .any(|path_prefix| repo_path.0.starts_with(path_prefix))
1186 {
1187 Some((repo_path.to_owned(), *status))
1188 } else {
1189 None
1190 }
1191 })
1192 .collect::<Vec<_>>();
1193 entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
1194
1195 Ok(GitStatus {
1196 entries: entries.into(),
1197 })
1198 }
1199
1200 fn branches(&self) -> BoxFuture<Result<Vec<Branch>>> {
1201 let state = self.state.lock();
1202 let current_branch = &state.current_branch_name;
1203 let result = Ok(state
1204 .branches
1205 .iter()
1206 .map(|branch_name| Branch {
1207 is_head: Some(branch_name) == current_branch.as_ref(),
1208 name: branch_name.into(),
1209 most_recent_commit: None,
1210 upstream: None,
1211 })
1212 .collect());
1213
1214 async { result }.boxed()
1215 }
1216
1217 fn change_branch(&self, name: String, _: AsyncApp) -> BoxFuture<Result<()>> {
1218 let mut state = self.state.lock();
1219 state.current_branch_name = Some(name.to_owned());
1220 state
1221 .event_emitter
1222 .try_send(state.path.clone())
1223 .expect("Dropped repo change event");
1224 async { Ok(()) }.boxed()
1225 }
1226
1227 fn create_branch(&self, name: String, _: AsyncApp) -> BoxFuture<Result<()>> {
1228 let mut state = self.state.lock();
1229 state.branches.insert(name.to_owned());
1230 state
1231 .event_emitter
1232 .try_send(state.path.clone())
1233 .expect("Dropped repo change event");
1234 async { Ok(()) }.boxed()
1235 }
1236
1237 fn blame(
1238 &self,
1239 path: RepoPath,
1240 _content: Rope,
1241 _cx: AsyncApp,
1242 ) -> BoxFuture<Result<crate::blame::Blame>> {
1243 let state = self.state.lock();
1244 let result = state
1245 .blames
1246 .get(&path)
1247 .with_context(|| format!("failed to get blame for {:?}", path.0))
1248 .cloned();
1249 async { result }.boxed()
1250 }
1251
1252 fn stage_paths(
1253 &self,
1254 _paths: Vec<RepoPath>,
1255 _env: HashMap<String, String>,
1256 _cx: AsyncApp,
1257 ) -> BoxFuture<Result<()>> {
1258 unimplemented!()
1259 }
1260
1261 fn unstage_paths(
1262 &self,
1263 _paths: Vec<RepoPath>,
1264 _env: HashMap<String, String>,
1265 _cx: AsyncApp,
1266 ) -> BoxFuture<Result<()>> {
1267 unimplemented!()
1268 }
1269
1270 fn commit(
1271 &self,
1272 _message: SharedString,
1273 _name_and_email: Option<(SharedString, SharedString)>,
1274 _env: HashMap<String, String>,
1275 _: AsyncApp,
1276 ) -> BoxFuture<Result<()>> {
1277 unimplemented!()
1278 }
1279
1280 fn push(
1281 &self,
1282 _branch: String,
1283 _remote: String,
1284 _options: Option<PushOptions>,
1285 _ask_pass: AskPassSession,
1286 _env: HashMap<String, String>,
1287 _cx: AsyncApp,
1288 ) -> BoxFuture<Result<RemoteCommandOutput>> {
1289 unimplemented!()
1290 }
1291
1292 fn pull(
1293 &self,
1294 _branch: String,
1295 _remote: String,
1296 _ask_pass: AskPassSession,
1297 _env: HashMap<String, String>,
1298 _cx: AsyncApp,
1299 ) -> BoxFuture<Result<RemoteCommandOutput>> {
1300 unimplemented!()
1301 }
1302
1303 fn fetch(
1304 &self,
1305 _ask_pass: AskPassSession,
1306 _env: HashMap<String, String>,
1307 _cx: AsyncApp,
1308 ) -> BoxFuture<Result<RemoteCommandOutput>> {
1309 unimplemented!()
1310 }
1311
1312 fn get_remotes(
1313 &self,
1314 _branch: Option<String>,
1315 _cx: AsyncApp,
1316 ) -> BoxFuture<Result<Vec<Remote>>> {
1317 unimplemented!()
1318 }
1319
1320 fn check_for_pushed_commit(&self, _cx: AsyncApp) -> BoxFuture<Result<Vec<SharedString>>> {
1321 unimplemented!()
1322 }
1323
1324 fn diff(&self, _diff: DiffType, _cx: AsyncApp) -> BoxFuture<Result<String>> {
1325 unimplemented!()
1326 }
1327}
1328
1329fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1330 match relative_file_path.components().next() {
1331 None => anyhow::bail!("repo path should not be empty"),
1332 Some(Component::Prefix(_)) => anyhow::bail!(
1333 "repo path `{}` should be relative, not a windows prefix",
1334 relative_file_path.to_string_lossy()
1335 ),
1336 Some(Component::RootDir) => {
1337 anyhow::bail!(
1338 "repo path `{}` should be relative",
1339 relative_file_path.to_string_lossy()
1340 )
1341 }
1342 Some(Component::CurDir) => {
1343 anyhow::bail!(
1344 "repo path `{}` should not start with `.`",
1345 relative_file_path.to_string_lossy()
1346 )
1347 }
1348 Some(Component::ParentDir) => {
1349 anyhow::bail!(
1350 "repo path `{}` should not start with `..`",
1351 relative_file_path.to_string_lossy()
1352 )
1353 }
1354 _ => Ok(()),
1355 }
1356}
1357
1358pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1359 LazyLock::new(|| RepoPath(Path::new("").into()));
1360
1361#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1362pub struct RepoPath(pub Arc<Path>);
1363
1364impl RepoPath {
1365 pub fn new(path: PathBuf) -> Self {
1366 debug_assert!(path.is_relative(), "Repo paths must be relative");
1367
1368 RepoPath(path.into())
1369 }
1370
1371 pub fn from_str(path: &str) -> Self {
1372 let path = Path::new(path);
1373 debug_assert!(path.is_relative(), "Repo paths must be relative");
1374
1375 RepoPath(path.into())
1376 }
1377}
1378
1379impl std::fmt::Display for RepoPath {
1380 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1381 self.0.to_string_lossy().fmt(f)
1382 }
1383}
1384
1385impl From<&Path> for RepoPath {
1386 fn from(value: &Path) -> Self {
1387 RepoPath::new(value.into())
1388 }
1389}
1390
1391impl From<Arc<Path>> for RepoPath {
1392 fn from(value: Arc<Path>) -> Self {
1393 RepoPath(value)
1394 }
1395}
1396
1397impl From<PathBuf> for RepoPath {
1398 fn from(value: PathBuf) -> Self {
1399 RepoPath::new(value)
1400 }
1401}
1402
1403impl From<&str> for RepoPath {
1404 fn from(value: &str) -> Self {
1405 Self::from_str(value)
1406 }
1407}
1408
1409impl Default for RepoPath {
1410 fn default() -> Self {
1411 RepoPath(Path::new("").into())
1412 }
1413}
1414
1415impl AsRef<Path> for RepoPath {
1416 fn as_ref(&self) -> &Path {
1417 self.0.as_ref()
1418 }
1419}
1420
1421impl std::ops::Deref for RepoPath {
1422 type Target = Path;
1423
1424 fn deref(&self) -> &Self::Target {
1425 &self.0
1426 }
1427}
1428
1429impl Borrow<Path> for RepoPath {
1430 fn borrow(&self) -> &Path {
1431 self.0.as_ref()
1432 }
1433}
1434
1435#[derive(Debug)]
1436pub struct RepoPathDescendants<'a>(pub &'a Path);
1437
1438impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1439 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1440 if key.starts_with(self.0) {
1441 Ordering::Greater
1442 } else {
1443 self.0.cmp(key)
1444 }
1445 }
1446}
1447
1448fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1449 let mut branches = Vec::new();
1450 for line in input.split('\n') {
1451 if line.is_empty() {
1452 continue;
1453 }
1454 let mut fields = line.split('\x00');
1455 let is_current_branch = fields.next().context("no HEAD")? == "*";
1456 let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1457 let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
1458 let ref_name: SharedString = fields
1459 .next()
1460 .context("no refname")?
1461 .strip_prefix("refs/heads/")
1462 .context("unexpected format for refname")?
1463 .to_string()
1464 .into();
1465 let upstream_name = fields.next().context("no upstream")?.to_string();
1466 let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1467 let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1468 let subject: SharedString = fields
1469 .next()
1470 .context("no contents:subject")?
1471 .to_string()
1472 .into();
1473
1474 branches.push(Branch {
1475 is_head: is_current_branch,
1476 name: ref_name,
1477 most_recent_commit: Some(CommitSummary {
1478 sha: head_sha,
1479 subject,
1480 commit_timestamp: commiterdate,
1481 has_parent: !parent_sha.is_empty(),
1482 }),
1483 upstream: if upstream_name.is_empty() {
1484 None
1485 } else {
1486 Some(Upstream {
1487 ref_name: upstream_name.into(),
1488 tracking: upstream_tracking,
1489 })
1490 },
1491 })
1492 }
1493
1494 Ok(branches)
1495}
1496
1497fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1498 if upstream_track == "" {
1499 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1500 ahead: 0,
1501 behind: 0,
1502 }));
1503 }
1504
1505 let upstream_track = upstream_track
1506 .strip_prefix("[")
1507 .ok_or_else(|| anyhow!("missing ["))?;
1508 let upstream_track = upstream_track
1509 .strip_suffix("]")
1510 .ok_or_else(|| anyhow!("missing ["))?;
1511 let mut ahead: u32 = 0;
1512 let mut behind: u32 = 0;
1513 for component in upstream_track.split(", ") {
1514 if component == "gone" {
1515 return Ok(UpstreamTracking::Gone);
1516 }
1517 if let Some(ahead_num) = component.strip_prefix("ahead ") {
1518 ahead = ahead_num.parse::<u32>()?;
1519 }
1520 if let Some(behind_num) = component.strip_prefix("behind ") {
1521 behind = behind_num.parse::<u32>()?;
1522 }
1523 }
1524 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1525 ahead,
1526 behind,
1527 }))
1528}
1529
1530#[test]
1531fn test_branches_parsing() {
1532 // suppress "help: octal escapes are not supported, `\0` is always null"
1533 #[allow(clippy::octal_escapes)]
1534 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1535 assert_eq!(
1536 parse_branch_input(&input).unwrap(),
1537 vec![Branch {
1538 is_head: true,
1539 name: "zed-patches".into(),
1540 upstream: Some(Upstream {
1541 ref_name: "refs/remotes/origin/zed-patches".into(),
1542 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1543 ahead: 0,
1544 behind: 0
1545 })
1546 }),
1547 most_recent_commit: Some(CommitSummary {
1548 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1549 subject: "generated protobuf".into(),
1550 commit_timestamp: 1733187470,
1551 has_parent: false,
1552 })
1553 }]
1554 )
1555}