1use crate::commit::parse_git_diff_name_status;
2use crate::stash::GitStash;
3use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
4use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
5use anyhow::{Context as _, Result, anyhow, bail};
6use collections::HashMap;
7use futures::channel::oneshot;
8use futures::future::BoxFuture;
9use futures::io::BufWriter;
10use futures::{AsyncWriteExt, FutureExt as _, select_biased};
11use git2::{BranchType, ErrorCode};
12use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
13use parking_lot::Mutex;
14use rope::Rope;
15use schemars::JsonSchema;
16use serde::Deserialize;
17use smallvec::SmallVec;
18use smol::channel::Sender;
19use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
20use text::LineEnding;
21
22use std::collections::HashSet;
23use std::ffi::{OsStr, OsString};
24use std::process::{ExitStatus, Stdio};
25use std::str::FromStr;
26use std::{
27 cmp::Ordering,
28 future,
29 path::{Path, PathBuf},
30 sync::Arc,
31};
32use sum_tree::MapSeekTarget;
33use thiserror::Error;
34use util::command::new_smol_command;
35use util::paths::PathStyle;
36use util::rel_path::RelPath;
37use util::{ResultExt, paths};
38use uuid::Uuid;
39
40pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
41
42pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
43
44/// Format string used in graph log to get initial data for the git graph
45/// %H - Full commit hash
46/// %P - Parent hashes
47/// %D - Ref names
48/// %x00 - Null byte separator, used to split up commit data
49static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D";
50
51/// Number of commits to load per chunk for the git graph.
52pub const GRAPH_CHUNK_SIZE: usize = 1000;
53
54/// Commit data needed for the git graph visualization.
55#[derive(Debug, Clone)]
56pub struct GraphCommitData {
57 pub sha: Oid,
58 /// Most commits have a single parent, so we use a SmallVec to avoid allocations.
59 pub parents: SmallVec<[Oid; 1]>,
60 pub author_name: SharedString,
61 pub author_email: SharedString,
62 pub commit_timestamp: i64,
63 pub subject: SharedString,
64}
65
66#[derive(Debug)]
67pub struct InitialGraphCommitData {
68 pub sha: Oid,
69 pub parents: SmallVec<[Oid; 1]>,
70 pub ref_names: Vec<SharedString>,
71}
72
73struct CommitDataRequest {
74 sha: Oid,
75 response_tx: oneshot::Sender<Result<GraphCommitData>>,
76}
77
78pub struct CommitDataReader {
79 request_tx: smol::channel::Sender<CommitDataRequest>,
80 _task: Task<()>,
81}
82
83impl CommitDataReader {
84 pub async fn read(&self, sha: Oid) -> Result<GraphCommitData> {
85 let (response_tx, response_rx) = oneshot::channel();
86 self.request_tx
87 .send(CommitDataRequest { sha, response_tx })
88 .await
89 .map_err(|_| anyhow!("commit data reader task closed"))?;
90 response_rx
91 .await
92 .map_err(|_| anyhow!("commit data reader task dropped response"))?
93 }
94}
95
96fn parse_cat_file_commit(sha: Oid, content: &str) -> Option<GraphCommitData> {
97 let mut parents = SmallVec::new();
98 let mut author_name = SharedString::default();
99 let mut author_email = SharedString::default();
100 let mut commit_timestamp = 0i64;
101 let mut in_headers = true;
102 let mut subject = None;
103
104 for line in content.lines() {
105 if in_headers {
106 if line.is_empty() {
107 in_headers = false;
108 continue;
109 }
110
111 if let Some(parent_sha) = line.strip_prefix("parent ") {
112 if let Ok(oid) = Oid::from_str(parent_sha.trim()) {
113 parents.push(oid);
114 }
115 } else if let Some(author_line) = line.strip_prefix("author ") {
116 if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') {
117 if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') {
118 if let Ok(ts) = timestamp_str.parse::<i64>() {
119 commit_timestamp = ts;
120 }
121 if let Some((name, email)) = name_email.rsplit_once(" <") {
122 author_name = SharedString::from(name.to_string());
123 author_email =
124 SharedString::from(email.trim_end_matches('>').to_string());
125 }
126 }
127 }
128 }
129 } else if subject.is_none() {
130 subject = Some(SharedString::from(line.to_string()));
131 }
132 }
133
134 Some(GraphCommitData {
135 sha,
136 parents,
137 author_name,
138 author_email,
139 commit_timestamp,
140 subject: subject.unwrap_or_default(),
141 })
142}
143
144#[derive(Clone, Debug, Hash, PartialEq, Eq)]
145pub struct Branch {
146 pub is_head: bool,
147 pub ref_name: SharedString,
148 pub upstream: Option<Upstream>,
149 pub most_recent_commit: Option<CommitSummary>,
150}
151
152impl Branch {
153 pub fn name(&self) -> &str {
154 self.ref_name
155 .as_ref()
156 .strip_prefix("refs/heads/")
157 .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
158 .unwrap_or(self.ref_name.as_ref())
159 }
160
161 pub fn is_remote(&self) -> bool {
162 self.ref_name.starts_with("refs/remotes/")
163 }
164
165 pub fn remote_name(&self) -> Option<&str> {
166 self.ref_name
167 .strip_prefix("refs/remotes/")
168 .and_then(|stripped| stripped.split("/").next())
169 }
170
171 pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
172 self.upstream
173 .as_ref()
174 .and_then(|upstream| upstream.tracking.status())
175 }
176
177 pub fn priority_key(&self) -> (bool, Option<i64>) {
178 (
179 self.is_head,
180 self.most_recent_commit
181 .as_ref()
182 .map(|commit| commit.commit_timestamp),
183 )
184 }
185}
186
187#[derive(Clone, Debug, Hash, PartialEq, Eq)]
188pub struct Worktree {
189 pub path: PathBuf,
190 pub ref_name: SharedString,
191 pub sha: SharedString,
192}
193
194impl Worktree {
195 pub fn branch(&self) -> &str {
196 self.ref_name
197 .as_ref()
198 .strip_prefix("refs/heads/")
199 .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
200 .unwrap_or(self.ref_name.as_ref())
201 }
202}
203
204pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
205 let mut worktrees = Vec::new();
206 let entries = raw_worktrees.as_ref().split("\n\n");
207 for entry in entries {
208 let mut parts = entry.splitn(3, '\n');
209 let path = parts
210 .next()
211 .and_then(|p| p.split_once(' ').map(|(_, path)| path.to_string()));
212 let sha = parts
213 .next()
214 .and_then(|p| p.split_once(' ').map(|(_, sha)| sha.to_string()));
215 let ref_name = parts
216 .next()
217 .and_then(|p| p.split_once(' ').map(|(_, ref_name)| ref_name.to_string()));
218
219 if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) {
220 worktrees.push(Worktree {
221 path: PathBuf::from(path),
222 ref_name: ref_name.into(),
223 sha: sha.into(),
224 })
225 }
226 }
227
228 worktrees
229}
230
231#[derive(Clone, Debug, Hash, PartialEq, Eq)]
232pub struct Upstream {
233 pub ref_name: SharedString,
234 pub tracking: UpstreamTracking,
235}
236
237impl Upstream {
238 pub fn is_remote(&self) -> bool {
239 self.remote_name().is_some()
240 }
241
242 pub fn remote_name(&self) -> Option<&str> {
243 self.ref_name
244 .strip_prefix("refs/remotes/")
245 .and_then(|stripped| stripped.split("/").next())
246 }
247
248 pub fn stripped_ref_name(&self) -> Option<&str> {
249 self.ref_name.strip_prefix("refs/remotes/")
250 }
251
252 pub fn branch_name(&self) -> Option<&str> {
253 self.ref_name
254 .strip_prefix("refs/remotes/")
255 .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
256 }
257}
258
259#[derive(Clone, Copy, Default)]
260pub struct CommitOptions {
261 pub amend: bool,
262 pub signoff: bool,
263}
264
265#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
266pub enum UpstreamTracking {
267 /// Remote ref not present in local repository.
268 Gone,
269 /// Remote ref present in local repository (fetched from remote).
270 Tracked(UpstreamTrackingStatus),
271}
272
273impl From<UpstreamTrackingStatus> for UpstreamTracking {
274 fn from(status: UpstreamTrackingStatus) -> Self {
275 UpstreamTracking::Tracked(status)
276 }
277}
278
279impl UpstreamTracking {
280 pub fn is_gone(&self) -> bool {
281 matches!(self, UpstreamTracking::Gone)
282 }
283
284 pub fn status(&self) -> Option<UpstreamTrackingStatus> {
285 match self {
286 UpstreamTracking::Gone => None,
287 UpstreamTracking::Tracked(status) => Some(*status),
288 }
289 }
290}
291
292#[derive(Debug, Clone)]
293pub struct RemoteCommandOutput {
294 pub stdout: String,
295 pub stderr: String,
296}
297
298impl RemoteCommandOutput {
299 pub fn is_empty(&self) -> bool {
300 self.stdout.is_empty() && self.stderr.is_empty()
301 }
302}
303
304#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
305pub struct UpstreamTrackingStatus {
306 pub ahead: u32,
307 pub behind: u32,
308}
309
310#[derive(Clone, Debug, Hash, PartialEq, Eq)]
311pub struct CommitSummary {
312 pub sha: SharedString,
313 pub subject: SharedString,
314 /// This is a unix timestamp
315 pub commit_timestamp: i64,
316 pub author_name: SharedString,
317 pub has_parent: bool,
318}
319
320#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
321pub struct CommitDetails {
322 pub sha: SharedString,
323 pub message: SharedString,
324 pub commit_timestamp: i64,
325 pub author_email: SharedString,
326 pub author_name: SharedString,
327}
328
329#[derive(Clone, Debug, Hash, PartialEq, Eq)]
330pub struct FileHistoryEntry {
331 pub sha: SharedString,
332 pub subject: SharedString,
333 pub message: SharedString,
334 pub commit_timestamp: i64,
335 pub author_name: SharedString,
336 pub author_email: SharedString,
337}
338
339#[derive(Debug, Clone)]
340pub struct FileHistory {
341 pub entries: Vec<FileHistoryEntry>,
342 pub path: RepoPath,
343}
344
345#[derive(Debug)]
346pub struct CommitDiff {
347 pub files: Vec<CommitFile>,
348}
349
350#[derive(Debug)]
351pub struct CommitFile {
352 pub path: RepoPath,
353 pub old_text: Option<String>,
354 pub new_text: Option<String>,
355 pub is_binary: bool,
356}
357
358impl CommitDetails {
359 pub fn short_sha(&self) -> SharedString {
360 self.sha[..SHORT_SHA_LENGTH].to_string().into()
361 }
362}
363
364/// Detects if content is binary by checking for NUL bytes in the first 8000 bytes.
365/// This matches git's binary detection heuristic.
366pub fn is_binary_content(content: &[u8]) -> bool {
367 let check_len = content.len().min(8000);
368 content[..check_len].contains(&0)
369}
370
371#[derive(Debug, Clone, Hash, PartialEq, Eq)]
372pub struct Remote {
373 pub name: SharedString,
374}
375
376pub enum ResetMode {
377 /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
378 /// committed are now staged).
379 Soft,
380 /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
381 /// committed are now unstaged).
382 Mixed,
383}
384
385#[derive(Debug, Clone, Hash, PartialEq, Eq)]
386pub enum FetchOptions {
387 All,
388 Remote(Remote),
389}
390
391impl FetchOptions {
392 pub fn to_proto(&self) -> Option<String> {
393 match self {
394 FetchOptions::All => None,
395 FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
396 }
397 }
398
399 pub fn from_proto(remote_name: Option<String>) -> Self {
400 match remote_name {
401 Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
402 None => FetchOptions::All,
403 }
404 }
405
406 pub fn name(&self) -> SharedString {
407 match self {
408 Self::All => "Fetch all remotes".into(),
409 Self::Remote(remote) => remote.name.clone(),
410 }
411 }
412}
413
414impl std::fmt::Display for FetchOptions {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 match self {
417 FetchOptions::All => write!(f, "--all"),
418 FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
419 }
420 }
421}
422
423/// Modifies .git/info/exclude temporarily
424pub struct GitExcludeOverride {
425 git_exclude_path: PathBuf,
426 original_excludes: Option<String>,
427 added_excludes: Option<String>,
428}
429
430impl GitExcludeOverride {
431 const START_BLOCK_MARKER: &str = "\n\n# ====== Auto-added by Zed: =======\n";
432 const END_BLOCK_MARKER: &str = "\n# ====== End of auto-added by Zed =======\n";
433
434 pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
435 let original_excludes =
436 smol::fs::read_to_string(&git_exclude_path)
437 .await
438 .ok()
439 .map(|content| {
440 // Auto-generated lines are normally cleaned up in
441 // `restore_original()` or `drop()`, but may stuck in rare cases.
442 // Make sure to remove them.
443 Self::remove_auto_generated_block(&content)
444 });
445
446 Ok(GitExcludeOverride {
447 git_exclude_path,
448 original_excludes,
449 added_excludes: None,
450 })
451 }
452
453 pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
454 self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
455 format!("{already_added}\n{excludes}")
456 } else {
457 excludes.to_string()
458 });
459
460 let mut content = self.original_excludes.clone().unwrap_or_default();
461
462 content.push_str(Self::START_BLOCK_MARKER);
463 content.push_str(self.added_excludes.as_ref().unwrap());
464 content.push_str(Self::END_BLOCK_MARKER);
465
466 smol::fs::write(&self.git_exclude_path, content).await?;
467 Ok(())
468 }
469
470 pub async fn restore_original(&mut self) -> Result<()> {
471 if let Some(ref original) = self.original_excludes {
472 smol::fs::write(&self.git_exclude_path, original).await?;
473 } else if self.git_exclude_path.exists() {
474 smol::fs::remove_file(&self.git_exclude_path).await?;
475 }
476
477 self.added_excludes = None;
478
479 Ok(())
480 }
481
482 fn remove_auto_generated_block(content: &str) -> String {
483 let start_marker = Self::START_BLOCK_MARKER;
484 let end_marker = Self::END_BLOCK_MARKER;
485 let mut content = content.to_string();
486
487 let start_index = content.find(start_marker);
488 let end_index = content.rfind(end_marker);
489
490 if let (Some(start), Some(end)) = (start_index, end_index) {
491 if end > start {
492 content.replace_range(start..end + end_marker.len(), "");
493 }
494 }
495
496 // Older versions of Zed didn't have end-of-block markers,
497 // so it's impossible to determine auto-generated lines.
498 // Conservatively remove the standard list of excludes
499 let standard_excludes = format!(
500 "{}{}",
501 Self::START_BLOCK_MARKER,
502 include_str!("./checkpoint.gitignore")
503 );
504 content = content.replace(&standard_excludes, "");
505
506 content
507 }
508}
509
510impl Drop for GitExcludeOverride {
511 fn drop(&mut self) {
512 if self.added_excludes.is_some() {
513 let git_exclude_path = self.git_exclude_path.clone();
514 let original_excludes = self.original_excludes.clone();
515 smol::spawn(async move {
516 if let Some(original) = original_excludes {
517 smol::fs::write(&git_exclude_path, original).await
518 } else {
519 smol::fs::remove_file(&git_exclude_path).await
520 }
521 })
522 .detach();
523 }
524 }
525}
526
527#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)]
528pub enum LogOrder {
529 #[default]
530 DateOrder,
531 TopoOrder,
532 AuthorDateOrder,
533 ReverseChronological,
534}
535
536impl LogOrder {
537 pub fn as_arg(&self) -> &'static str {
538 match self {
539 LogOrder::DateOrder => "--date-order",
540 LogOrder::TopoOrder => "--topo-order",
541 LogOrder::AuthorDateOrder => "--author-date-order",
542 LogOrder::ReverseChronological => "--reverse",
543 }
544 }
545}
546
547#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
548pub enum LogSource {
549 #[default]
550 All,
551 Branch(SharedString),
552 Sha(Oid),
553}
554
555impl LogSource {
556 fn get_arg(&self) -> Result<&str> {
557 match self {
558 LogSource::All => Ok("--all"),
559 LogSource::Branch(branch) => Ok(branch.as_str()),
560 LogSource::Sha(oid) => {
561 str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")
562 }
563 }
564 }
565}
566
567pub trait GitRepository: Send + Sync {
568 fn reload_index(&self);
569
570 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
571 ///
572 /// Also returns `None` for symlinks.
573 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
574
575 /// 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.
576 ///
577 /// Also returns `None` for symlinks.
578 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
579 fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
580
581 fn set_index_text(
582 &self,
583 path: RepoPath,
584 content: Option<String>,
585 env: Arc<HashMap<String, String>>,
586 is_executable: bool,
587 ) -> BoxFuture<'_, anyhow::Result<()>>;
588
589 /// Returns the URL of the remote with the given name.
590 fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>>;
591
592 /// Resolve a list of refs to SHAs.
593 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
594
595 fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
596 async move {
597 self.revparse_batch(vec!["HEAD".into()])
598 .await
599 .unwrap_or_default()
600 .into_iter()
601 .next()
602 .flatten()
603 }
604 .boxed()
605 }
606
607 fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
608
609 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
610 fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
611
612 fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
613
614 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
615
616 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
617 fn create_branch(&self, name: String, base_branch: Option<String>)
618 -> BoxFuture<'_, Result<()>>;
619 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
620
621 fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
622
623 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
624
625 fn create_worktree(
626 &self,
627 name: String,
628 directory: PathBuf,
629 from_commit: Option<String>,
630 ) -> BoxFuture<'_, Result<()>>;
631
632 fn reset(
633 &self,
634 commit: String,
635 mode: ResetMode,
636 env: Arc<HashMap<String, String>>,
637 ) -> BoxFuture<'_, Result<()>>;
638
639 fn checkout_files(
640 &self,
641 commit: String,
642 paths: Vec<RepoPath>,
643 env: Arc<HashMap<String, String>>,
644 ) -> BoxFuture<'_, Result<()>>;
645
646 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
647
648 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
649 fn blame(
650 &self,
651 path: RepoPath,
652 content: Rope,
653 line_ending: LineEnding,
654 ) -> BoxFuture<'_, Result<crate::blame::Blame>>;
655 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>>;
656 fn file_history_paginated(
657 &self,
658 path: RepoPath,
659 skip: usize,
660 limit: Option<usize>,
661 ) -> BoxFuture<'_, Result<FileHistory>>;
662
663 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
664 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
665 fn path(&self) -> PathBuf;
666
667 fn main_repository_path(&self) -> PathBuf;
668
669 /// Updates the index to match the worktree at the given paths.
670 ///
671 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
672 fn stage_paths(
673 &self,
674 paths: Vec<RepoPath>,
675 env: Arc<HashMap<String, String>>,
676 ) -> BoxFuture<'_, Result<()>>;
677 /// Updates the index to match HEAD at the given paths.
678 ///
679 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
680 fn unstage_paths(
681 &self,
682 paths: Vec<RepoPath>,
683 env: Arc<HashMap<String, String>>,
684 ) -> BoxFuture<'_, Result<()>>;
685
686 fn run_hook(
687 &self,
688 hook: RunHook,
689 env: Arc<HashMap<String, String>>,
690 ) -> BoxFuture<'_, Result<()>>;
691
692 fn commit(
693 &self,
694 message: SharedString,
695 name_and_email: Option<(SharedString, SharedString)>,
696 options: CommitOptions,
697 askpass: AskPassDelegate,
698 env: Arc<HashMap<String, String>>,
699 ) -> BoxFuture<'_, Result<()>>;
700
701 fn stash_paths(
702 &self,
703 paths: Vec<RepoPath>,
704 env: Arc<HashMap<String, String>>,
705 ) -> BoxFuture<'_, Result<()>>;
706
707 fn stash_pop(
708 &self,
709 index: Option<usize>,
710 env: Arc<HashMap<String, String>>,
711 ) -> BoxFuture<'_, Result<()>>;
712
713 fn stash_apply(
714 &self,
715 index: Option<usize>,
716 env: Arc<HashMap<String, String>>,
717 ) -> BoxFuture<'_, Result<()>>;
718
719 fn stash_drop(
720 &self,
721 index: Option<usize>,
722 env: Arc<HashMap<String, String>>,
723 ) -> BoxFuture<'_, Result<()>>;
724
725 fn push(
726 &self,
727 branch_name: String,
728 remote_branch_name: String,
729 upstream_name: String,
730 options: Option<PushOptions>,
731 askpass: AskPassDelegate,
732 env: Arc<HashMap<String, String>>,
733 // This method takes an AsyncApp to ensure it's invoked on the main thread,
734 // otherwise git-credentials-manager won't work.
735 cx: AsyncApp,
736 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
737
738 fn pull(
739 &self,
740 branch_name: Option<String>,
741 upstream_name: String,
742 rebase: bool,
743 askpass: AskPassDelegate,
744 env: Arc<HashMap<String, String>>,
745 // This method takes an AsyncApp to ensure it's invoked on the main thread,
746 // otherwise git-credentials-manager won't work.
747 cx: AsyncApp,
748 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
749
750 fn fetch(
751 &self,
752 fetch_options: FetchOptions,
753 askpass: AskPassDelegate,
754 env: Arc<HashMap<String, String>>,
755 // This method takes an AsyncApp to ensure it's invoked on the main thread,
756 // otherwise git-credentials-manager won't work.
757 cx: AsyncApp,
758 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
759
760 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
761
762 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
763
764 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
765
766 fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>;
767
768 fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>;
769
770 /// returns a list of remote branches that contain HEAD
771 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
772
773 /// Run git diff
774 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
775
776 /// Creates a checkpoint for the repository.
777 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
778
779 /// Resets to a previously-created checkpoint.
780 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
781
782 /// Compares two checkpoints, returning true if they are equal
783 fn compare_checkpoints(
784 &self,
785 left: GitRepositoryCheckpoint,
786 right: GitRepositoryCheckpoint,
787 ) -> BoxFuture<'_, Result<bool>>;
788
789 /// Computes a diff between two checkpoints.
790 fn diff_checkpoints(
791 &self,
792 base_checkpoint: GitRepositoryCheckpoint,
793 target_checkpoint: GitRepositoryCheckpoint,
794 ) -> BoxFuture<'_, Result<String>>;
795
796 fn default_branch(
797 &self,
798 include_remote_name: bool,
799 ) -> BoxFuture<'_, Result<Option<SharedString>>>;
800
801 /// Runs `git rev-list --parents` to get the commit graph structure.
802 /// Returns commit SHAs and their parent SHAs for building the graph visualization.
803 fn initial_graph_data(
804 &self,
805 log_source: LogSource,
806 log_order: LogOrder,
807 request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
808 ) -> BoxFuture<'_, Result<()>>;
809
810 fn commit_data_reader(&self) -> Result<CommitDataReader>;
811}
812
813pub enum DiffType {
814 HeadToIndex,
815 HeadToWorktree,
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
819pub enum PushOptions {
820 SetUpstream,
821 Force,
822}
823
824impl std::fmt::Debug for dyn GitRepository {
825 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
826 f.debug_struct("dyn GitRepository<...>").finish()
827 }
828}
829
830pub struct RealGitRepository {
831 pub repository: Arc<Mutex<git2::Repository>>,
832 pub system_git_binary_path: Option<PathBuf>,
833 pub any_git_binary_path: PathBuf,
834 any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
835 executor: BackgroundExecutor,
836}
837
838impl RealGitRepository {
839 pub fn new(
840 dotgit_path: &Path,
841 bundled_git_binary_path: Option<PathBuf>,
842 system_git_binary_path: Option<PathBuf>,
843 executor: BackgroundExecutor,
844 ) -> Option<Self> {
845 let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?;
846 let workdir_root = dotgit_path.parent()?;
847 let repository = git2::Repository::open(workdir_root).log_err()?;
848 Some(Self {
849 repository: Arc::new(Mutex::new(repository)),
850 system_git_binary_path,
851 any_git_binary_path,
852 executor,
853 any_git_binary_help_output: Arc::new(Mutex::new(None)),
854 })
855 }
856
857 fn working_directory(&self) -> Result<PathBuf> {
858 self.repository
859 .lock()
860 .workdir()
861 .context("failed to read git work directory")
862 .map(Path::to_path_buf)
863 }
864
865 async fn any_git_binary_help_output(&self) -> SharedString {
866 if let Some(output) = self.any_git_binary_help_output.lock().clone() {
867 return output;
868 }
869 let git_binary_path = self.any_git_binary_path.clone();
870 let executor = self.executor.clone();
871 let working_directory = self.working_directory();
872 let output: SharedString = self
873 .executor
874 .spawn(async move {
875 GitBinary::new(git_binary_path, working_directory?, executor)
876 .run(["help", "-a"])
877 .await
878 })
879 .await
880 .unwrap_or_default()
881 .into();
882 *self.any_git_binary_help_output.lock() = Some(output.clone());
883 output
884 }
885}
886
887#[derive(Clone, Debug)]
888pub struct GitRepositoryCheckpoint {
889 pub commit_sha: Oid,
890}
891
892#[derive(Debug)]
893pub struct GitCommitter {
894 pub name: Option<String>,
895 pub email: Option<String>,
896}
897
898pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
899 if cfg!(any(feature = "test-support", test)) {
900 return GitCommitter {
901 name: None,
902 email: None,
903 };
904 }
905
906 let git_binary_path =
907 if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
908 cx.update(|cx| {
909 cx.path_for_auxiliary_executable("git")
910 .context("could not find git binary path")
911 .log_err()
912 })
913 } else {
914 None
915 };
916
917 let git = GitBinary::new(
918 git_binary_path.unwrap_or(PathBuf::from("git")),
919 paths::home_dir().clone(),
920 cx.background_executor().clone(),
921 );
922
923 cx.background_spawn(async move {
924 let name = git.run(["config", "--global", "user.name"]).await.log_err();
925 let email = git
926 .run(["config", "--global", "user.email"])
927 .await
928 .log_err();
929 GitCommitter { name, email }
930 })
931 .await
932}
933
934impl GitRepository for RealGitRepository {
935 fn reload_index(&self) {
936 if let Ok(mut index) = self.repository.lock().index() {
937 _ = index.read(false);
938 }
939 }
940
941 fn path(&self) -> PathBuf {
942 let repo = self.repository.lock();
943 repo.path().into()
944 }
945
946 fn main_repository_path(&self) -> PathBuf {
947 let repo = self.repository.lock();
948 repo.commondir().into()
949 }
950
951 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
952 let git_binary_path = self.any_git_binary_path.clone();
953 let working_directory = self.working_directory();
954 self.executor
955 .spawn(async move {
956 let working_directory = working_directory?;
957 let output = new_smol_command(git_binary_path)
958 .current_dir(&working_directory)
959 .args([
960 "--no-optional-locks",
961 "show",
962 "--no-patch",
963 "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
964 &commit,
965 ])
966 .output()
967 .await?;
968 let output = std::str::from_utf8(&output.stdout)?;
969 let fields = output.split('\0').collect::<Vec<_>>();
970 if fields.len() != 6 {
971 bail!("unexpected git-show output for {commit:?}: {output:?}")
972 }
973 let sha = fields[0].to_string().into();
974 let message = fields[1].to_string().into();
975 let commit_timestamp = fields[2].parse()?;
976 let author_email = fields[3].to_string().into();
977 let author_name = fields[4].to_string().into();
978 Ok(CommitDetails {
979 sha,
980 message,
981 commit_timestamp,
982 author_email,
983 author_name,
984 })
985 })
986 .boxed()
987 }
988
989 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
990 let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
991 else {
992 return future::ready(Err(anyhow!("no working directory"))).boxed();
993 };
994 let git_binary_path = self.any_git_binary_path.clone();
995 cx.background_spawn(async move {
996 let show_output = util::command::new_smol_command(&git_binary_path)
997 .current_dir(&working_directory)
998 .args([
999 "--no-optional-locks",
1000 "show",
1001 "--format=",
1002 "-z",
1003 "--no-renames",
1004 "--name-status",
1005 "--first-parent",
1006 ])
1007 .arg(&commit)
1008 .stdin(Stdio::null())
1009 .stdout(Stdio::piped())
1010 .stderr(Stdio::piped())
1011 .output()
1012 .await
1013 .context("starting git show process")?;
1014
1015 let show_stdout = String::from_utf8_lossy(&show_output.stdout);
1016 let changes = parse_git_diff_name_status(&show_stdout);
1017 let parent_sha = format!("{}^", commit);
1018
1019 let mut cat_file_process = util::command::new_smol_command(&git_binary_path)
1020 .current_dir(&working_directory)
1021 .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
1022 .stdin(Stdio::piped())
1023 .stdout(Stdio::piped())
1024 .stderr(Stdio::piped())
1025 .spawn()
1026 .context("starting git cat-file process")?;
1027
1028 let mut files = Vec::<CommitFile>::new();
1029 let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
1030 let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
1031 let mut info_line = String::new();
1032 let mut newline = [b'\0'];
1033 for (path, status_code) in changes {
1034 // git-show outputs `/`-delimited paths even on Windows.
1035 let Some(rel_path) = RelPath::unix(path).log_err() else {
1036 continue;
1037 };
1038
1039 match status_code {
1040 StatusCode::Modified => {
1041 stdin.write_all(commit.as_bytes()).await?;
1042 stdin.write_all(b":").await?;
1043 stdin.write_all(path.as_bytes()).await?;
1044 stdin.write_all(b"\n").await?;
1045 stdin.write_all(parent_sha.as_bytes()).await?;
1046 stdin.write_all(b":").await?;
1047 stdin.write_all(path.as_bytes()).await?;
1048 stdin.write_all(b"\n").await?;
1049 }
1050 StatusCode::Added => {
1051 stdin.write_all(commit.as_bytes()).await?;
1052 stdin.write_all(b":").await?;
1053 stdin.write_all(path.as_bytes()).await?;
1054 stdin.write_all(b"\n").await?;
1055 }
1056 StatusCode::Deleted => {
1057 stdin.write_all(parent_sha.as_bytes()).await?;
1058 stdin.write_all(b":").await?;
1059 stdin.write_all(path.as_bytes()).await?;
1060 stdin.write_all(b"\n").await?;
1061 }
1062 _ => continue,
1063 }
1064 stdin.flush().await?;
1065
1066 info_line.clear();
1067 stdout.read_line(&mut info_line).await?;
1068
1069 let len = info_line.trim_end().parse().with_context(|| {
1070 format!("invalid object size output from cat-file {info_line}")
1071 })?;
1072 let mut text_bytes = vec![0; len];
1073 stdout.read_exact(&mut text_bytes).await?;
1074 stdout.read_exact(&mut newline).await?;
1075
1076 let mut old_text = None;
1077 let mut new_text = None;
1078 let mut is_binary = is_binary_content(&text_bytes);
1079 let text = if is_binary {
1080 String::new()
1081 } else {
1082 String::from_utf8_lossy(&text_bytes).to_string()
1083 };
1084
1085 match status_code {
1086 StatusCode::Modified => {
1087 info_line.clear();
1088 stdout.read_line(&mut info_line).await?;
1089 let len = info_line.trim_end().parse().with_context(|| {
1090 format!("invalid object size output from cat-file {}", info_line)
1091 })?;
1092 let mut parent_bytes = vec![0; len];
1093 stdout.read_exact(&mut parent_bytes).await?;
1094 stdout.read_exact(&mut newline).await?;
1095 is_binary = is_binary || is_binary_content(&parent_bytes);
1096 if is_binary {
1097 old_text = Some(String::new());
1098 new_text = Some(String::new());
1099 } else {
1100 old_text = Some(String::from_utf8_lossy(&parent_bytes).to_string());
1101 new_text = Some(text);
1102 }
1103 }
1104 StatusCode::Added => new_text = Some(text),
1105 StatusCode::Deleted => old_text = Some(text),
1106 _ => continue,
1107 }
1108
1109 files.push(CommitFile {
1110 path: RepoPath(Arc::from(rel_path)),
1111 old_text,
1112 new_text,
1113 is_binary,
1114 })
1115 }
1116
1117 Ok(CommitDiff { files })
1118 })
1119 .boxed()
1120 }
1121
1122 fn reset(
1123 &self,
1124 commit: String,
1125 mode: ResetMode,
1126 env: Arc<HashMap<String, String>>,
1127 ) -> BoxFuture<'_, Result<()>> {
1128 async move {
1129 let working_directory = self.working_directory();
1130
1131 let mode_flag = match mode {
1132 ResetMode::Mixed => "--mixed",
1133 ResetMode::Soft => "--soft",
1134 };
1135
1136 let output = new_smol_command(&self.any_git_binary_path)
1137 .envs(env.iter())
1138 .current_dir(&working_directory?)
1139 .args(["reset", mode_flag, &commit])
1140 .output()
1141 .await?;
1142 anyhow::ensure!(
1143 output.status.success(),
1144 "Failed to reset:\n{}",
1145 String::from_utf8_lossy(&output.stderr),
1146 );
1147 Ok(())
1148 }
1149 .boxed()
1150 }
1151
1152 fn checkout_files(
1153 &self,
1154 commit: String,
1155 paths: Vec<RepoPath>,
1156 env: Arc<HashMap<String, String>>,
1157 ) -> BoxFuture<'_, Result<()>> {
1158 let working_directory = self.working_directory();
1159 let git_binary_path = self.any_git_binary_path.clone();
1160 async move {
1161 if paths.is_empty() {
1162 return Ok(());
1163 }
1164
1165 let working_directory = working_directory?;
1166 let mut child = new_smol_command(&git_binary_path)
1167 .current_dir(&working_directory)
1168 .envs(env.iter())
1169 .args([
1170 "checkout",
1171 &commit,
1172 "--pathspec-from-file=-",
1173 "--pathspec-file-nul",
1174 ])
1175 .stdin(Stdio::piped())
1176 .stdout(Stdio::null())
1177 .stderr(Stdio::piped())
1178 .spawn()
1179 .context("failed to spawn git checkout")?;
1180
1181 let mut stdin = child.stdin.take().context("failed to get stdin")?;
1182 for path in &paths {
1183 stdin.write_all(path.as_unix_str().as_bytes()).await?;
1184 stdin.write_all(b"\0").await?;
1185 }
1186 drop(stdin);
1187
1188 let output = child.output().await?;
1189 if output.status.success() {
1190 return Ok(());
1191 }
1192
1193 let stderr = String::from_utf8_lossy(&output.stderr);
1194 if !stderr.contains("pathspec-from-file") {
1195 anyhow::bail!("Failed to checkout files:\n{}", stderr);
1196 }
1197
1198 // Fallback for older git versions: pass paths as command-line arguments
1199 let output = new_smol_command(&git_binary_path)
1200 .current_dir(&working_directory)
1201 .envs(env.iter())
1202 .args(["checkout", &commit, "--"])
1203 .args(paths.iter().map(|path| path.as_unix_str()))
1204 .output()
1205 .await?;
1206 anyhow::ensure!(
1207 output.status.success(),
1208 "Failed to checkout files:\n{}",
1209 String::from_utf8_lossy(&output.stderr),
1210 );
1211 Ok(())
1212 }
1213 .boxed()
1214 }
1215
1216 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1217 // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
1218 const GIT_MODE_SYMLINK: u32 = 0o120000;
1219
1220 let repo = self.repository.clone();
1221 self.executor
1222 .spawn(async move {
1223 fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
1224 // This check is required because index.get_path() unwraps internally :(
1225 let mut index = repo.index()?;
1226 index.read(false)?;
1227
1228 const STAGE_NORMAL: i32 = 0;
1229 let path = path.as_std_path();
1230 // `RepoPath` contains a `RelPath` which normalizes `.` into an empty path
1231 // `get_path` unwraps on empty paths though, so undo that normalization here
1232 let path = if path.components().next().is_none() {
1233 ".".as_ref()
1234 } else {
1235 path
1236 };
1237 let oid = match index.get_path(path, STAGE_NORMAL) {
1238 Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
1239 _ => return Ok(None),
1240 };
1241
1242 let content = repo.find_blob(oid)?.content().to_owned();
1243 Ok(String::from_utf8(content).ok())
1244 }
1245
1246 match logic(&repo.lock(), &path) {
1247 Ok(value) => return value,
1248 Err(err) => log::error!("Error loading index text: {:?}", err),
1249 }
1250 None
1251 })
1252 .boxed()
1253 }
1254
1255 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1256 let repo = self.repository.clone();
1257 self.executor
1258 .spawn(async move {
1259 let repo = repo.lock();
1260 let head = repo.head().ok()?.peel_to_tree().log_err()?;
1261 let entry = head.get_path(path.as_std_path()).ok()?;
1262 if entry.filemode() == i32::from(git2::FileMode::Link) {
1263 return None;
1264 }
1265 let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
1266 String::from_utf8(content).ok()
1267 })
1268 .boxed()
1269 }
1270
1271 fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
1272 let repo = self.repository.clone();
1273 self.executor
1274 .spawn(async move {
1275 let repo = repo.lock();
1276 let content = repo.find_blob(oid.0)?.content().to_owned();
1277 Ok(String::from_utf8(content)?)
1278 })
1279 .boxed()
1280 }
1281
1282 fn set_index_text(
1283 &self,
1284 path: RepoPath,
1285 content: Option<String>,
1286 env: Arc<HashMap<String, String>>,
1287 is_executable: bool,
1288 ) -> BoxFuture<'_, anyhow::Result<()>> {
1289 let working_directory = self.working_directory();
1290 let git_binary_path = self.any_git_binary_path.clone();
1291 self.executor
1292 .spawn(async move {
1293 let working_directory = working_directory?;
1294 let mode = if is_executable { "100755" } else { "100644" };
1295
1296 if let Some(content) = content {
1297 let mut child = new_smol_command(&git_binary_path)
1298 .current_dir(&working_directory)
1299 .envs(env.iter())
1300 .args(["hash-object", "-w", "--stdin"])
1301 .stdin(Stdio::piped())
1302 .stdout(Stdio::piped())
1303 .spawn()?;
1304 let mut stdin = child.stdin.take().unwrap();
1305 stdin.write_all(content.as_bytes()).await?;
1306 stdin.flush().await?;
1307 drop(stdin);
1308 let output = child.output().await?.stdout;
1309 let sha = str::from_utf8(&output)?.trim();
1310
1311 log::debug!("indexing SHA: {sha}, path {path:?}");
1312
1313 let output = new_smol_command(&git_binary_path)
1314 .current_dir(&working_directory)
1315 .envs(env.iter())
1316 .args(["update-index", "--add", "--cacheinfo", mode, sha])
1317 .arg(path.as_unix_str())
1318 .output()
1319 .await?;
1320
1321 anyhow::ensure!(
1322 output.status.success(),
1323 "Failed to stage:\n{}",
1324 String::from_utf8_lossy(&output.stderr)
1325 );
1326 } else {
1327 log::debug!("removing path {path:?} from the index");
1328 let output = new_smol_command(&git_binary_path)
1329 .current_dir(&working_directory)
1330 .envs(env.iter())
1331 .args(["update-index", "--force-remove"])
1332 .arg(path.as_unix_str())
1333 .output()
1334 .await?;
1335 anyhow::ensure!(
1336 output.status.success(),
1337 "Failed to unstage:\n{}",
1338 String::from_utf8_lossy(&output.stderr)
1339 );
1340 }
1341
1342 Ok(())
1343 })
1344 .boxed()
1345 }
1346
1347 fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
1348 let repo = self.repository.clone();
1349 let name = name.to_owned();
1350 self.executor
1351 .spawn(async move {
1352 let repo = repo.lock();
1353 let remote = repo.find_remote(&name).ok()?;
1354 remote.url().map(|url| url.to_string())
1355 })
1356 .boxed()
1357 }
1358
1359 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1360 let working_directory = self.working_directory();
1361 let git_binary_path = self.any_git_binary_path.clone();
1362 self.executor
1363 .spawn(async move {
1364 let working_directory = working_directory?;
1365 let mut process = new_smol_command(&git_binary_path)
1366 .current_dir(&working_directory)
1367 .args([
1368 "--no-optional-locks",
1369 "cat-file",
1370 "--batch-check=%(objectname)",
1371 ])
1372 .stdin(Stdio::piped())
1373 .stdout(Stdio::piped())
1374 .stderr(Stdio::piped())
1375 .spawn()?;
1376
1377 let stdin = process
1378 .stdin
1379 .take()
1380 .context("no stdin for git cat-file subprocess")?;
1381 let mut stdin = BufWriter::new(stdin);
1382 for rev in &revs {
1383 stdin.write_all(rev.as_bytes()).await?;
1384 stdin.write_all(b"\n").await?;
1385 }
1386 stdin.flush().await?;
1387 drop(stdin);
1388
1389 let output = process.output().await?;
1390 let output = std::str::from_utf8(&output.stdout)?;
1391 let shas = output
1392 .lines()
1393 .map(|line| {
1394 if line.ends_with("missing") {
1395 None
1396 } else {
1397 Some(line.to_string())
1398 }
1399 })
1400 .collect::<Vec<_>>();
1401
1402 if shas.len() != revs.len() {
1403 // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
1404 bail!("unexpected number of shas")
1405 }
1406
1407 Ok(shas)
1408 })
1409 .boxed()
1410 }
1411
1412 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
1413 let path = self.path().join("MERGE_MSG");
1414 self.executor
1415 .spawn(async move { std::fs::read_to_string(&path).ok() })
1416 .boxed()
1417 }
1418
1419 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
1420 let git_binary_path = self.any_git_binary_path.clone();
1421 let working_directory = match self.working_directory() {
1422 Ok(working_directory) => working_directory,
1423 Err(e) => return Task::ready(Err(e)),
1424 };
1425 let args = git_status_args(path_prefixes);
1426 log::debug!("Checking for git status in {path_prefixes:?}");
1427 self.executor.spawn(async move {
1428 let output = new_smol_command(&git_binary_path)
1429 .current_dir(working_directory)
1430 .args(args)
1431 .output()
1432 .await?;
1433 if output.status.success() {
1434 let stdout = String::from_utf8_lossy(&output.stdout);
1435 stdout.parse()
1436 } else {
1437 let stderr = String::from_utf8_lossy(&output.stderr);
1438 anyhow::bail!("git status failed: {stderr}");
1439 }
1440 })
1441 }
1442
1443 fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
1444 let git_binary_path = self.any_git_binary_path.clone();
1445 let working_directory = match self.working_directory() {
1446 Ok(working_directory) => working_directory,
1447 Err(e) => return Task::ready(Err(e)).boxed(),
1448 };
1449
1450 let mut args = vec![
1451 OsString::from("--no-optional-locks"),
1452 OsString::from("diff-tree"),
1453 OsString::from("-r"),
1454 OsString::from("-z"),
1455 OsString::from("--no-renames"),
1456 ];
1457 match request {
1458 DiffTreeType::MergeBase { base, head } => {
1459 args.push("--merge-base".into());
1460 args.push(OsString::from(base.as_str()));
1461 args.push(OsString::from(head.as_str()));
1462 }
1463 DiffTreeType::Since { base, head } => {
1464 args.push(OsString::from(base.as_str()));
1465 args.push(OsString::from(head.as_str()));
1466 }
1467 }
1468
1469 self.executor
1470 .spawn(async move {
1471 let output = new_smol_command(&git_binary_path)
1472 .current_dir(working_directory)
1473 .args(args)
1474 .output()
1475 .await?;
1476 if output.status.success() {
1477 let stdout = String::from_utf8_lossy(&output.stdout);
1478 stdout.parse()
1479 } else {
1480 let stderr = String::from_utf8_lossy(&output.stderr);
1481 anyhow::bail!("git status failed: {stderr}");
1482 }
1483 })
1484 .boxed()
1485 }
1486
1487 fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
1488 let git_binary_path = self.any_git_binary_path.clone();
1489 let working_directory = self.working_directory();
1490 self.executor
1491 .spawn(async move {
1492 let output = new_smol_command(&git_binary_path)
1493 .current_dir(working_directory?)
1494 .args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1495 .output()
1496 .await?;
1497 if output.status.success() {
1498 let stdout = String::from_utf8_lossy(&output.stdout);
1499 stdout.parse()
1500 } else {
1501 let stderr = String::from_utf8_lossy(&output.stderr);
1502 anyhow::bail!("git status failed: {stderr}");
1503 }
1504 })
1505 .boxed()
1506 }
1507
1508 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
1509 let working_directory = self.working_directory();
1510 let git_binary_path = self.any_git_binary_path.clone();
1511 self.executor
1512 .spawn(async move {
1513 let fields = [
1514 "%(HEAD)",
1515 "%(objectname)",
1516 "%(parent)",
1517 "%(refname)",
1518 "%(upstream)",
1519 "%(upstream:track)",
1520 "%(committerdate:unix)",
1521 "%(authorname)",
1522 "%(contents:subject)",
1523 ]
1524 .join("%00");
1525 let args = vec![
1526 "for-each-ref",
1527 "refs/heads/**/*",
1528 "refs/remotes/**/*",
1529 "--format",
1530 &fields,
1531 ];
1532 let working_directory = working_directory?;
1533 let output = new_smol_command(&git_binary_path)
1534 .current_dir(&working_directory)
1535 .args(args)
1536 .output()
1537 .await?;
1538
1539 anyhow::ensure!(
1540 output.status.success(),
1541 "Failed to git git branches:\n{}",
1542 String::from_utf8_lossy(&output.stderr)
1543 );
1544
1545 let input = String::from_utf8_lossy(&output.stdout);
1546
1547 let mut branches = parse_branch_input(&input)?;
1548 if branches.is_empty() {
1549 let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1550
1551 let output = new_smol_command(&git_binary_path)
1552 .current_dir(&working_directory)
1553 .args(args)
1554 .output()
1555 .await?;
1556
1557 // git symbolic-ref returns a non-0 exit code if HEAD points
1558 // to something other than a branch
1559 if output.status.success() {
1560 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1561
1562 branches.push(Branch {
1563 ref_name: name.into(),
1564 is_head: true,
1565 upstream: None,
1566 most_recent_commit: None,
1567 });
1568 }
1569 }
1570
1571 Ok(branches)
1572 })
1573 .boxed()
1574 }
1575
1576 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
1577 let git_binary_path = self.any_git_binary_path.clone();
1578 let working_directory = self.working_directory();
1579 self.executor
1580 .spawn(async move {
1581 let output = new_smol_command(&git_binary_path)
1582 .current_dir(working_directory?)
1583 .args(&["--no-optional-locks", "worktree", "list", "--porcelain"])
1584 .output()
1585 .await?;
1586 if output.status.success() {
1587 let stdout = String::from_utf8_lossy(&output.stdout);
1588 Ok(parse_worktrees_from_str(&stdout))
1589 } else {
1590 let stderr = String::from_utf8_lossy(&output.stderr);
1591 anyhow::bail!("git worktree list failed: {stderr}");
1592 }
1593 })
1594 .boxed()
1595 }
1596
1597 fn create_worktree(
1598 &self,
1599 name: String,
1600 directory: PathBuf,
1601 from_commit: Option<String>,
1602 ) -> BoxFuture<'_, Result<()>> {
1603 let git_binary_path = self.any_git_binary_path.clone();
1604 let working_directory = self.working_directory();
1605 let final_path = directory.join(&name);
1606 let mut args = vec![
1607 OsString::from("--no-optional-locks"),
1608 OsString::from("worktree"),
1609 OsString::from("add"),
1610 OsString::from(final_path.as_os_str()),
1611 ];
1612 if let Some(from_commit) = from_commit {
1613 args.extend([
1614 OsString::from("-b"),
1615 OsString::from(name.as_str()),
1616 OsString::from(from_commit),
1617 ]);
1618 }
1619 self.executor
1620 .spawn(async move {
1621 let output = new_smol_command(&git_binary_path)
1622 .current_dir(working_directory?)
1623 .args(args)
1624 .output()
1625 .await?;
1626 if output.status.success() {
1627 Ok(())
1628 } else {
1629 let stderr = String::from_utf8_lossy(&output.stderr);
1630 anyhow::bail!("git worktree list failed: {stderr}");
1631 }
1632 })
1633 .boxed()
1634 }
1635
1636 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1637 let repo = self.repository.clone();
1638 let working_directory = self.working_directory();
1639 let git_binary_path = self.any_git_binary_path.clone();
1640 let executor = self.executor.clone();
1641 let branch = self.executor.spawn(async move {
1642 let repo = repo.lock();
1643 let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1644 branch
1645 } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1646 let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1647
1648 let revision = revision.get();
1649 let branch_commit = revision.peel_to_commit()?;
1650 let mut branch = match repo.branch(&branch_name, &branch_commit, false) {
1651 Ok(branch) => branch,
1652 Err(err) if err.code() == ErrorCode::Exists => {
1653 repo.find_branch(&branch_name, BranchType::Local)?
1654 }
1655 Err(err) => {
1656 return Err(err.into());
1657 }
1658 };
1659
1660 branch.set_upstream(Some(&name))?;
1661 branch
1662 } else {
1663 anyhow::bail!("Branch '{}' not found", name);
1664 };
1665
1666 Ok(branch
1667 .name()?
1668 .context("cannot checkout anonymous branch")?
1669 .to_string())
1670 });
1671
1672 self.executor
1673 .spawn(async move {
1674 let branch = branch.await?;
1675 GitBinary::new(git_binary_path, working_directory?, executor)
1676 .run(&["checkout", &branch])
1677 .await?;
1678 anyhow::Ok(())
1679 })
1680 .boxed()
1681 }
1682
1683 fn create_branch(
1684 &self,
1685 name: String,
1686 base_branch: Option<String>,
1687 ) -> BoxFuture<'_, Result<()>> {
1688 let git_binary_path = self.any_git_binary_path.clone();
1689 let working_directory = self.working_directory();
1690 let executor = self.executor.clone();
1691
1692 self.executor
1693 .spawn(async move {
1694 let mut args = vec!["switch", "-c", &name];
1695 let base_branch_str;
1696 if let Some(ref base) = base_branch {
1697 base_branch_str = base.clone();
1698 args.push(&base_branch_str);
1699 }
1700
1701 GitBinary::new(git_binary_path, working_directory?, executor)
1702 .run(&args)
1703 .await?;
1704 anyhow::Ok(())
1705 })
1706 .boxed()
1707 }
1708
1709 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
1710 let git_binary_path = self.any_git_binary_path.clone();
1711 let working_directory = self.working_directory();
1712 let executor = self.executor.clone();
1713
1714 self.executor
1715 .spawn(async move {
1716 GitBinary::new(git_binary_path, working_directory?, executor)
1717 .run(&["branch", "-m", &branch, &new_name])
1718 .await?;
1719 anyhow::Ok(())
1720 })
1721 .boxed()
1722 }
1723
1724 fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1725 let git_binary_path = self.any_git_binary_path.clone();
1726 let working_directory = self.working_directory();
1727 let executor = self.executor.clone();
1728
1729 self.executor
1730 .spawn(async move {
1731 GitBinary::new(git_binary_path, working_directory?, executor)
1732 .run(&["branch", "-d", &name])
1733 .await?;
1734 anyhow::Ok(())
1735 })
1736 .boxed()
1737 }
1738
1739 fn blame(
1740 &self,
1741 path: RepoPath,
1742 content: Rope,
1743 line_ending: LineEnding,
1744 ) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1745 let working_directory = self.working_directory();
1746 let git_binary_path = self.any_git_binary_path.clone();
1747 let executor = self.executor.clone();
1748
1749 executor
1750 .spawn(async move {
1751 crate::blame::Blame::for_path(
1752 &git_binary_path,
1753 &working_directory?,
1754 &path,
1755 &content,
1756 line_ending,
1757 )
1758 .await
1759 })
1760 .boxed()
1761 }
1762
1763 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
1764 self.file_history_paginated(path, 0, None)
1765 }
1766
1767 fn file_history_paginated(
1768 &self,
1769 path: RepoPath,
1770 skip: usize,
1771 limit: Option<usize>,
1772 ) -> BoxFuture<'_, Result<FileHistory>> {
1773 let working_directory = self.working_directory();
1774 let git_binary_path = self.any_git_binary_path.clone();
1775 self.executor
1776 .spawn(async move {
1777 let working_directory = working_directory?;
1778 // Use a unique delimiter with a hardcoded UUID to separate commits
1779 // This essentially eliminates any chance of encountering the delimiter in actual commit data
1780 let commit_delimiter =
1781 concat!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
1782
1783 let format_string = format!(
1784 "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}",
1785 commit_delimiter
1786 );
1787
1788 let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string];
1789
1790 let skip_str;
1791 let limit_str;
1792 if skip > 0 {
1793 skip_str = skip.to_string();
1794 args.push("--skip");
1795 args.push(&skip_str);
1796 }
1797 if let Some(n) = limit {
1798 limit_str = n.to_string();
1799 args.push("-n");
1800 args.push(&limit_str);
1801 }
1802
1803 args.push("--");
1804
1805 let output = new_smol_command(&git_binary_path)
1806 .current_dir(&working_directory)
1807 .args(&args)
1808 .arg(path.as_unix_str())
1809 .output()
1810 .await?;
1811
1812 if !output.status.success() {
1813 let stderr = String::from_utf8_lossy(&output.stderr);
1814 bail!("git log failed: {stderr}");
1815 }
1816
1817 let stdout = std::str::from_utf8(&output.stdout)?;
1818 let mut entries = Vec::new();
1819
1820 for commit_block in stdout.split(commit_delimiter) {
1821 let commit_block = commit_block.trim();
1822 if commit_block.is_empty() {
1823 continue;
1824 }
1825
1826 let fields: Vec<&str> = commit_block.split('\0').collect();
1827 if fields.len() >= 6 {
1828 let sha = fields[0].trim().to_string().into();
1829 let subject = fields[1].trim().to_string().into();
1830 let message = fields[2].trim().to_string().into();
1831 let commit_timestamp = fields[3].trim().parse().unwrap_or(0);
1832 let author_name = fields[4].trim().to_string().into();
1833 let author_email = fields[5].trim().to_string().into();
1834
1835 entries.push(FileHistoryEntry {
1836 sha,
1837 subject,
1838 message,
1839 commit_timestamp,
1840 author_name,
1841 author_email,
1842 });
1843 }
1844 }
1845
1846 Ok(FileHistory { entries, path })
1847 })
1848 .boxed()
1849 }
1850
1851 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1852 let working_directory = self.working_directory();
1853 let git_binary_path = self.any_git_binary_path.clone();
1854 self.executor
1855 .spawn(async move {
1856 let args = match diff {
1857 DiffType::HeadToIndex => Some("--staged"),
1858 DiffType::HeadToWorktree => None,
1859 };
1860
1861 let output = new_smol_command(&git_binary_path)
1862 .current_dir(&working_directory?)
1863 .args(["diff"])
1864 .args(args)
1865 .output()
1866 .await?;
1867
1868 anyhow::ensure!(
1869 output.status.success(),
1870 "Failed to run git diff:\n{}",
1871 String::from_utf8_lossy(&output.stderr)
1872 );
1873 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1874 })
1875 .boxed()
1876 }
1877
1878 fn stage_paths(
1879 &self,
1880 paths: Vec<RepoPath>,
1881 env: Arc<HashMap<String, String>>,
1882 ) -> BoxFuture<'_, Result<()>> {
1883 let working_directory = self.working_directory();
1884 let git_binary_path = self.any_git_binary_path.clone();
1885 self.executor
1886 .spawn(async move {
1887 if paths.is_empty() {
1888 return Ok(());
1889 }
1890
1891 let mut child = new_smol_command(&git_binary_path)
1892 .current_dir(&working_directory?)
1893 .envs(env.iter())
1894 .args(["update-index", "--add", "--remove", "-z", "--stdin"])
1895 .stdin(Stdio::piped())
1896 .stdout(Stdio::null())
1897 .stderr(Stdio::piped())
1898 .spawn()
1899 .context("failed to spawn git update-index")?;
1900
1901 let mut stdin = child.stdin.take().context("failed to get stdin")?;
1902 for path in &paths {
1903 stdin.write_all(path.as_unix_str().as_bytes()).await?;
1904 stdin.write_all(b"\0").await?;
1905 }
1906 drop(stdin);
1907
1908 let output = child.output().await?;
1909 anyhow::ensure!(
1910 output.status.success(),
1911 "Failed to stage paths:\n{}",
1912 String::from_utf8_lossy(&output.stderr),
1913 );
1914 Ok(())
1915 })
1916 .boxed()
1917 }
1918
1919 fn unstage_paths(
1920 &self,
1921 paths: Vec<RepoPath>,
1922 env: Arc<HashMap<String, String>>,
1923 ) -> BoxFuture<'_, Result<()>> {
1924 let working_directory = self.working_directory();
1925 let git_binary_path = self.any_git_binary_path.clone();
1926
1927 self.executor
1928 .spawn(async move {
1929 if paths.is_empty() {
1930 return Ok(());
1931 }
1932
1933 let working_directory = working_directory?;
1934 let mut child = new_smol_command(&git_binary_path)
1935 .current_dir(&working_directory)
1936 .envs(env.iter())
1937 .args([
1938 "reset",
1939 "--quiet",
1940 "--pathspec-from-file=-",
1941 "--pathspec-file-nul",
1942 ])
1943 .stdin(Stdio::piped())
1944 .stdout(Stdio::null())
1945 .stderr(Stdio::piped())
1946 .spawn()
1947 .context("failed to spawn git reset")?;
1948
1949 let mut stdin = child.stdin.take().context("failed to get stdin")?;
1950 for path in &paths {
1951 stdin.write_all(path.as_unix_str().as_bytes()).await?;
1952 stdin.write_all(b"\0").await?;
1953 }
1954 drop(stdin);
1955
1956 let output = child.output().await?;
1957 if output.status.success() {
1958 return Ok(());
1959 }
1960
1961 let stderr = String::from_utf8_lossy(&output.stderr);
1962 if !stderr.contains("pathspec-from-file") {
1963 anyhow::bail!("Failed to unstage:\n{}", stderr);
1964 }
1965
1966 // Fallback for older git versions: pass paths as command-line arguments
1967 let output = new_smol_command(&git_binary_path)
1968 .current_dir(&working_directory)
1969 .envs(env.iter())
1970 .args(["reset", "--quiet", "--"])
1971 .args(paths.iter().map(|p| p.as_std_path()))
1972 .output()
1973 .await?;
1974
1975 anyhow::ensure!(
1976 output.status.success(),
1977 "Failed to unstage:\n{}",
1978 String::from_utf8_lossy(&output.stderr),
1979 );
1980 Ok(())
1981 })
1982 .boxed()
1983 }
1984
1985 fn stash_paths(
1986 &self,
1987 paths: Vec<RepoPath>,
1988 env: Arc<HashMap<String, String>>,
1989 ) -> BoxFuture<'_, Result<()>> {
1990 let working_directory = self.working_directory();
1991 let git_binary_path = self.any_git_binary_path.clone();
1992 self.executor
1993 .spawn(async move {
1994 let mut cmd = new_smol_command(&git_binary_path);
1995 cmd.current_dir(&working_directory?)
1996 .envs(env.iter())
1997 .args(["stash", "push", "--quiet"])
1998 .arg("--include-untracked");
1999
2000 cmd.args(paths.iter().map(|p| p.as_unix_str()));
2001
2002 let output = cmd.output().await?;
2003
2004 anyhow::ensure!(
2005 output.status.success(),
2006 "Failed to stash:\n{}",
2007 String::from_utf8_lossy(&output.stderr)
2008 );
2009 Ok(())
2010 })
2011 .boxed()
2012 }
2013
2014 fn stash_pop(
2015 &self,
2016 index: Option<usize>,
2017 env: Arc<HashMap<String, String>>,
2018 ) -> BoxFuture<'_, Result<()>> {
2019 let working_directory = self.working_directory();
2020 let git_binary_path = self.any_git_binary_path.clone();
2021 self.executor
2022 .spawn(async move {
2023 let mut cmd = new_smol_command(git_binary_path);
2024 let mut args = vec!["stash".to_string(), "pop".to_string()];
2025 if let Some(index) = index {
2026 args.push(format!("stash@{{{}}}", index));
2027 }
2028 cmd.current_dir(&working_directory?)
2029 .envs(env.iter())
2030 .args(args);
2031
2032 let output = cmd.output().await?;
2033
2034 anyhow::ensure!(
2035 output.status.success(),
2036 "Failed to stash pop:\n{}",
2037 String::from_utf8_lossy(&output.stderr)
2038 );
2039 Ok(())
2040 })
2041 .boxed()
2042 }
2043
2044 fn stash_apply(
2045 &self,
2046 index: Option<usize>,
2047 env: Arc<HashMap<String, String>>,
2048 ) -> BoxFuture<'_, Result<()>> {
2049 let working_directory = self.working_directory();
2050 let git_binary_path = self.any_git_binary_path.clone();
2051 self.executor
2052 .spawn(async move {
2053 let mut cmd = new_smol_command(git_binary_path);
2054 let mut args = vec!["stash".to_string(), "apply".to_string()];
2055 if let Some(index) = index {
2056 args.push(format!("stash@{{{}}}", index));
2057 }
2058 cmd.current_dir(&working_directory?)
2059 .envs(env.iter())
2060 .args(args);
2061
2062 let output = cmd.output().await?;
2063
2064 anyhow::ensure!(
2065 output.status.success(),
2066 "Failed to apply stash:\n{}",
2067 String::from_utf8_lossy(&output.stderr)
2068 );
2069 Ok(())
2070 })
2071 .boxed()
2072 }
2073
2074 fn stash_drop(
2075 &self,
2076 index: Option<usize>,
2077 env: Arc<HashMap<String, String>>,
2078 ) -> BoxFuture<'_, Result<()>> {
2079 let working_directory = self.working_directory();
2080 let git_binary_path = self.any_git_binary_path.clone();
2081 self.executor
2082 .spawn(async move {
2083 let mut cmd = new_smol_command(git_binary_path);
2084 let mut args = vec!["stash".to_string(), "drop".to_string()];
2085 if let Some(index) = index {
2086 args.push(format!("stash@{{{}}}", index));
2087 }
2088 cmd.current_dir(&working_directory?)
2089 .envs(env.iter())
2090 .args(args);
2091
2092 let output = cmd.output().await?;
2093
2094 anyhow::ensure!(
2095 output.status.success(),
2096 "Failed to stash drop:\n{}",
2097 String::from_utf8_lossy(&output.stderr)
2098 );
2099 Ok(())
2100 })
2101 .boxed()
2102 }
2103
2104 fn commit(
2105 &self,
2106 message: SharedString,
2107 name_and_email: Option<(SharedString, SharedString)>,
2108 options: CommitOptions,
2109 ask_pass: AskPassDelegate,
2110 env: Arc<HashMap<String, String>>,
2111 ) -> BoxFuture<'_, Result<()>> {
2112 let working_directory = self.working_directory();
2113 let git_binary_path = self.any_git_binary_path.clone();
2114 let executor = self.executor.clone();
2115 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2116 // which we want to block on.
2117 async move {
2118 let mut cmd = new_smol_command(git_binary_path);
2119 cmd.current_dir(&working_directory?)
2120 .envs(env.iter())
2121 .args(["commit", "--quiet", "-m"])
2122 .arg(&message.to_string())
2123 .arg("--cleanup=strip")
2124 .arg("--no-verify")
2125 .stdout(smol::process::Stdio::piped())
2126 .stderr(smol::process::Stdio::piped());
2127
2128 if options.amend {
2129 cmd.arg("--amend");
2130 }
2131
2132 if options.signoff {
2133 cmd.arg("--signoff");
2134 }
2135
2136 if let Some((name, email)) = name_and_email {
2137 cmd.arg("--author").arg(&format!("{name} <{email}>"));
2138 }
2139
2140 run_git_command(env, ask_pass, cmd, executor).await?;
2141
2142 Ok(())
2143 }
2144 .boxed()
2145 }
2146
2147 fn push(
2148 &self,
2149 branch_name: String,
2150 remote_branch_name: String,
2151 remote_name: String,
2152 options: Option<PushOptions>,
2153 ask_pass: AskPassDelegate,
2154 env: Arc<HashMap<String, String>>,
2155 cx: AsyncApp,
2156 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2157 let working_directory = self.working_directory();
2158 let executor = cx.background_executor().clone();
2159 let git_binary_path = self.system_git_binary_path.clone();
2160 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2161 // which we want to block on.
2162 async move {
2163 let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
2164 let working_directory = working_directory?;
2165 let mut command = new_smol_command(git_binary_path);
2166 command
2167 .envs(env.iter())
2168 .current_dir(&working_directory)
2169 .args(["push"])
2170 .args(options.map(|option| match option {
2171 PushOptions::SetUpstream => "--set-upstream",
2172 PushOptions::Force => "--force-with-lease",
2173 }))
2174 .arg(remote_name)
2175 .arg(format!("{}:{}", branch_name, remote_branch_name))
2176 .stdin(smol::process::Stdio::null())
2177 .stdout(smol::process::Stdio::piped())
2178 .stderr(smol::process::Stdio::piped());
2179
2180 run_git_command(env, ask_pass, command, executor).await
2181 }
2182 .boxed()
2183 }
2184
2185 fn pull(
2186 &self,
2187 branch_name: Option<String>,
2188 remote_name: String,
2189 rebase: bool,
2190 ask_pass: AskPassDelegate,
2191 env: Arc<HashMap<String, String>>,
2192 cx: AsyncApp,
2193 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2194 let working_directory = self.working_directory();
2195 let executor = cx.background_executor().clone();
2196 let git_binary_path = self.system_git_binary_path.clone();
2197 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2198 // which we want to block on.
2199 async move {
2200 let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
2201 let mut command = new_smol_command(git_binary_path);
2202 command
2203 .envs(env.iter())
2204 .current_dir(&working_directory?)
2205 .arg("pull");
2206
2207 if rebase {
2208 command.arg("--rebase");
2209 }
2210
2211 command
2212 .arg(remote_name)
2213 .args(branch_name)
2214 .stdout(smol::process::Stdio::piped())
2215 .stderr(smol::process::Stdio::piped());
2216
2217 run_git_command(env, ask_pass, command, executor).await
2218 }
2219 .boxed()
2220 }
2221
2222 fn fetch(
2223 &self,
2224 fetch_options: FetchOptions,
2225 ask_pass: AskPassDelegate,
2226 env: Arc<HashMap<String, String>>,
2227 cx: AsyncApp,
2228 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2229 let working_directory = self.working_directory();
2230 let remote_name = format!("{}", fetch_options);
2231 let git_binary_path = self.system_git_binary_path.clone();
2232 let executor = cx.background_executor().clone();
2233 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2234 // which we want to block on.
2235 async move {
2236 let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
2237 let mut command = new_smol_command(git_binary_path);
2238 command
2239 .envs(env.iter())
2240 .current_dir(&working_directory?)
2241 .args(["fetch", &remote_name])
2242 .stdout(smol::process::Stdio::piped())
2243 .stderr(smol::process::Stdio::piped());
2244
2245 run_git_command(env, ask_pass, command, executor).await
2246 }
2247 .boxed()
2248 }
2249
2250 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2251 let working_directory = self.working_directory();
2252 let git_binary_path = self.any_git_binary_path.clone();
2253 self.executor
2254 .spawn(async move {
2255 let working_directory = working_directory?;
2256 let output = new_smol_command(&git_binary_path)
2257 .current_dir(&working_directory)
2258 .args(["rev-parse", "--abbrev-ref"])
2259 .arg(format!("{branch}@{{push}}"))
2260 .output()
2261 .await?;
2262 if !output.status.success() {
2263 return Ok(None);
2264 }
2265 let remote_name = String::from_utf8_lossy(&output.stdout)
2266 .split('/')
2267 .next()
2268 .map(|name| Remote {
2269 name: name.trim().to_string().into(),
2270 });
2271
2272 Ok(remote_name)
2273 })
2274 .boxed()
2275 }
2276
2277 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2278 let working_directory = self.working_directory();
2279 let git_binary_path = self.any_git_binary_path.clone();
2280 self.executor
2281 .spawn(async move {
2282 let working_directory = working_directory?;
2283 let output = new_smol_command(&git_binary_path)
2284 .current_dir(&working_directory)
2285 .args(["config", "--get"])
2286 .arg(format!("branch.{branch}.remote"))
2287 .output()
2288 .await?;
2289 if !output.status.success() {
2290 return Ok(None);
2291 }
2292
2293 let remote_name = String::from_utf8_lossy(&output.stdout);
2294 return Ok(Some(Remote {
2295 name: remote_name.trim().to_string().into(),
2296 }));
2297 })
2298 .boxed()
2299 }
2300
2301 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
2302 let working_directory = self.working_directory();
2303 let git_binary_path = self.any_git_binary_path.clone();
2304 self.executor
2305 .spawn(async move {
2306 let working_directory = working_directory?;
2307 let output = new_smol_command(&git_binary_path)
2308 .current_dir(&working_directory)
2309 .args(["remote", "-v"])
2310 .output()
2311 .await?;
2312
2313 anyhow::ensure!(
2314 output.status.success(),
2315 "Failed to get all remotes:\n{}",
2316 String::from_utf8_lossy(&output.stderr)
2317 );
2318 let remote_names: HashSet<Remote> = String::from_utf8_lossy(&output.stdout)
2319 .lines()
2320 .filter(|line| !line.is_empty())
2321 .filter_map(|line| {
2322 let mut split_line = line.split_whitespace();
2323 let remote_name = split_line.next()?;
2324
2325 Some(Remote {
2326 name: remote_name.trim().to_string().into(),
2327 })
2328 })
2329 .collect();
2330
2331 Ok(remote_names.into_iter().collect())
2332 })
2333 .boxed()
2334 }
2335
2336 fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
2337 let repo = self.repository.clone();
2338 self.executor
2339 .spawn(async move {
2340 let repo = repo.lock();
2341 repo.remote_delete(&name)?;
2342
2343 Ok(())
2344 })
2345 .boxed()
2346 }
2347
2348 fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
2349 let repo = self.repository.clone();
2350 self.executor
2351 .spawn(async move {
2352 let repo = repo.lock();
2353 repo.remote(&name, url.as_ref())?;
2354 Ok(())
2355 })
2356 .boxed()
2357 }
2358
2359 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
2360 let working_directory = self.working_directory();
2361 let git_binary_path = self.any_git_binary_path.clone();
2362 self.executor
2363 .spawn(async move {
2364 let working_directory = working_directory?;
2365 let git_cmd = async |args: &[&str]| -> Result<String> {
2366 let output = new_smol_command(&git_binary_path)
2367 .current_dir(&working_directory)
2368 .args(args)
2369 .output()
2370 .await?;
2371 anyhow::ensure!(
2372 output.status.success(),
2373 String::from_utf8_lossy(&output.stderr).to_string()
2374 );
2375 Ok(String::from_utf8(output.stdout)?)
2376 };
2377
2378 let head = git_cmd(&["rev-parse", "HEAD"])
2379 .await
2380 .context("Failed to get HEAD")?
2381 .trim()
2382 .to_owned();
2383
2384 let mut remote_branches = vec![];
2385 let mut add_if_matching = async |remote_head: &str| {
2386 if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
2387 && merge_base.trim() == head
2388 && let Some(s) = remote_head.strip_prefix("refs/remotes/")
2389 {
2390 remote_branches.push(s.to_owned().into());
2391 }
2392 };
2393
2394 // check the main branch of each remote
2395 let remotes = git_cmd(&["remote"])
2396 .await
2397 .context("Failed to get remotes")?;
2398 for remote in remotes.lines() {
2399 if let Ok(remote_head) =
2400 git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
2401 {
2402 add_if_matching(remote_head.trim()).await;
2403 }
2404 }
2405
2406 // ... and the remote branch that the checked-out one is tracking
2407 if let Ok(remote_head) =
2408 git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
2409 {
2410 add_if_matching(remote_head.trim()).await;
2411 }
2412
2413 Ok(remote_branches)
2414 })
2415 .boxed()
2416 }
2417
2418 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
2419 let working_directory = self.working_directory();
2420 let git_binary_path = self.any_git_binary_path.clone();
2421 let executor = self.executor.clone();
2422 self.executor
2423 .spawn(async move {
2424 let working_directory = working_directory?;
2425 let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
2426 .envs(checkpoint_author_envs());
2427 git.with_temp_index(async |git| {
2428 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
2429 let mut excludes = exclude_files(git).await?;
2430
2431 git.run(&["add", "--all"]).await?;
2432 let tree = git.run(&["write-tree"]).await?;
2433 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
2434 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
2435 .await?
2436 } else {
2437 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
2438 };
2439
2440 excludes.restore_original().await?;
2441
2442 Ok(GitRepositoryCheckpoint {
2443 commit_sha: checkpoint_sha.parse()?,
2444 })
2445 })
2446 .await
2447 })
2448 .boxed()
2449 }
2450
2451 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
2452 let working_directory = self.working_directory();
2453 let git_binary_path = self.any_git_binary_path.clone();
2454
2455 let executor = self.executor.clone();
2456 self.executor
2457 .spawn(async move {
2458 let working_directory = working_directory?;
2459
2460 let git = GitBinary::new(git_binary_path, working_directory, executor);
2461 git.run(&[
2462 "restore",
2463 "--source",
2464 &checkpoint.commit_sha.to_string(),
2465 "--worktree",
2466 ".",
2467 ])
2468 .await?;
2469
2470 // TODO: We don't track binary and large files anymore,
2471 // so the following call would delete them.
2472 // Implement an alternative way to track files added by agent.
2473 //
2474 // git.with_temp_index(async move |git| {
2475 // git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
2476 // .await?;
2477 // git.run(&["clean", "-d", "--force"]).await
2478 // })
2479 // .await?;
2480
2481 Ok(())
2482 })
2483 .boxed()
2484 }
2485
2486 fn compare_checkpoints(
2487 &self,
2488 left: GitRepositoryCheckpoint,
2489 right: GitRepositoryCheckpoint,
2490 ) -> BoxFuture<'_, Result<bool>> {
2491 let working_directory = self.working_directory();
2492 let git_binary_path = self.any_git_binary_path.clone();
2493
2494 let executor = self.executor.clone();
2495 self.executor
2496 .spawn(async move {
2497 let working_directory = working_directory?;
2498 let git = GitBinary::new(git_binary_path, working_directory, executor);
2499 let result = git
2500 .run(&[
2501 "diff-tree",
2502 "--quiet",
2503 &left.commit_sha.to_string(),
2504 &right.commit_sha.to_string(),
2505 ])
2506 .await;
2507 match result {
2508 Ok(_) => Ok(true),
2509 Err(error) => {
2510 if let Some(GitBinaryCommandError { status, .. }) =
2511 error.downcast_ref::<GitBinaryCommandError>()
2512 && status.code() == Some(1)
2513 {
2514 return Ok(false);
2515 }
2516
2517 Err(error)
2518 }
2519 }
2520 })
2521 .boxed()
2522 }
2523
2524 fn diff_checkpoints(
2525 &self,
2526 base_checkpoint: GitRepositoryCheckpoint,
2527 target_checkpoint: GitRepositoryCheckpoint,
2528 ) -> BoxFuture<'_, Result<String>> {
2529 let working_directory = self.working_directory();
2530 let git_binary_path = self.any_git_binary_path.clone();
2531
2532 let executor = self.executor.clone();
2533 self.executor
2534 .spawn(async move {
2535 let working_directory = working_directory?;
2536 let git = GitBinary::new(git_binary_path, working_directory, executor);
2537 git.run(&[
2538 "diff",
2539 "--find-renames",
2540 "--patch",
2541 &base_checkpoint.commit_sha.to_string(),
2542 &target_checkpoint.commit_sha.to_string(),
2543 ])
2544 .await
2545 })
2546 .boxed()
2547 }
2548
2549 fn default_branch(
2550 &self,
2551 include_remote_name: bool,
2552 ) -> BoxFuture<'_, Result<Option<SharedString>>> {
2553 let working_directory = self.working_directory();
2554 let git_binary_path = self.any_git_binary_path.clone();
2555
2556 let executor = self.executor.clone();
2557 self.executor
2558 .spawn(async move {
2559 let working_directory = working_directory?;
2560 let git = GitBinary::new(git_binary_path, working_directory, executor);
2561
2562 let strip_prefix = if include_remote_name {
2563 "refs/remotes/"
2564 } else {
2565 "refs/remotes/upstream/"
2566 };
2567
2568 if let Ok(output) = git
2569 .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
2570 .await
2571 {
2572 let output = output
2573 .strip_prefix(strip_prefix)
2574 .map(|s| SharedString::from(s.to_owned()));
2575 return Ok(output);
2576 }
2577
2578 let strip_prefix = if include_remote_name {
2579 "refs/remotes/"
2580 } else {
2581 "refs/remotes/origin/"
2582 };
2583
2584 if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
2585 return Ok(output
2586 .strip_prefix(strip_prefix)
2587 .map(|s| SharedString::from(s.to_owned())));
2588 }
2589
2590 if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
2591 if git.run(&["rev-parse", &default_branch]).await.is_ok() {
2592 return Ok(Some(default_branch.into()));
2593 }
2594 }
2595
2596 if git.run(&["rev-parse", "master"]).await.is_ok() {
2597 return Ok(Some("master".into()));
2598 }
2599
2600 Ok(None)
2601 })
2602 .boxed()
2603 }
2604
2605 fn run_hook(
2606 &self,
2607 hook: RunHook,
2608 env: Arc<HashMap<String, String>>,
2609 ) -> BoxFuture<'_, Result<()>> {
2610 let working_directory = self.working_directory();
2611 let repository = self.repository.clone();
2612 let git_binary_path = self.any_git_binary_path.clone();
2613 let executor = self.executor.clone();
2614 let help_output = self.any_git_binary_help_output();
2615
2616 // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
2617 async move {
2618 let working_directory = working_directory?;
2619 if !help_output
2620 .await
2621 .lines()
2622 .any(|line| line.trim().starts_with("hook "))
2623 {
2624 let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str());
2625 if hook_abs_path.is_file() {
2626 let output = new_smol_command(&hook_abs_path)
2627 .envs(env.iter())
2628 .current_dir(&working_directory)
2629 .output()
2630 .await?;
2631
2632 if !output.status.success() {
2633 return Err(GitBinaryCommandError {
2634 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2635 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2636 status: output.status,
2637 }
2638 .into());
2639 }
2640 }
2641
2642 return Ok(());
2643 }
2644
2645 let git = GitBinary::new(git_binary_path, working_directory, executor)
2646 .envs(HashMap::clone(&env));
2647 git.run(&["hook", "run", "--ignore-missing", hook.as_str()])
2648 .await?;
2649 Ok(())
2650 }
2651 .boxed()
2652 }
2653
2654 fn initial_graph_data(
2655 &self,
2656 log_source: LogSource,
2657 log_order: LogOrder,
2658 request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
2659 ) -> BoxFuture<'_, Result<()>> {
2660 let git_binary_path = self.any_git_binary_path.clone();
2661 let working_directory = self.working_directory();
2662 let executor = self.executor.clone();
2663
2664 async move {
2665 let working_directory = working_directory?;
2666 let git = GitBinary::new(git_binary_path, working_directory, executor);
2667
2668 let mut command = git.build_command([
2669 "log",
2670 GRAPH_COMMIT_FORMAT,
2671 log_order.as_arg(),
2672 log_source.get_arg()?,
2673 ]);
2674 command.stdout(Stdio::piped());
2675 command.stderr(Stdio::null());
2676
2677 let mut child = command.spawn()?;
2678 let stdout = child.stdout.take().context("failed to get stdout")?;
2679 let mut reader = BufReader::new(stdout);
2680
2681 let mut line_buffer = String::new();
2682 let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
2683
2684 loop {
2685 line_buffer.clear();
2686 let bytes_read = reader.read_line(&mut line_buffer).await?;
2687
2688 if bytes_read == 0 {
2689 if !lines.is_empty() {
2690 let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2691 if request_tx.send(commits).await.is_err() {
2692 log::warn!(
2693 "initial_graph_data: receiver dropped while sending commits"
2694 );
2695 }
2696 }
2697 break;
2698 }
2699
2700 let line = line_buffer.trim_end_matches('\n').to_string();
2701 lines.push(line);
2702
2703 if lines.len() >= GRAPH_CHUNK_SIZE {
2704 let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2705 if request_tx.send(commits).await.is_err() {
2706 log::warn!("initial_graph_data: receiver dropped while streaming commits");
2707 break;
2708 }
2709 lines.clear();
2710 }
2711 }
2712
2713 child.status().await?;
2714 Ok(())
2715 }
2716 .boxed()
2717 }
2718
2719 fn commit_data_reader(&self) -> Result<CommitDataReader> {
2720 let git_binary_path = self.any_git_binary_path.clone();
2721 let working_directory = self
2722 .working_directory()
2723 .map_err(|_| anyhow!("no working directory"))?;
2724 let executor = self.executor.clone();
2725
2726 let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
2727
2728 let task = self.executor.spawn(async move {
2729 if let Err(error) =
2730 run_commit_data_reader(git_binary_path, working_directory, executor, request_rx)
2731 .await
2732 {
2733 log::error!("commit data reader failed: {error:?}");
2734 }
2735 });
2736
2737 Ok(CommitDataReader {
2738 request_tx,
2739 _task: task,
2740 })
2741 }
2742}
2743
2744async fn run_commit_data_reader(
2745 git_binary_path: PathBuf,
2746 working_directory: PathBuf,
2747 executor: BackgroundExecutor,
2748 request_rx: smol::channel::Receiver<CommitDataRequest>,
2749) -> Result<()> {
2750 let git = GitBinary::new(git_binary_path, working_directory, executor);
2751 let mut process = git
2752 .build_command(["--no-optional-locks", "cat-file", "--batch"])
2753 .stdin(Stdio::piped())
2754 .stdout(Stdio::piped())
2755 .stderr(Stdio::piped())
2756 .spawn()
2757 .context("starting git cat-file --batch process")?;
2758
2759 let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
2760 let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
2761
2762 const MAX_BATCH_SIZE: usize = 64;
2763
2764 while let Ok(first_request) = request_rx.recv().await {
2765 let mut pending_requests = vec![first_request];
2766
2767 while pending_requests.len() < MAX_BATCH_SIZE {
2768 match request_rx.try_recv() {
2769 Ok(request) => pending_requests.push(request),
2770 Err(_) => break,
2771 }
2772 }
2773
2774 for request in &pending_requests {
2775 stdin.write_all(request.sha.to_string().as_bytes()).await?;
2776 stdin.write_all(b"\n").await?;
2777 }
2778 stdin.flush().await?;
2779
2780 for request in pending_requests {
2781 let result = read_single_commit_response(&mut stdout, &request.sha).await;
2782 request.response_tx.send(result).ok();
2783 }
2784 }
2785
2786 drop(stdin);
2787 process.kill().ok();
2788
2789 Ok(())
2790}
2791
2792async fn read_single_commit_response(
2793 stdout: &mut BufReader<smol::process::ChildStdout>,
2794 sha: &Oid,
2795) -> Result<GraphCommitData> {
2796 let mut header_bytes = Vec::new();
2797 stdout.read_until(b'\n', &mut header_bytes).await?;
2798 let header_line = String::from_utf8_lossy(&header_bytes);
2799
2800 let parts: Vec<&str> = header_line.trim().split(' ').collect();
2801 if parts.len() < 3 {
2802 bail!("invalid cat-file header: {header_line}");
2803 }
2804
2805 let object_type = parts[1];
2806 if object_type == "missing" {
2807 bail!("object not found: {}", sha);
2808 }
2809
2810 if object_type != "commit" {
2811 bail!("expected commit object, got {object_type}");
2812 }
2813
2814 let size: usize = parts[2]
2815 .parse()
2816 .with_context(|| format!("invalid object size: {}", parts[2]))?;
2817
2818 let mut content = vec![0u8; size];
2819 stdout.read_exact(&mut content).await?;
2820
2821 let mut newline = [0u8; 1];
2822 stdout.read_exact(&mut newline).await?;
2823
2824 let content_str = String::from_utf8_lossy(&content);
2825 parse_cat_file_commit(*sha, &content_str)
2826 .ok_or_else(|| anyhow!("failed to parse commit {}", sha))
2827}
2828
2829fn parse_initial_graph_output<'a>(
2830 lines: impl Iterator<Item = &'a str>,
2831) -> Vec<Arc<InitialGraphCommitData>> {
2832 lines
2833 .filter(|line| !line.is_empty())
2834 .filter_map(|line| {
2835 // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
2836 let mut parts = line.split('\x00');
2837
2838 let sha = Oid::from_str(parts.next()?).ok()?;
2839 let parents_str = parts.next()?;
2840 let parents = parents_str
2841 .split_whitespace()
2842 .filter_map(|p| Oid::from_str(p).ok())
2843 .collect();
2844
2845 let ref_names_str = parts.next().unwrap_or("");
2846 let ref_names = if ref_names_str.is_empty() {
2847 Vec::new()
2848 } else {
2849 ref_names_str
2850 .split(", ")
2851 .map(|s| SharedString::from(s.to_string()))
2852 .collect()
2853 };
2854
2855 Some(Arc::new(InitialGraphCommitData {
2856 sha,
2857 parents,
2858 ref_names,
2859 }))
2860 })
2861 .collect()
2862}
2863
2864fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
2865 let mut args = vec![
2866 OsString::from("--no-optional-locks"),
2867 OsString::from("status"),
2868 OsString::from("--porcelain=v1"),
2869 OsString::from("--untracked-files=all"),
2870 OsString::from("--no-renames"),
2871 OsString::from("-z"),
2872 ];
2873 args.extend(
2874 path_prefixes
2875 .iter()
2876 .map(|path_prefix| path_prefix.as_std_path().into()),
2877 );
2878 args.extend(path_prefixes.iter().map(|path_prefix| {
2879 if path_prefix.is_empty() {
2880 Path::new(".").into()
2881 } else {
2882 path_prefix.as_std_path().into()
2883 }
2884 }));
2885 args
2886}
2887
2888/// Temporarily git-ignore commonly ignored files and files over 2MB
2889async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
2890 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
2891 let mut excludes = git.with_exclude_overrides().await?;
2892 excludes
2893 .add_excludes(include_str!("./checkpoint.gitignore"))
2894 .await?;
2895
2896 let working_directory = git.working_directory.clone();
2897 let untracked_files = git.list_untracked_files().await?;
2898 let excluded_paths = untracked_files.into_iter().map(|path| {
2899 let working_directory = working_directory.clone();
2900 smol::spawn(async move {
2901 let full_path = working_directory.join(path.clone());
2902 match smol::fs::metadata(&full_path).await {
2903 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
2904 Some(PathBuf::from("/").join(path.clone()))
2905 }
2906 _ => None,
2907 }
2908 })
2909 });
2910
2911 let excluded_paths = futures::future::join_all(excluded_paths).await;
2912 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
2913
2914 if !excluded_paths.is_empty() {
2915 let exclude_patterns = excluded_paths
2916 .into_iter()
2917 .map(|path| path.to_string_lossy().into_owned())
2918 .collect::<Vec<_>>()
2919 .join("\n");
2920 excludes.add_excludes(&exclude_patterns).await?;
2921 }
2922
2923 Ok(excludes)
2924}
2925
2926struct GitBinary {
2927 git_binary_path: PathBuf,
2928 working_directory: PathBuf,
2929 executor: BackgroundExecutor,
2930 index_file_path: Option<PathBuf>,
2931 envs: HashMap<String, String>,
2932}
2933
2934impl GitBinary {
2935 fn new(
2936 git_binary_path: PathBuf,
2937 working_directory: PathBuf,
2938 executor: BackgroundExecutor,
2939 ) -> Self {
2940 Self {
2941 git_binary_path,
2942 working_directory,
2943 executor,
2944 index_file_path: None,
2945 envs: HashMap::default(),
2946 }
2947 }
2948
2949 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
2950 let status_output = self
2951 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
2952 .await?;
2953
2954 let paths = status_output
2955 .split('\0')
2956 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
2957 .map(|entry| PathBuf::from(&entry[3..]))
2958 .collect::<Vec<_>>();
2959 Ok(paths)
2960 }
2961
2962 fn envs(mut self, envs: HashMap<String, String>) -> Self {
2963 self.envs = envs;
2964 self
2965 }
2966
2967 pub async fn with_temp_index<R>(
2968 &mut self,
2969 f: impl AsyncFnOnce(&Self) -> Result<R>,
2970 ) -> Result<R> {
2971 let index_file_path = self.path_for_index_id(Uuid::new_v4());
2972
2973 let delete_temp_index = util::defer({
2974 let index_file_path = index_file_path.clone();
2975 let executor = self.executor.clone();
2976 move || {
2977 executor
2978 .spawn(async move {
2979 smol::fs::remove_file(index_file_path).await.log_err();
2980 })
2981 .detach();
2982 }
2983 });
2984
2985 // Copy the default index file so that Git doesn't have to rebuild the
2986 // whole index from scratch. This might fail if this is an empty repository.
2987 smol::fs::copy(
2988 self.working_directory.join(".git").join("index"),
2989 &index_file_path,
2990 )
2991 .await
2992 .ok();
2993
2994 self.index_file_path = Some(index_file_path.clone());
2995 let result = f(self).await;
2996 self.index_file_path = None;
2997 let result = result?;
2998
2999 smol::fs::remove_file(index_file_path).await.ok();
3000 delete_temp_index.abort();
3001
3002 Ok(result)
3003 }
3004
3005 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
3006 let path = self
3007 .working_directory
3008 .join(".git")
3009 .join("info")
3010 .join("exclude");
3011
3012 GitExcludeOverride::new(path).await
3013 }
3014
3015 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
3016 self.working_directory
3017 .join(".git")
3018 .join(format!("index-{}.tmp", id))
3019 }
3020
3021 pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
3022 where
3023 S: AsRef<OsStr>,
3024 {
3025 let mut stdout = self.run_raw(args).await?;
3026 if stdout.chars().last() == Some('\n') {
3027 stdout.pop();
3028 }
3029 Ok(stdout)
3030 }
3031
3032 /// Returns the result of the command without trimming the trailing newline.
3033 pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
3034 where
3035 S: AsRef<OsStr>,
3036 {
3037 let mut command = self.build_command(args);
3038 let output = command.output().await?;
3039 anyhow::ensure!(
3040 output.status.success(),
3041 GitBinaryCommandError {
3042 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3043 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3044 status: output.status,
3045 }
3046 );
3047 Ok(String::from_utf8(output.stdout)?)
3048 }
3049
3050 fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
3051 where
3052 S: AsRef<OsStr>,
3053 {
3054 let mut command = new_smol_command(&self.git_binary_path);
3055 command.current_dir(&self.working_directory);
3056 command.args(args);
3057 if let Some(index_file_path) = self.index_file_path.as_ref() {
3058 command.env("GIT_INDEX_FILE", index_file_path);
3059 }
3060 command.envs(&self.envs);
3061 command
3062 }
3063}
3064
3065#[derive(Error, Debug)]
3066#[error("Git command failed:\n{stdout}{stderr}\n")]
3067struct GitBinaryCommandError {
3068 stdout: String,
3069 stderr: String,
3070 status: ExitStatus,
3071}
3072
3073async fn run_git_command(
3074 env: Arc<HashMap<String, String>>,
3075 ask_pass: AskPassDelegate,
3076 mut command: smol::process::Command,
3077 executor: BackgroundExecutor,
3078) -> Result<RemoteCommandOutput> {
3079 if env.contains_key("GIT_ASKPASS") {
3080 let git_process = command.spawn()?;
3081 let output = git_process.output().await?;
3082 anyhow::ensure!(
3083 output.status.success(),
3084 "{}",
3085 String::from_utf8_lossy(&output.stderr)
3086 );
3087 Ok(RemoteCommandOutput {
3088 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3089 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3090 })
3091 } else {
3092 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
3093 command
3094 .env("GIT_ASKPASS", ask_pass.script_path())
3095 .env("SSH_ASKPASS", ask_pass.script_path())
3096 .env("SSH_ASKPASS_REQUIRE", "force");
3097 let git_process = command.spawn()?;
3098
3099 run_askpass_command(ask_pass, git_process).await
3100 }
3101}
3102
3103async fn run_askpass_command(
3104 mut ask_pass: AskPassSession,
3105 git_process: smol::process::Child,
3106) -> anyhow::Result<RemoteCommandOutput> {
3107 select_biased! {
3108 result = ask_pass.run().fuse() => {
3109 match result {
3110 AskPassResult::CancelledByUser => {
3111 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
3112 }
3113 AskPassResult::Timedout => {
3114 Err(anyhow!("Connecting to host timed out"))?
3115 }
3116 }
3117 }
3118 output = git_process.output().fuse() => {
3119 let output = output?;
3120 anyhow::ensure!(
3121 output.status.success(),
3122 "{}",
3123 String::from_utf8_lossy(&output.stderr)
3124 );
3125 Ok(RemoteCommandOutput {
3126 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3127 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3128 })
3129 }
3130 }
3131}
3132
3133#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
3134pub struct RepoPath(Arc<RelPath>);
3135
3136impl std::fmt::Debug for RepoPath {
3137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3138 self.0.fmt(f)
3139 }
3140}
3141
3142impl RepoPath {
3143 pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
3144 let rel_path = RelPath::unix(s.as_ref())?;
3145 Ok(Self::from_rel_path(rel_path))
3146 }
3147
3148 pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
3149 let rel_path = RelPath::new(path, path_style)?;
3150 Ok(Self::from_rel_path(&rel_path))
3151 }
3152
3153 pub fn from_proto(proto: &str) -> Result<Self> {
3154 let rel_path = RelPath::from_proto(proto)?;
3155 Ok(Self(rel_path))
3156 }
3157
3158 pub fn from_rel_path(path: &RelPath) -> RepoPath {
3159 Self(Arc::from(path))
3160 }
3161
3162 pub fn as_std_path(&self) -> &Path {
3163 // git2 does not like empty paths and our RelPath infra turns `.` into ``
3164 // so undo that here
3165 if self.is_empty() {
3166 Path::new(".")
3167 } else {
3168 self.0.as_std_path()
3169 }
3170 }
3171}
3172
3173#[cfg(any(test, feature = "test-support"))]
3174pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
3175 RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
3176}
3177
3178impl AsRef<Arc<RelPath>> for RepoPath {
3179 fn as_ref(&self) -> &Arc<RelPath> {
3180 &self.0
3181 }
3182}
3183
3184impl std::ops::Deref for RepoPath {
3185 type Target = RelPath;
3186
3187 fn deref(&self) -> &Self::Target {
3188 &self.0
3189 }
3190}
3191
3192#[derive(Debug)]
3193pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
3194
3195impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
3196 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
3197 if key.starts_with(self.0) {
3198 Ordering::Greater
3199 } else {
3200 self.0.cmp(key)
3201 }
3202 }
3203}
3204
3205fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
3206 let mut branches = Vec::new();
3207 for line in input.split('\n') {
3208 if line.is_empty() {
3209 continue;
3210 }
3211 let mut fields = line.split('\x00');
3212 let Some(head) = fields.next() else {
3213 continue;
3214 };
3215 let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
3216 continue;
3217 };
3218 let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
3219 continue;
3220 };
3221 let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
3222 continue;
3223 };
3224 let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
3225 continue;
3226 };
3227 let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
3228 else {
3229 continue;
3230 };
3231 let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
3232 continue;
3233 };
3234 let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
3235 continue;
3236 };
3237 let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
3238 continue;
3239 };
3240
3241 branches.push(Branch {
3242 is_head: head == "*",
3243 ref_name,
3244 most_recent_commit: Some(CommitSummary {
3245 sha: head_sha,
3246 subject,
3247 commit_timestamp: commiterdate,
3248 author_name: author_name,
3249 has_parent: !parent_sha.is_empty(),
3250 }),
3251 upstream: if upstream_name.is_empty() {
3252 None
3253 } else {
3254 Some(Upstream {
3255 ref_name: upstream_name.into(),
3256 tracking: upstream_tracking,
3257 })
3258 },
3259 })
3260 }
3261
3262 Ok(branches)
3263}
3264
3265fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
3266 if upstream_track.is_empty() {
3267 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3268 ahead: 0,
3269 behind: 0,
3270 }));
3271 }
3272
3273 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
3274 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
3275 let mut ahead: u32 = 0;
3276 let mut behind: u32 = 0;
3277 for component in upstream_track.split(", ") {
3278 if component == "gone" {
3279 return Ok(UpstreamTracking::Gone);
3280 }
3281 if let Some(ahead_num) = component.strip_prefix("ahead ") {
3282 ahead = ahead_num.parse::<u32>()?;
3283 }
3284 if let Some(behind_num) = component.strip_prefix("behind ") {
3285 behind = behind_num.parse::<u32>()?;
3286 }
3287 }
3288 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3289 ahead,
3290 behind,
3291 }))
3292}
3293
3294fn checkpoint_author_envs() -> HashMap<String, String> {
3295 HashMap::from_iter([
3296 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
3297 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
3298 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
3299 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
3300 ])
3301}
3302
3303#[cfg(test)]
3304mod tests {
3305 use super::*;
3306 use gpui::TestAppContext;
3307
3308 fn disable_git_global_config() {
3309 unsafe {
3310 std::env::set_var("GIT_CONFIG_GLOBAL", "");
3311 std::env::set_var("GIT_CONFIG_SYSTEM", "");
3312 }
3313 }
3314
3315 #[gpui::test]
3316 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
3317 disable_git_global_config();
3318
3319 cx.executor().allow_parking();
3320
3321 let repo_dir = tempfile::tempdir().unwrap();
3322
3323 git2::Repository::init(repo_dir.path()).unwrap();
3324 let file_path = repo_dir.path().join("file");
3325 smol::fs::write(&file_path, "initial").await.unwrap();
3326
3327 let repo = RealGitRepository::new(
3328 &repo_dir.path().join(".git"),
3329 None,
3330 Some("git".into()),
3331 cx.executor(),
3332 )
3333 .unwrap();
3334
3335 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3336 .await
3337 .unwrap();
3338 repo.commit(
3339 "Initial commit".into(),
3340 None,
3341 CommitOptions::default(),
3342 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3343 Arc::new(checkpoint_author_envs()),
3344 )
3345 .await
3346 .unwrap();
3347
3348 smol::fs::write(&file_path, "modified before checkpoint")
3349 .await
3350 .unwrap();
3351 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
3352 .await
3353 .unwrap();
3354 let checkpoint = repo.checkpoint().await.unwrap();
3355
3356 // Ensure the user can't see any branches after creating a checkpoint.
3357 assert_eq!(repo.branches().await.unwrap().len(), 1);
3358
3359 smol::fs::write(&file_path, "modified after checkpoint")
3360 .await
3361 .unwrap();
3362 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3363 .await
3364 .unwrap();
3365 repo.commit(
3366 "Commit after checkpoint".into(),
3367 None,
3368 CommitOptions::default(),
3369 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3370 Arc::new(checkpoint_author_envs()),
3371 )
3372 .await
3373 .unwrap();
3374
3375 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
3376 .await
3377 .unwrap();
3378 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
3379 .await
3380 .unwrap();
3381
3382 // Ensure checkpoint stays alive even after a Git GC.
3383 repo.gc().await.unwrap();
3384 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
3385
3386 assert_eq!(
3387 smol::fs::read_to_string(&file_path).await.unwrap(),
3388 "modified before checkpoint"
3389 );
3390 assert_eq!(
3391 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
3392 .await
3393 .unwrap(),
3394 "1"
3395 );
3396 // See TODO above
3397 // assert_eq!(
3398 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
3399 // .await
3400 // .ok(),
3401 // None
3402 // );
3403 }
3404
3405 #[gpui::test]
3406 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
3407 disable_git_global_config();
3408
3409 cx.executor().allow_parking();
3410
3411 let repo_dir = tempfile::tempdir().unwrap();
3412 git2::Repository::init(repo_dir.path()).unwrap();
3413 let repo = RealGitRepository::new(
3414 &repo_dir.path().join(".git"),
3415 None,
3416 Some("git".into()),
3417 cx.executor(),
3418 )
3419 .unwrap();
3420
3421 smol::fs::write(repo_dir.path().join("foo"), "foo")
3422 .await
3423 .unwrap();
3424 let checkpoint_sha = repo.checkpoint().await.unwrap();
3425
3426 // Ensure the user can't see any branches after creating a checkpoint.
3427 assert_eq!(repo.branches().await.unwrap().len(), 1);
3428
3429 smol::fs::write(repo_dir.path().join("foo"), "bar")
3430 .await
3431 .unwrap();
3432 smol::fs::write(repo_dir.path().join("baz"), "qux")
3433 .await
3434 .unwrap();
3435 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
3436 assert_eq!(
3437 smol::fs::read_to_string(repo_dir.path().join("foo"))
3438 .await
3439 .unwrap(),
3440 "foo"
3441 );
3442 // See TODOs above
3443 // assert_eq!(
3444 // smol::fs::read_to_string(repo_dir.path().join("baz"))
3445 // .await
3446 // .ok(),
3447 // None
3448 // );
3449 }
3450
3451 #[gpui::test]
3452 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
3453 disable_git_global_config();
3454
3455 cx.executor().allow_parking();
3456
3457 let repo_dir = tempfile::tempdir().unwrap();
3458 git2::Repository::init(repo_dir.path()).unwrap();
3459 let repo = RealGitRepository::new(
3460 &repo_dir.path().join(".git"),
3461 None,
3462 Some("git".into()),
3463 cx.executor(),
3464 )
3465 .unwrap();
3466
3467 smol::fs::write(repo_dir.path().join("file1"), "content1")
3468 .await
3469 .unwrap();
3470 let checkpoint1 = repo.checkpoint().await.unwrap();
3471
3472 smol::fs::write(repo_dir.path().join("file2"), "content2")
3473 .await
3474 .unwrap();
3475 let checkpoint2 = repo.checkpoint().await.unwrap();
3476
3477 assert!(
3478 !repo
3479 .compare_checkpoints(checkpoint1, checkpoint2.clone())
3480 .await
3481 .unwrap()
3482 );
3483
3484 let checkpoint3 = repo.checkpoint().await.unwrap();
3485 assert!(
3486 repo.compare_checkpoints(checkpoint2, checkpoint3)
3487 .await
3488 .unwrap()
3489 );
3490 }
3491
3492 #[gpui::test]
3493 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
3494 disable_git_global_config();
3495
3496 cx.executor().allow_parking();
3497
3498 let repo_dir = tempfile::tempdir().unwrap();
3499 let text_path = repo_dir.path().join("main.rs");
3500 let bin_path = repo_dir.path().join("binary.o");
3501
3502 git2::Repository::init(repo_dir.path()).unwrap();
3503
3504 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
3505
3506 smol::fs::write(&bin_path, "some binary file here")
3507 .await
3508 .unwrap();
3509
3510 let repo = RealGitRepository::new(
3511 &repo_dir.path().join(".git"),
3512 None,
3513 Some("git".into()),
3514 cx.executor(),
3515 )
3516 .unwrap();
3517
3518 // initial commit
3519 repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
3520 .await
3521 .unwrap();
3522 repo.commit(
3523 "Initial commit".into(),
3524 None,
3525 CommitOptions::default(),
3526 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3527 Arc::new(checkpoint_author_envs()),
3528 )
3529 .await
3530 .unwrap();
3531
3532 let checkpoint = repo.checkpoint().await.unwrap();
3533
3534 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
3535 .await
3536 .unwrap();
3537 smol::fs::write(&bin_path, "Modified binary file")
3538 .await
3539 .unwrap();
3540
3541 repo.restore_checkpoint(checkpoint).await.unwrap();
3542
3543 // Text files should be restored to checkpoint state,
3544 // but binaries should not (they aren't tracked)
3545 assert_eq!(
3546 smol::fs::read_to_string(&text_path).await.unwrap(),
3547 "fn main() {}"
3548 );
3549
3550 assert_eq!(
3551 smol::fs::read_to_string(&bin_path).await.unwrap(),
3552 "Modified binary file"
3553 );
3554 }
3555
3556 #[test]
3557 fn test_branches_parsing() {
3558 // suppress "help: octal escapes are not supported, `\0` is always null"
3559 #[allow(clippy::octal_escapes)]
3560 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
3561 assert_eq!(
3562 parse_branch_input(input).unwrap(),
3563 vec![Branch {
3564 is_head: true,
3565 ref_name: "refs/heads/zed-patches".into(),
3566 upstream: Some(Upstream {
3567 ref_name: "refs/remotes/origin/zed-patches".into(),
3568 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3569 ahead: 0,
3570 behind: 0
3571 })
3572 }),
3573 most_recent_commit: Some(CommitSummary {
3574 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
3575 subject: "generated protobuf".into(),
3576 commit_timestamp: 1733187470,
3577 author_name: SharedString::new_static("John Doe"),
3578 has_parent: false,
3579 })
3580 }]
3581 )
3582 }
3583
3584 #[test]
3585 fn test_branches_parsing_containing_refs_with_missing_fields() {
3586 #[allow(clippy::octal_escapes)]
3587 let input = " \090012116c03db04344ab10d50348553aa94f1ea0\0refs/heads/broken\n \0eb0cae33272689bd11030822939dd2701c52f81e\0895951d681e5561478c0acdd6905e8aacdfd2249\0refs/heads/dev\0\0\01762948725\0Zed\0Add feature\n*\0895951d681e5561478c0acdd6905e8aacdfd2249\0\0refs/heads/main\0\0\01762948695\0Zed\0Initial commit\n";
3588
3589 let branches = parse_branch_input(input).unwrap();
3590 assert_eq!(branches.len(), 2);
3591 assert_eq!(
3592 branches,
3593 vec![
3594 Branch {
3595 is_head: false,
3596 ref_name: "refs/heads/dev".into(),
3597 upstream: None,
3598 most_recent_commit: Some(CommitSummary {
3599 sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
3600 subject: "Add feature".into(),
3601 commit_timestamp: 1762948725,
3602 author_name: SharedString::new_static("Zed"),
3603 has_parent: true,
3604 })
3605 },
3606 Branch {
3607 is_head: true,
3608 ref_name: "refs/heads/main".into(),
3609 upstream: None,
3610 most_recent_commit: Some(CommitSummary {
3611 sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
3612 subject: "Initial commit".into(),
3613 commit_timestamp: 1762948695,
3614 author_name: SharedString::new_static("Zed"),
3615 has_parent: false,
3616 })
3617 }
3618 ]
3619 )
3620 }
3621
3622 #[test]
3623 fn test_upstream_branch_name() {
3624 let upstream = Upstream {
3625 ref_name: "refs/remotes/origin/feature/branch".into(),
3626 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3627 ahead: 0,
3628 behind: 0,
3629 }),
3630 };
3631 assert_eq!(upstream.branch_name(), Some("feature/branch"));
3632
3633 let upstream = Upstream {
3634 ref_name: "refs/remotes/upstream/main".into(),
3635 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3636 ahead: 0,
3637 behind: 0,
3638 }),
3639 };
3640 assert_eq!(upstream.branch_name(), Some("main"));
3641
3642 let upstream = Upstream {
3643 ref_name: "refs/heads/local".into(),
3644 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3645 ahead: 0,
3646 behind: 0,
3647 }),
3648 };
3649 assert_eq!(upstream.branch_name(), None);
3650
3651 // Test case where upstream branch name differs from what might be the local branch name
3652 let upstream = Upstream {
3653 ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
3654 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3655 ahead: 0,
3656 behind: 0,
3657 }),
3658 };
3659 assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
3660 }
3661
3662 impl RealGitRepository {
3663 /// Force a Git garbage collection on the repository.
3664 fn gc(&self) -> BoxFuture<'_, Result<()>> {
3665 let working_directory = self.working_directory();
3666 let git_binary_path = self.any_git_binary_path.clone();
3667 let executor = self.executor.clone();
3668 self.executor
3669 .spawn(async move {
3670 let git_binary_path = git_binary_path.clone();
3671 let working_directory = working_directory?;
3672 let git = GitBinary::new(git_binary_path, working_directory, executor);
3673 git.run(&["gc", "--prune"]).await?;
3674 Ok(())
3675 })
3676 .boxed()
3677 }
3678 }
3679}