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