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