Skip to repository content6112 lines · 205.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:34:52.264Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
repository.rs
1use crate::commit::{CommitDiffObject, CommitDiffObjectKind, parse_git_diff_raw};
2use crate::stash::GitStash;
3use crate::status::{DiffTreeType, GitStatus, TreeDiff};
4use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
5use anyhow::{Context as _, Result, anyhow, bail};
6use async_channel::Sender;
7use collections::HashMap;
8use futures::channel::oneshot;
9use futures::future::BoxFuture;
10use futures::io::BufWriter;
11use futures::{AsyncWriteExt, FutureExt as _, select_biased};
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::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
19use text::LineEnding;
20
21use std::borrow::Cow;
22use std::collections::HashSet;
23use std::ffi::{OsStr, OsString};
24use std::sync::atomic::AtomicBool;
25
26use std::process::{ExitStatus, Output};
27use std::str::FromStr;
28use std::time::SystemTime;
29use std::{
30 cmp::Ordering,
31 path::{Path, PathBuf},
32 sync::Arc,
33};
34use sum_tree::MapSeekTarget;
35use thiserror::Error;
36use util::command::{Stdio, new_command};
37use util::paths::PathStyle;
38use util::rel_path::RelPath;
39use util::{ResultExt, paths};
40use uuid::Uuid;
41
42pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
43
44pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
45
46/// Format string used in graph log to get initial data for the git graph
47/// %H - Full commit hash
48/// %P - Parent hashes
49/// %D - Ref names
50/// %x00 - Null byte separator, used to split up commit data
51static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D";
52
53/// Used to get commits that match with a search
54/// %H - Full commit hash
55static SEARCH_COMMIT_FORMAT: &str = "--format=%H";
56
57/// Number of commits to load per chunk for the git graph.
58pub const GRAPH_CHUNK_SIZE: usize = 1000;
59
60/// Default value for the `git.worktree_directory` setting.
61pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees";
62
63/// Given the git common directory (from `commondir()`), derive the original
64/// repository's working directory.
65///
66/// For a standard checkout, `common_dir` is `<work_dir>/.git`, so the parent
67/// is the working directory. For a git worktree, `common_dir` is the **main**
68/// repo's `.git` directory, so the parent is the original repo's working directory.
69///
70/// Returns `None` if `common_dir` doesn't end with `.git` (e.g. bare repos),
71/// because there is no working-tree root to resolve to in that case.
72pub fn original_repo_path_from_common_dir(common_dir: &Path) -> Option<PathBuf> {
73 if common_dir.file_name() == Some(OsStr::new(".git")) {
74 common_dir.parent().map(|p| p.to_path_buf())
75 } else {
76 None
77 }
78}
79
80fn linked_worktree_git_dir(worktree_path: &Path) -> Result<PathBuf> {
81 let dot_git_path = worktree_path.join(".git");
82 let git_file = std::fs::read_to_string(&dot_git_path)
83 .with_context(|| format!("failed to read {}", dot_git_path.display()))?;
84 let git_dir = git_file
85 .strip_prefix("gitdir:")
86 .context("worktree .git file missing gitdir pointer")?
87 .trim();
88 Ok(worktree_path.join(git_dir))
89}
90
91fn normalize_git_metadata_path(path: PathBuf) -> Result<PathBuf> {
92 paths::normalize_lexically(&path)
93 .map_err(|_| anyhow!("git metadata path escapes its filesystem root: {path:?}"))
94}
95
96/// Commit data needed for the git graph visualization.
97#[derive(Debug, Clone)]
98pub struct CommitData {
99 pub sha: Oid,
100 /// Most commits have a single parent, so we use a SmallVec to avoid allocations.
101 pub parents: SmallVec<[Oid; 1]>,
102 pub author_name: SharedString,
103 pub author_email: SharedString,
104 pub commit_timestamp: i64,
105 pub subject: SharedString,
106 pub message: SharedString,
107}
108
109#[derive(Debug)]
110pub struct InitialGraphCommitData {
111 pub sha: Oid,
112 pub parents: SmallVec<[Oid; 1]>,
113 pub ref_names: Vec<SharedString>,
114}
115
116impl InitialGraphCommitData {
117 pub fn tag_names(&self) -> Vec<&str> {
118 self.ref_names
119 .iter()
120 .filter_map(|ref_name| {
121 let tag_name = ref_name.strip_prefix("tag: ")?;
122
123 if tag_name.is_empty() {
124 return None;
125 }
126
127 Some(tag_name)
128 })
129 .collect()
130 }
131}
132
133struct CommitDataRequest {
134 sha: Oid,
135 response_tx: oneshot::Sender<Result<CommitData>>,
136}
137
138pub struct CommitDataReader {
139 request_tx: async_channel::Sender<CommitDataRequest>,
140 _task: Task<()>,
141}
142
143impl CommitDataReader {
144 pub async fn read(&self, sha: Oid) -> Result<CommitData> {
145 let (response_tx, response_rx) = oneshot::channel();
146 self.request_tx
147 .send(CommitDataRequest { sha, response_tx })
148 .await
149 .map_err(|_| anyhow!("commit data reader task closed"))?;
150 response_rx
151 .await
152 .map_err(|_| anyhow!("commit data reader task dropped response"))?
153 }
154
155 #[cfg(any(test, feature = "test-support"))]
156 pub fn for_test(
157 executor: BackgroundExecutor,
158 resolve: impl 'static + Send + Sync + Fn(Oid) -> Result<CommitData>,
159 ) -> Self {
160 let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
161 let resolve = Arc::new(resolve);
162 let delay_executor = executor.clone();
163 let task = executor.spawn(async move {
164 while let Ok(CommitDataRequest { sha, response_tx }) = request_rx.recv().await {
165 delay_executor.simulate_random_delay().await;
166 response_tx.send(resolve(sha)).ok();
167 }
168 });
169
170 Self {
171 request_tx,
172 _task: task,
173 }
174 }
175}
176
177fn parse_cat_file_commit(sha: Oid, content: &str) -> Option<CommitData> {
178 let mut parents = SmallVec::new();
179 let mut author_name = SharedString::default();
180 let mut author_email = SharedString::default();
181 let mut commit_timestamp = 0i64;
182 let mut in_headers = true;
183 let mut subject = None;
184 let mut message_lines = Vec::new();
185
186 for line in content.lines() {
187 if in_headers {
188 if line.is_empty() {
189 in_headers = false;
190 continue;
191 }
192
193 if let Some(parent_sha) = line.strip_prefix("parent ") {
194 if let Ok(oid) = Oid::from_str(parent_sha.trim()) {
195 parents.push(oid);
196 }
197 } else if let Some(author_line) = line.strip_prefix("author ") {
198 if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') {
199 if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') {
200 if let Ok(ts) = timestamp_str.parse::<i64>() {
201 commit_timestamp = ts;
202 }
203 if let Some((name, email)) = name_email.rsplit_once(" <") {
204 author_name = SharedString::from(name.to_string());
205 author_email =
206 SharedString::from(email.trim_end_matches('>').to_string());
207 }
208 }
209 }
210 }
211 } else {
212 if subject.is_none() {
213 subject = Some(SharedString::from(line.to_string()));
214 }
215 message_lines.push(line);
216 }
217 }
218
219 Some(CommitData {
220 sha,
221 parents,
222 author_name,
223 author_email,
224 commit_timestamp,
225 subject: subject.unwrap_or_default(),
226 message: SharedString::from(message_lines.join("\n")),
227 })
228}
229
230#[derive(Clone, Debug, Hash, PartialEq, Eq)]
231pub struct Branch {
232 pub is_head: bool,
233 pub ref_name: SharedString,
234 pub upstream: Option<Upstream>,
235 pub most_recent_commit: Option<CommitSummary>,
236}
237
238#[derive(Clone, Debug, Default, PartialEq, Eq)]
239pub struct BranchesScanResult {
240 pub branches: Vec<Branch>,
241 pub error: Option<SharedString>,
242}
243
244impl From<Vec<Branch>> for BranchesScanResult {
245 fn from(branches: Vec<Branch>) -> Self {
246 Self {
247 branches,
248 error: None,
249 }
250 }
251}
252
253impl Branch {
254 pub fn name(&self) -> &str {
255 self.ref_name
256 .as_ref()
257 .strip_prefix("refs/heads/")
258 .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
259 .unwrap_or(self.ref_name.as_ref())
260 }
261
262 pub fn is_remote(&self) -> bool {
263 self.ref_name.starts_with("refs/remotes/")
264 }
265
266 pub fn remote_name(&self) -> Option<&str> {
267 self.ref_name
268 .strip_prefix("refs/remotes/")
269 .and_then(|stripped| stripped.split("/").next())
270 }
271
272 pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
273 self.upstream
274 .as_ref()
275 .and_then(|upstream| upstream.tracking.status())
276 }
277
278 pub fn priority_key(&self) -> (bool, Option<i64>) {
279 (
280 self.is_head,
281 self.most_recent_commit
282 .as_ref()
283 .map(|commit| commit.commit_timestamp),
284 )
285 }
286}
287
288#[derive(Clone, Debug, Hash, PartialEq, Eq)]
289pub struct Worktree {
290 pub path: PathBuf,
291 pub ref_name: Option<SharedString>,
292 // todo(git_worktree) This type should be a Oid
293 pub sha: SharedString,
294 pub is_main: bool,
295 pub is_bare: bool,
296}
297
298/// Describes how a new worktree should choose or create its checked-out HEAD.
299#[derive(Clone, Debug, Hash, PartialEq, Eq)]
300pub enum CreateWorktreeTarget {
301 /// Check out an existing local branch in the new worktree.
302 ExistingBranch {
303 /// The existing local branch to check out.
304 branch_name: String,
305 },
306 /// Create a new local branch for the new worktree.
307 NewBranch {
308 /// The new local branch to create and check out.
309 branch_name: String,
310 /// The commit or ref to create the branch from. Uses `HEAD` when `None`.
311 base_sha: Option<String>,
312 },
313 /// Check out a commit or ref in detached HEAD state.
314 Detached {
315 /// The commit or ref to check out. Uses `HEAD` when `None`.
316 base_sha: Option<String>,
317 },
318}
319
320impl CreateWorktreeTarget {
321 pub fn branch_name(&self) -> Option<&str> {
322 match self {
323 Self::ExistingBranch { branch_name } | Self::NewBranch { branch_name, .. } => {
324 Some(branch_name)
325 }
326 Self::Detached { .. } => None,
327 }
328 }
329}
330
331impl Worktree {
332 /// Returns the branch name if the worktree is attached to a branch.
333 pub fn branch_name(&self) -> Option<&str> {
334 self.ref_name.as_ref().map(|ref_name| {
335 ref_name
336 .strip_prefix("refs/heads/")
337 .or_else(|| ref_name.strip_prefix("refs/remotes/"))
338 .unwrap_or(ref_name)
339 })
340 }
341
342 /// Returns a display name for the worktree, suitable for use in the UI.
343 ///
344 /// If the worktree is attached to a branch, returns the branch name.
345 /// Otherwise, returns the short SHA of the worktree's HEAD commit.
346 pub fn display_name(&self) -> &str {
347 self.branch_name()
348 .unwrap_or(&self.sha[..self.sha.len().min(SHORT_SHA_LENGTH)])
349 }
350
351 pub fn directory_name(&self, main_worktree_path: Option<&Path>) -> String {
352 if self.is_main {
353 return "main worktree".to_string();
354 }
355
356 let dir_name = self
357 .path
358 .file_name()
359 .and_then(|name| name.to_str())
360 .unwrap_or(self.display_name());
361
362 if let Some(main_path) = main_worktree_path {
363 let main_dir = main_path.file_name().and_then(|n| n.to_str());
364 if main_dir == Some(dir_name) {
365 if let Some(parent_name) = self
366 .path
367 .parent()
368 .and_then(|p| p.file_name())
369 .and_then(|n| n.to_str())
370 {
371 return parent_name.to_string();
372 }
373 }
374 }
375
376 dir_name.to_string()
377 }
378}
379
380pub fn parse_worktrees_from_str<T: AsRef<str>>(
381 raw_worktrees: T,
382 main_worktree_path: Option<&Path>,
383) -> Vec<Worktree> {
384 let mut worktrees = Vec::new();
385 let normalized = raw_worktrees.as_ref().replace("\r\n", "\n");
386 let entries = normalized.split("\n\n");
387 for entry in entries {
388 let mut path = None;
389 let mut sha = None;
390 let mut ref_name = None;
391
392 let mut is_bare = false;
393
394 for line in entry.lines() {
395 let line = line.trim();
396 if line.is_empty() {
397 continue;
398 }
399 if let Some(rest) = line.strip_prefix("worktree ") {
400 path = Some(rest.to_string());
401 } else if let Some(rest) = line.strip_prefix("HEAD ") {
402 sha = Some(rest.to_string());
403 } else if let Some(rest) = line.strip_prefix("branch ") {
404 ref_name = Some(rest.to_string());
405 } else if line == "bare" {
406 is_bare = true;
407 }
408 // Ignore other lines: detached, locked, prunable, etc.
409 }
410
411 if let (Some(path), Some(sha)) = (path, sha) {
412 let path = PathBuf::from(path);
413 let is_main =
414 main_worktree_path.is_some_and(|main_worktree_path| path == main_worktree_path);
415 worktrees.push(Worktree {
416 path,
417 ref_name: ref_name.map(Into::into),
418 sha: sha.into(),
419 is_main,
420 is_bare,
421 });
422 }
423 }
424
425 worktrees
426}
427
428#[derive(Clone, Debug, Hash, PartialEq, Eq)]
429pub struct Upstream {
430 pub ref_name: SharedString,
431 pub tracking: UpstreamTracking,
432}
433
434impl Upstream {
435 pub fn is_remote(&self) -> bool {
436 self.remote_name().is_some()
437 }
438
439 pub fn remote_name(&self) -> Option<&str> {
440 self.ref_name
441 .strip_prefix("refs/remotes/")
442 .and_then(|stripped| stripped.split("/").next())
443 }
444
445 pub fn stripped_ref_name(&self) -> Option<&str> {
446 self.ref_name.strip_prefix("refs/remotes/")
447 }
448
449 pub fn branch_name(&self) -> Option<&str> {
450 self.ref_name
451 .strip_prefix("refs/remotes/")
452 .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
453 }
454}
455
456#[derive(Clone, Copy, Default)]
457pub struct CommitOptions {
458 pub amend: bool,
459 pub signoff: bool,
460 pub allow_empty: bool,
461 pub no_verify: bool,
462}
463
464#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
465pub enum UpstreamTracking {
466 /// Remote ref not present in local repository.
467 Gone,
468 /// Remote ref present in local repository (fetched from remote).
469 Tracked(UpstreamTrackingStatus),
470}
471
472impl From<UpstreamTrackingStatus> for UpstreamTracking {
473 fn from(status: UpstreamTrackingStatus) -> Self {
474 UpstreamTracking::Tracked(status)
475 }
476}
477
478impl UpstreamTracking {
479 pub fn is_gone(&self) -> bool {
480 matches!(self, UpstreamTracking::Gone)
481 }
482
483 pub fn status(&self) -> Option<UpstreamTrackingStatus> {
484 match self {
485 UpstreamTracking::Gone => None,
486 UpstreamTracking::Tracked(status) => Some(*status),
487 }
488 }
489}
490
491#[derive(Debug, Clone)]
492pub struct RemoteCommandOutput {
493 pub stdout: String,
494 pub stderr: String,
495}
496
497impl RemoteCommandOutput {
498 pub fn is_empty(&self) -> bool {
499 self.stdout.is_empty() && self.stderr.is_empty()
500 }
501}
502
503#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
504pub struct UpstreamTrackingStatus {
505 pub ahead: u32,
506 pub behind: u32,
507}
508
509#[derive(Clone, Debug, Hash, PartialEq, Eq)]
510pub struct CommitSummary {
511 pub sha: SharedString,
512 pub subject: SharedString,
513 /// This is a unix timestamp
514 pub commit_timestamp: i64,
515 pub author_name: SharedString,
516 pub has_parent: bool,
517}
518
519#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
520pub struct CommitDetails {
521 pub sha: SharedString,
522 pub message: SharedString,
523 pub commit_timestamp: i64,
524 pub author_email: SharedString,
525 pub author_name: SharedString,
526}
527
528#[derive(Debug)]
529pub struct CommitDiff {
530 pub files: Vec<CommitFile>,
531}
532
533#[derive(Clone, Debug, Default, Eq, PartialEq)]
534pub struct FileHistoryChangedFileSets {
535 pub file_sets: Vec<Vec<RepoPath>>,
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
539pub enum CommitFileStatus {
540 Added,
541 Modified,
542 Deleted,
543}
544
545#[derive(Debug)]
546pub struct CommitFile {
547 pub path: RepoPath,
548 pub old_text: Option<String>,
549 pub new_text: Option<String>,
550 pub is_binary: bool,
551}
552
553impl CommitFile {
554 pub fn status(&self) -> CommitFileStatus {
555 match (&self.old_text, &self.new_text) {
556 (None, Some(_)) => CommitFileStatus::Added,
557 (Some(_), None) => CommitFileStatus::Deleted,
558 _ => CommitFileStatus::Modified,
559 }
560 }
561}
562
563impl CommitDetails {
564 pub fn short_sha(&self) -> SharedString {
565 self.sha[..SHORT_SHA_LENGTH].to_string().into()
566 }
567}
568
569/// Detects if content is binary by checking for NUL bytes in the first 8000 bytes.
570/// This matches git's binary detection heuristic.
571pub fn is_binary_content(content: &[u8]) -> bool {
572 let check_len = content.len().min(8000);
573 content[..check_len].contains(&0)
574}
575
576struct LoadedCommitObject {
577 text: String,
578 is_binary: bool,
579}
580
581async fn read_commit_blob<R: smol::io::AsyncBufRead + Unpin>(
582 stdout: &mut R,
583 info_line: &mut String,
584 newline: &mut [u8; 1],
585) -> Result<LoadedCommitObject> {
586 info_line.clear();
587 stdout.read_line(info_line).await?;
588
589 let len = info_line
590 .trim_end()
591 .parse()
592 .with_context(|| format!("invalid object size output from cat-file {info_line}"))?;
593
594 let mut bytes = vec![0; len];
595 stdout.read_exact(&mut bytes).await?;
596 stdout.read_exact(newline).await?;
597
598 let is_binary = is_binary_content(&bytes);
599 Ok(LoadedCommitObject {
600 text: if is_binary {
601 String::new()
602 } else {
603 String::from_utf8_lossy(&bytes).to_string()
604 },
605 is_binary,
606 })
607}
608
609async fn load_commit_object<R: smol::io::AsyncBufRead + Unpin>(
610 object: Option<CommitDiffObject<'_>>,
611 stdout: &mut R,
612 info_line: &mut String,
613 newline: &mut [u8; 1],
614) -> Result<Option<LoadedCommitObject>> {
615 match object {
616 Some(object) if object.kind == CommitDiffObjectKind::Gitlink => {
617 Ok(Some(LoadedCommitObject {
618 text: format!("Subproject commit {}\n", object.oid),
619 is_binary: false,
620 }))
621 }
622 Some(_) => Ok(Some(read_commit_blob(stdout, info_line, newline).await?)),
623 None => Ok(None),
624 }
625}
626
627#[derive(Debug, Clone, Hash, PartialEq, Eq)]
628pub struct Remote {
629 pub name: SharedString,
630}
631
632pub enum ResetMode {
633 /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
634 /// committed are now staged).
635 Soft,
636 /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
637 /// committed are now unstaged).
638 Mixed,
639}
640
641#[derive(Debug, Clone, Hash, PartialEq, Eq)]
642pub enum FetchOptions {
643 All,
644 Remote(Remote),
645}
646
647impl FetchOptions {
648 pub fn to_proto(&self) -> Option<String> {
649 match self {
650 FetchOptions::All => None,
651 FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
652 }
653 }
654
655 pub fn from_proto(remote_name: Option<String>) -> Self {
656 match remote_name {
657 Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
658 None => FetchOptions::All,
659 }
660 }
661
662 pub fn name(&self) -> SharedString {
663 match self {
664 Self::All => "Fetch all remotes".into(),
665 Self::Remote(remote) => remote.name.clone(),
666 }
667 }
668}
669
670impl std::fmt::Display for FetchOptions {
671 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
672 match self {
673 FetchOptions::All => write!(f, "--all"),
674 FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
675 }
676 }
677}
678
679/// Modifies .git/info/exclude temporarily
680pub struct GitExcludeOverride {
681 git_exclude_path: PathBuf,
682 original_excludes: Option<String>,
683 added_excludes: Option<String>,
684}
685
686impl GitExcludeOverride {
687 const START_BLOCK_MARKER: &str = "\n\n# ====== Auto-added by Omega: =======\n";
688 const END_BLOCK_MARKER: &str = "\n# ====== End of auto-added by Omega =======\n";
689 /// The markers Omega wrote into `.git/info/exclude` before the prose rename.
690 ///
691 /// A marker is not only a label: it is how the block is found again and
692 /// removed. Renaming it without reading the old one back would strand an
693 /// inherited-marker block in the user's repository, still excluding files
694 /// from git with nothing left that knows how to clean it up.
695 const LEGACY_BLOCK_MARKERS: &[(&str, &str)] = &[(
696 "\n\n# ====== Auto-added by Zed: =======\n",
697 "\n# ====== End of auto-added by Zed =======\n",
698 )];
699
700 pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
701 let original_excludes =
702 smol::fs::read_to_string(&git_exclude_path)
703 .await
704 .ok()
705 .map(|content| {
706 // Auto-generated lines are normally cleaned up in
707 // `restore_original()` or `drop()`, but may stuck in rare cases.
708 // Make sure to remove them.
709 Self::remove_auto_generated_block(&content)
710 });
711
712 Ok(GitExcludeOverride {
713 git_exclude_path,
714 original_excludes,
715 added_excludes: None,
716 })
717 }
718
719 pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
720 self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
721 format!("{already_added}\n{excludes}")
722 } else {
723 excludes.to_string()
724 });
725
726 let mut content = self.original_excludes.clone().unwrap_or_default();
727
728 content.push_str(Self::START_BLOCK_MARKER);
729 content.push_str(self.added_excludes.as_ref().unwrap());
730 content.push_str(Self::END_BLOCK_MARKER);
731
732 smol::fs::write(&self.git_exclude_path, content).await?;
733 Ok(())
734 }
735
736 pub async fn restore_original(&mut self) -> Result<()> {
737 if let Some(ref original) = self.original_excludes {
738 smol::fs::write(&self.git_exclude_path, original).await?;
739 } else if self.git_exclude_path.exists() {
740 smol::fs::remove_file(&self.git_exclude_path).await?;
741 }
742
743 self.added_excludes = None;
744
745 Ok(())
746 }
747
748 fn remove_auto_generated_block(content: &str) -> String {
749 let mut content = content.to_string();
750
751 for (start_marker, end_marker) in std::iter::once(&(
752 Self::START_BLOCK_MARKER,
753 Self::END_BLOCK_MARKER,
754 ))
755 .chain(Self::LEGACY_BLOCK_MARKERS)
756 {
757 let start_index = content.find(start_marker);
758 let end_index = content.rfind(end_marker);
759
760 if let (Some(start), Some(end)) = (start_index, end_index) {
761 if end > start {
762 content.replace_range(start..end + end_marker.len(), "");
763 }
764 }
765
766 // Older versions didn't have end-of-block markers, so it's
767 // impossible to determine auto-generated lines. Conservatively
768 // remove the standard list of excludes.
769 let standard_excludes =
770 format!("{}{}", start_marker, include_str!("./checkpoint.gitignore"));
771 content = content.replace(&standard_excludes, "");
772 }
773
774 content
775 }
776}
777
778impl Drop for GitExcludeOverride {
779 fn drop(&mut self) {
780 if self.added_excludes.is_some() {
781 let git_exclude_path = self.git_exclude_path.clone();
782 let original_excludes = self.original_excludes.clone();
783 smol::spawn(async move {
784 if let Some(original) = original_excludes {
785 smol::fs::write(&git_exclude_path, original).await
786 } else {
787 smol::fs::remove_file(&git_exclude_path).await
788 }
789 })
790 .detach();
791 }
792 }
793}
794
795#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)]
796pub enum LogOrder {
797 #[default]
798 DateOrder,
799 TopoOrder,
800 AuthorDateOrder,
801 ReverseChronological,
802}
803
804impl LogOrder {
805 pub fn as_arg(&self) -> &'static str {
806 match self {
807 LogOrder::DateOrder => "--date-order",
808 LogOrder::TopoOrder => "--topo-order",
809 LogOrder::AuthorDateOrder => "--author-date-order",
810 LogOrder::ReverseChronological => "--reverse",
811 }
812 }
813}
814
815#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
816pub enum LogSource {
817 #[default]
818 All,
819 Branch(SharedString),
820 Sha(Oid),
821 Path(RepoPath),
822}
823
824impl LogSource {
825 fn get_args(&self) -> Vec<Cow<'_, str>> {
826 match self {
827 LogSource::All => vec![
828 Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD
829 Cow::Borrowed("--branches"),
830 Cow::Borrowed("--remotes"),
831 Cow::Borrowed("--tags"),
832 Cow::Borrowed("HEAD"),
833 ],
834 LogSource::Branch(branch) => vec![Cow::Borrowed(branch.as_str())],
835 LogSource::Sha(oid) => vec![Cow::Owned(oid.to_string())],
836 LogSource::Path(path) => vec![
837 Cow::Borrowed("--follow"),
838 Cow::Borrowed("--"),
839 Cow::Borrowed(path.as_unix_str()),
840 ],
841 }
842 }
843}
844
845pub struct SearchCommitArgs {
846 pub query: SharedString,
847 pub case_sensitive: bool,
848}
849
850pub fn commit_hash_search_query(query: &str) -> Option<&str> {
851 let query = query.trim();
852 (7..=40)
853 .contains(&query.len())
854 .then_some(query)
855 .filter(|query| query.bytes().all(|byte| byte.is_ascii_hexdigit()))
856}
857
858pub fn delete_branch_flag(is_remote_tracking_ref: bool, force: bool) -> &'static str {
859 match (is_remote_tracking_ref, force) {
860 (true, true) => "-Dr",
861 (true, false) => "-dr",
862 (false, true) => "-D",
863 (false, false) => "-d",
864 }
865}
866
867pub trait GitRepository: Send + Sync {
868 /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
869 ///
870 /// Also returns `None` for symlinks.
871 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
872 let future = self.load_revisions(vec![format!(":{}", path.as_unix_str())]);
873 async move { future.await.ok()?.pop()? }.boxed()
874 }
875
876 /// 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.
877 ///
878 /// Also returns `None` for symlinks.
879 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
880 let future = self.load_revisions(vec![format!("HEAD:{}", path.as_unix_str())]);
881 async move { future.await.ok()?.pop()? }.boxed()
882 }
883 fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
884
885 fn set_index_text(
886 &self,
887 path: RepoPath,
888 content: Option<String>,
889 env: Arc<HashMap<String, String>>,
890 is_executable: bool,
891 ) -> BoxFuture<'_, anyhow::Result<()>>;
892
893 /// Returns the URL of the remote with the given name.
894 fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
895 let name = name.to_string();
896 let fut = self.remote_urls();
897 async move { fut.await.remove(&name) }.boxed()
898 }
899
900 /// Returns the URL of all remotes.
901 fn remote_urls(&self) -> BoxFuture<'_, HashMap<String, String>>;
902
903 /// Resolve a list of refs to SHAs.
904 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
905
906 fn load_revisions(&self, revisions: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
907
908 fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
909 async move {
910 self.revparse_batch(vec!["HEAD".into()])
911 .await
912 .unwrap_or_default()
913 .into_iter()
914 .next()
915 .flatten()
916 }
917 .boxed()
918 }
919
920 fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
921
922 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
923 fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
924
925 fn stash_entries(&self) -> BoxFuture<'static, Result<GitStash>>;
926
927 fn check_access(&self) -> BoxFuture<'_, Result<()>> {
928 async move { Ok(()) }.boxed()
929 }
930
931 fn branches(&self) -> BoxFuture<'_, Result<BranchesScanResult>>;
932
933 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
934 fn create_branch(&self, name: String, base_branch: Option<String>)
935 -> BoxFuture<'_, Result<()>>;
936 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
937
938 fn delete_branch(
939 &self,
940 is_remote: bool,
941 name: String,
942 force: bool,
943 ) -> BoxFuture<'_, Result<()>>;
944
945 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
946
947 /// Returns the creation time of a linked worktree's git metadata
948 /// directory (`.git/worktrees/<name>/`), resolved via the worktree's
949 /// `.git` file.
950 ///
951 /// The metadata directory is created by `git worktree add` and removed
952 /// by `git worktree remove`, so its creation time identifies a
953 /// particular incarnation of the worktree: if the worktree is removed
954 /// and recreated at the same path, the creation time changes.
955 ///
956 /// Returns `Ok(None)` when the worktree directory does not exist at
957 /// all, and an error when the directory exists but the time cannot be
958 /// determined (e.g. on filesystems without birthtime support); callers
959 /// should fail safe in the error case.
960 fn worktree_created_at(
961 &self,
962 worktree_path: PathBuf,
963 ) -> BoxFuture<'_, Result<Option<SystemTime>>>;
964
965 fn create_worktree(
966 &self,
967 target: CreateWorktreeTarget,
968 path: PathBuf,
969 ) -> BoxFuture<'_, Result<()>>;
970
971 fn checkout_branch_in_worktree(
972 &self,
973 branch_name: String,
974 worktree_path: PathBuf,
975 create: bool,
976 ) -> BoxFuture<'_, Result<()>>;
977
978 fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>>;
979
980 fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>>;
981
982 fn reset(
983 &self,
984 commit: String,
985 mode: ResetMode,
986 env: Arc<HashMap<String, String>>,
987 ) -> BoxFuture<'_, Result<()>>;
988
989 fn checkout_files(
990 &self,
991 commit: String,
992 paths: Vec<RepoPath>,
993 env: Arc<HashMap<String, String>>,
994 ) -> BoxFuture<'_, Result<()>>;
995
996 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
997
998 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
999 fn blame(
1000 &self,
1001 path: RepoPath,
1002 content: Rope,
1003 line_ending: LineEnding,
1004 ) -> BoxFuture<'_, Result<crate::blame::Blame>>;
1005
1006 /// Returns the absolute path to the repository. For worktrees, this will be the path to the
1007 /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
1008 fn path(&self) -> PathBuf;
1009
1010 fn main_repository_path(&self) -> PathBuf;
1011
1012 /// Updates the index to match the worktree at the given paths.
1013 ///
1014 /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
1015 fn stage_paths(
1016 &self,
1017 paths: Vec<RepoPath>,
1018 env: Arc<HashMap<String, String>>,
1019 ) -> BoxFuture<'_, Result<()>>;
1020 /// Updates the index to match HEAD at the given paths.
1021 ///
1022 /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
1023 fn unstage_paths(
1024 &self,
1025 paths: Vec<RepoPath>,
1026 env: Arc<HashMap<String, String>>,
1027 ) -> BoxFuture<'_, Result<()>>;
1028
1029 /// Only used to serve `proto::RunGitHook` requests from older remote clients;
1030 /// new code lets `git commit` run hooks itself.
1031 ///
1032 /// TODO: remove together with `proto::RunGitHook` (see the deprecation note in git.proto).
1033 fn run_hook(
1034 &self,
1035 hook: RunHook,
1036 env: Arc<HashMap<String, String>>,
1037 ) -> BoxFuture<'_, Result<()>>;
1038
1039 fn commit(
1040 &self,
1041 message: SharedString,
1042 name_and_email: Option<(SharedString, SharedString)>,
1043 options: CommitOptions,
1044 askpass: AskPassDelegate,
1045 env: Arc<HashMap<String, String>>,
1046 ) -> BoxFuture<'_, Result<()>>;
1047
1048 fn stash_paths(
1049 &self,
1050 paths: Vec<RepoPath>,
1051 env: Arc<HashMap<String, String>>,
1052 ) -> BoxFuture<'_, Result<()>>;
1053
1054 fn stash_pop(
1055 &self,
1056 index: Option<usize>,
1057 env: Arc<HashMap<String, String>>,
1058 ) -> BoxFuture<'_, Result<()>>;
1059
1060 fn stash_apply(
1061 &self,
1062 index: Option<usize>,
1063 env: Arc<HashMap<String, String>>,
1064 ) -> BoxFuture<'_, Result<()>>;
1065
1066 fn stash_drop(
1067 &self,
1068 index: Option<usize>,
1069 env: Arc<HashMap<String, String>>,
1070 ) -> BoxFuture<'_, Result<()>>;
1071
1072 fn push(
1073 &self,
1074 branch_name: String,
1075 remote_branch_name: String,
1076 upstream_name: String,
1077 options: Option<PushOptions>,
1078 askpass: AskPassDelegate,
1079 env: Arc<HashMap<String, String>>,
1080 // This method takes an AsyncApp to ensure it's invoked on the main thread,
1081 // otherwise git-credentials-manager won't work.
1082 cx: AsyncApp,
1083 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
1084
1085 fn pull(
1086 &self,
1087 branch_name: Option<String>,
1088 upstream_name: String,
1089 rebase: bool,
1090 askpass: AskPassDelegate,
1091 env: Arc<HashMap<String, String>>,
1092 // This method takes an AsyncApp to ensure it's invoked on the main thread,
1093 // otherwise git-credentials-manager won't work.
1094 cx: AsyncApp,
1095 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
1096
1097 fn fetch(
1098 &self,
1099 fetch_options: FetchOptions,
1100 askpass: AskPassDelegate,
1101 env: Arc<HashMap<String, String>>,
1102 // This method takes an AsyncApp to ensure it's invoked on the main thread,
1103 // otherwise git-credentials-manager won't work.
1104 cx: AsyncApp,
1105 ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
1106
1107 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
1108
1109 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
1110
1111 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
1112
1113 fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>;
1114
1115 fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>;
1116
1117 /// returns a list of remote branches that contain HEAD
1118 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
1119
1120 /// Run git diff
1121 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
1122
1123 fn diff_stat(
1124 &self,
1125 diff: DiffStatType,
1126 path_prefixes: &[RepoPath],
1127 ) -> BoxFuture<'static, Result<crate::status::GitDiffStat>>;
1128
1129 /// Creates a checkpoint for the repository.
1130 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
1131
1132 /// Resets to a previously-created checkpoint.
1133 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
1134
1135 /// Creates two detached commits capturing the current staged and unstaged
1136 /// state without moving any branch. Returns (staged_sha, unstaged_sha).
1137 fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>>;
1138
1139 /// Restores the working directory and index from archive checkpoint SHAs.
1140 /// Assumes HEAD is already at the correct commit (original_commit_hash).
1141 /// Restores the index to match staged_sha's tree, and the working
1142 /// directory to match unstaged_sha's tree.
1143 fn restore_archive_checkpoint(
1144 &self,
1145 staged_sha: String,
1146 unstaged_sha: String,
1147 ) -> BoxFuture<'_, Result<()>>;
1148
1149 /// Compares two checkpoints, returning true if they are equal
1150 fn compare_checkpoints(
1151 &self,
1152 left: GitRepositoryCheckpoint,
1153 right: GitRepositoryCheckpoint,
1154 ) -> BoxFuture<'_, Result<bool>>;
1155
1156 /// Computes a diff between two checkpoints.
1157 fn diff_checkpoints(
1158 &self,
1159 base_checkpoint: GitRepositoryCheckpoint,
1160 target_checkpoint: GitRepositoryCheckpoint,
1161 ) -> BoxFuture<'_, Result<String>>;
1162
1163 fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>>;
1164
1165 fn default_branch(
1166 &self,
1167 include_remote_name: bool,
1168 ) -> BoxFuture<'_, Result<Option<SharedString>>>;
1169
1170 /// Runs `git rev-list --parents` to get the commit graph structure.
1171 /// Returns commit SHAs and their parent SHAs for building the graph visualization.
1172 fn initial_graph_data(
1173 &self,
1174 log_source: LogSource,
1175 log_order: LogOrder,
1176 request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
1177 ) -> BoxFuture<'_, Result<()>>;
1178
1179 fn search_commits(
1180 &self,
1181 log_source: LogSource,
1182 search_args: SearchCommitArgs,
1183 request_tx: Sender<Oid>,
1184 ) -> BoxFuture<'_, Result<()>>;
1185
1186 fn file_history_changed_files(
1187 &self,
1188 paths: Vec<RepoPath>,
1189 commit_limit: usize,
1190 ) -> BoxFuture<'_, Result<Vec<FileHistoryChangedFileSets>>>;
1191
1192 fn commit_data_reader(&self) -> Result<CommitDataReader>;
1193
1194 fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>>;
1195
1196 fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>>;
1197
1198 fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>>;
1199
1200 fn set_trusted(&self, trusted: bool);
1201 fn is_trusted(&self) -> bool;
1202}
1203
1204pub enum DiffType {
1205 HeadToIndex,
1206 HeadToWorktree,
1207 MergeBase { base_ref: SharedString },
1208}
1209
1210#[derive(Clone, Copy)]
1211pub enum DiffStatType {
1212 HeadToIndex,
1213 HeadToWorktree,
1214 IndexToWorktree,
1215}
1216
1217#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
1218pub enum PushOptions {
1219 SetUpstream,
1220 Force,
1221}
1222
1223impl std::fmt::Debug for dyn GitRepository {
1224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1225 f.debug_struct("dyn GitRepository<...>").finish()
1226 }
1227}
1228
1229pub struct RealGitRepository {
1230 pub git_dir: PathBuf,
1231 pub common_dir: PathBuf,
1232 /// `None` only for bare repositories, which do not have a working directory.
1233 pub working_directory: Option<PathBuf>,
1234 pub system_git_binary_path: Option<PathBuf>,
1235 pub any_git_binary_path: PathBuf,
1236 any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
1237 executor: BackgroundExecutor,
1238 is_trusted: Arc<AtomicBool>,
1239}
1240
1241#[derive(Debug)]
1242pub enum RefEdit {
1243 Update { ref_name: String, commit: String },
1244 Delete { ref_name: String },
1245}
1246
1247impl RefEdit {
1248 fn into_args(self) -> Vec<OsString> {
1249 match self {
1250 Self::Update { ref_name, commit } => {
1251 vec!["update-ref".into(), ref_name.into(), commit.into()]
1252 }
1253 Self::Delete { ref_name } => {
1254 vec!["update-ref".into(), "-d".into(), ref_name.into()]
1255 }
1256 }
1257 }
1258}
1259
1260impl RealGitRepository {
1261 pub fn new(
1262 dotgit_path: &Path,
1263 bundled_git_binary_path: Option<PathBuf>,
1264 system_git_binary_path: Option<PathBuf>,
1265 executor: BackgroundExecutor,
1266 ) -> Result<Self> {
1267 let any_git_binary_path = system_git_binary_path
1268 .clone()
1269 .or(bundled_git_binary_path)
1270 .context("no git binary available")?;
1271 log::info!(
1272 "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}"
1273 );
1274 let dotgit_parent = dotgit_path.parent().context(".git has no parent")?;
1275 let has_working_directory =
1276 dotgit_path.is_file() || dotgit_path.file_name() == Some(OsStr::new(".git"));
1277 let working_directory = if has_working_directory {
1278 Some(normalize_git_metadata_path(dotgit_parent.to_path_buf())?)
1279 } else {
1280 None
1281 };
1282
1283 let git_dir = if dotgit_path.is_file() {
1284 let content =
1285 std::fs::read_to_string(dotgit_path).context("reading .git worktree file")?;
1286 let path_str = content
1287 .strip_prefix("gitdir: ")
1288 .context("expected .git file to start with 'gitdir: '")?
1289 .trim();
1290 let resolved = PathBuf::from(path_str);
1291 let resolved = if resolved.is_absolute() {
1292 resolved
1293 } else {
1294 dotgit_parent.join(resolved)
1295 };
1296 normalize_git_metadata_path(resolved)?
1297 } else {
1298 normalize_git_metadata_path(dotgit_path.to_path_buf())?
1299 };
1300
1301 let common_dir = {
1302 let commondir_file = git_dir.join("commondir");
1303 if commondir_file.is_file() {
1304 let content =
1305 std::fs::read_to_string(&commondir_file).context("reading commondir file")?;
1306 let path_str = content.trim();
1307 let resolved = PathBuf::from(path_str);
1308 let resolved = if resolved.is_absolute() {
1309 resolved
1310 } else {
1311 git_dir.join(resolved)
1312 };
1313 normalize_git_metadata_path(resolved)?
1314 } else {
1315 git_dir.clone()
1316 }
1317 };
1318
1319 Ok(Self {
1320 git_dir,
1321 common_dir,
1322 working_directory,
1323 system_git_binary_path,
1324 any_git_binary_path,
1325 executor,
1326 any_git_binary_help_output: Arc::new(Mutex::new(None)),
1327 is_trusted: Arc::new(AtomicBool::new(false)),
1328 })
1329 }
1330
1331 fn working_directory(&self) -> Result<PathBuf> {
1332 self.working_directory
1333 .clone()
1334 .context("bare repositories do not have a working directory")
1335 }
1336
1337 fn command_directory(&self) -> PathBuf {
1338 self.working_directory
1339 .clone()
1340 .unwrap_or_else(|| self.git_dir.clone())
1341 }
1342
1343 fn git_binary_in_worktree(&self) -> Result<GitBinary> {
1344 Ok(GitBinary::new(
1345 self.any_git_binary_path.clone(),
1346 self.working_directory()?,
1347 self.path(),
1348 self.executor.clone(),
1349 self.is_trusted(),
1350 ))
1351 }
1352
1353 fn git_binary(&self) -> GitBinary {
1354 GitBinary::new(
1355 self.any_git_binary_path.clone(),
1356 self.command_directory(),
1357 self.path(),
1358 self.executor.clone(),
1359 self.is_trusted(),
1360 )
1361 }
1362
1363 fn edit_ref(&self, edit: RefEdit) -> BoxFuture<'_, Result<()>> {
1364 let git_binary = self.git_binary();
1365 self.executor
1366 .spawn(async move {
1367 let git = git_binary;
1368 let args = edit.into_args();
1369 git.run(&args).await?;
1370 Ok(())
1371 })
1372 .boxed()
1373 }
1374
1375 async fn any_git_binary_help_output(&self) -> SharedString {
1376 if let Some(output) = self.any_git_binary_help_output.lock().clone() {
1377 return output;
1378 }
1379 let git = self.git_binary();
1380 let output: SharedString = self
1381 .executor
1382 .spawn(async move { git.run(&["help", "-a"]).await })
1383 .await
1384 .unwrap_or_default()
1385 .into();
1386 *self.any_git_binary_help_output.lock() = Some(output.clone());
1387 output
1388 }
1389}
1390
1391#[derive(Clone, Debug)]
1392pub struct GitRepositoryCheckpoint {
1393 pub commit_sha: Oid,
1394}
1395
1396#[derive(Debug)]
1397pub struct GitCommitter {
1398 pub name: Option<String>,
1399 pub email: Option<String>,
1400}
1401
1402#[derive(Clone, Debug)]
1403pub struct GitCommitTemplate {
1404 pub template: String,
1405}
1406
1407pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
1408 if cfg!(any(feature = "test-support", test)) {
1409 return GitCommitter {
1410 name: None,
1411 email: None,
1412 };
1413 }
1414
1415 let git_binary_path =
1416 if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
1417 cx.update(|cx| {
1418 cx.path_for_auxiliary_executable("git")
1419 .context("could not find git binary path")
1420 .log_err()
1421 })
1422 } else {
1423 None
1424 };
1425
1426 let git = GitBinary::new(
1427 git_binary_path.unwrap_or(PathBuf::from("git")),
1428 paths::home_dir().clone(),
1429 paths::home_dir().join(".git"),
1430 cx.background_executor().clone(),
1431 true,
1432 );
1433
1434 cx.background_spawn(async move {
1435 let name = git
1436 .run(&["config", "--global", "user.name"])
1437 .await
1438 .log_err();
1439 let email = git
1440 .run(&["config", "--global", "user.email"])
1441 .await
1442 .log_err();
1443 GitCommitter { name, email }
1444 })
1445 .await
1446}
1447
1448impl GitRepository for RealGitRepository {
1449 fn path(&self) -> PathBuf {
1450 self.git_dir.clone()
1451 }
1452
1453 fn main_repository_path(&self) -> PathBuf {
1454 self.common_dir.clone()
1455 }
1456
1457 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
1458 let git = self.git_binary();
1459 self.executor
1460 .spawn(async move {
1461 let output = git
1462 .build_command(&[
1463 "show",
1464 "--no-patch",
1465 "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
1466 &commit,
1467 ])
1468 .output()
1469 .await?;
1470 let output = std::str::from_utf8(&output.stdout)?;
1471 let fields = output.split('\0').collect::<Vec<_>>();
1472 if fields.len() != 6 {
1473 bail!("unexpected git-show output for {commit:?}: {output:?}")
1474 }
1475 let sha = fields[0].to_string().into();
1476 let message = fields[1].to_string().into();
1477 let commit_timestamp = fields[2].parse()?;
1478 let author_email = fields[3].to_string().into();
1479 let author_name = fields[4].to_string().into();
1480 Ok(CommitDetails {
1481 sha,
1482 message,
1483 commit_timestamp,
1484 author_email,
1485 author_name,
1486 })
1487 })
1488 .boxed()
1489 }
1490
1491 fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
1492 let git = self.git_binary();
1493 cx.background_spawn(async move {
1494 let show_output = git
1495 .build_command(&[
1496 "show",
1497 "--format=",
1498 "-z",
1499 "--no-renames",
1500 "--raw",
1501 "--no-abbrev",
1502 "--first-parent",
1503 ])
1504 .arg(&commit)
1505 .stdin(Stdio::null())
1506 .stdout(Stdio::piped())
1507 .stderr(Stdio::piped())
1508 .output()
1509 .await
1510 .context("starting git show process")?;
1511 anyhow::ensure!(
1512 show_output.status.success(),
1513 "git show failed: {}",
1514 String::from_utf8_lossy(&show_output.stderr)
1515 );
1516
1517 let show_stdout = String::from_utf8_lossy(&show_output.stdout);
1518 let changes = parse_git_diff_raw(&show_stdout);
1519
1520 let mut cat_file_process = git
1521 .build_command(&["cat-file", "--batch=%(objectsize)"])
1522 .stdin(Stdio::piped())
1523 .stdout(Stdio::piped())
1524 .stderr(Stdio::piped())
1525 .spawn()
1526 .context("starting git cat-file process")?;
1527
1528 let mut files = Vec::<CommitFile>::new();
1529 let stdin = cat_file_process
1530 .stdin
1531 .take()
1532 .context("git cat-file process has no stdin")?;
1533 let stdout = cat_file_process
1534 .stdout
1535 .take()
1536 .context("git cat-file process has no stdout")?;
1537 let mut stdin = BufWriter::with_capacity(512, stdin);
1538 let mut stdout = BufReader::new(stdout);
1539 let mut info_line = String::new();
1540 let mut newline = [b'\0'];
1541 for change in changes {
1542 let change = change?;
1543 let path = change.path;
1544 // git-show outputs `/`-delimited paths even on Windows.
1545 let Some(rel_path) = RelPath::from_unix_str(path).log_err() else {
1546 continue;
1547 };
1548
1549 let objects = [change.new_object, change.old_object];
1550 let mut has_blobs = false;
1551 for object in objects.iter().flatten() {
1552 if object.kind == CommitDiffObjectKind::Blob {
1553 stdin.write_all(object.oid.as_bytes()).await?;
1554 stdin.write_all(b"\n").await?;
1555 has_blobs = true;
1556 }
1557 }
1558 if has_blobs {
1559 stdin.flush().await?;
1560 }
1561
1562 let [new_object, old_object] = objects;
1563 let new_object =
1564 load_commit_object(new_object, &mut stdout, &mut info_line, &mut newline)
1565 .await?;
1566 let old_object =
1567 load_commit_object(old_object, &mut stdout, &mut info_line, &mut newline)
1568 .await?;
1569 let is_binary = new_object.as_ref().is_some_and(|object| object.is_binary)
1570 || old_object.as_ref().is_some_and(|object| object.is_binary);
1571 let new_text = new_object.map(|object| {
1572 if is_binary {
1573 String::new()
1574 } else {
1575 object.text
1576 }
1577 });
1578 let old_text = old_object.map(|object| {
1579 if is_binary {
1580 String::new()
1581 } else {
1582 object.text
1583 }
1584 });
1585
1586 files.push(CommitFile {
1587 path: RepoPath(Arc::from(rel_path)),
1588 old_text,
1589 new_text,
1590 is_binary,
1591 })
1592 }
1593
1594 Ok(CommitDiff { files })
1595 })
1596 .boxed()
1597 }
1598
1599 fn reset(
1600 &self,
1601 commit: String,
1602 mode: ResetMode,
1603 env: Arc<HashMap<String, String>>,
1604 ) -> BoxFuture<'_, Result<()>> {
1605 let git = self.git_binary_in_worktree();
1606 async move {
1607 let git = git?;
1608 let mode_flag = match mode {
1609 ResetMode::Mixed => "--mixed",
1610 ResetMode::Soft => "--soft",
1611 };
1612
1613 let output = git
1614 .build_command(&["reset", mode_flag, &commit])
1615 .envs(env.iter())
1616 .output()
1617 .await?;
1618 anyhow::ensure!(
1619 output.status.success(),
1620 "Failed to reset:\n{}",
1621 String::from_utf8_lossy(&output.stderr),
1622 );
1623 Ok(())
1624 }
1625 .boxed()
1626 }
1627
1628 fn checkout_files(
1629 &self,
1630 commit: String,
1631 paths: Vec<RepoPath>,
1632 env: Arc<HashMap<String, String>>,
1633 ) -> BoxFuture<'_, Result<()>> {
1634 let git = self.git_binary_in_worktree();
1635 async move {
1636 let git = git?;
1637 if paths.is_empty() {
1638 return Ok(());
1639 }
1640
1641 let output = git
1642 .build_command(&["checkout", &commit, "--"])
1643 .envs(env.iter())
1644 .args(paths.iter().map(|path| path.as_unix_str()))
1645 .output()
1646 .await?;
1647 anyhow::ensure!(
1648 output.status.success(),
1649 "Failed to checkout files:\n{}",
1650 String::from_utf8_lossy(&output.stderr),
1651 );
1652 Ok(())
1653 }
1654 .boxed()
1655 }
1656
1657 fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
1658 let git_binary = self.git_binary();
1659 let oid_str = oid.to_string();
1660 self.executor
1661 .spawn(async move { git_binary.run_raw(&["cat-file", "blob", &oid_str]).await })
1662 .boxed()
1663 }
1664
1665 fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>> {
1666 let working_directory = self.working_directory();
1667 let git_binary = self.git_binary_in_worktree();
1668
1669 self.executor
1670 .spawn(async move {
1671 let working_directory = working_directory?;
1672 let git_binary = git_binary?;
1673 let output = git_binary
1674 .build_command(&["config", "--get", "commit.template"])
1675 .output()
1676 .await
1677 .context("failed to run git config --get commit.template")?;
1678
1679 let raw_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
1680 if !output.status.success() || raw_path.is_empty() {
1681 return Ok(None);
1682 }
1683
1684 let path = PathBuf::from(&raw_path);
1685 let path = if let Some(path) = raw_path.strip_prefix("~/") {
1686 paths::home_dir().join(path)
1687 } else if path.is_relative() {
1688 working_directory.join(path)
1689 } else {
1690 path
1691 };
1692
1693 let template = match std::fs::read_to_string(&path) {
1694 Ok(s) if !s.trim().is_empty() => Some(s),
1695 Err(err) => {
1696 log::warn!("failed to read commit template {}: {}", path.display(), err);
1697 None
1698 }
1699 _ => None,
1700 };
1701
1702 Ok(template.map(|template| GitCommitTemplate { template }))
1703 })
1704 .boxed()
1705 }
1706
1707 fn set_index_text(
1708 &self,
1709 path: RepoPath,
1710 content: Option<String>,
1711 env: Arc<HashMap<String, String>>,
1712 is_executable: bool,
1713 ) -> BoxFuture<'_, anyhow::Result<()>> {
1714 let git = self.git_binary();
1715 self.executor
1716 .spawn(async move {
1717 let mode = if is_executable { "100755" } else { "100644" };
1718
1719 if let Some(content) = content {
1720 let mut child = git
1721 .build_command(&["hash-object", "-w", "--stdin"])
1722 .envs(env.iter())
1723 .stdin(Stdio::piped())
1724 .stdout(Stdio::piped())
1725 .spawn()?;
1726 let mut stdin = child.stdin.take().unwrap();
1727 stdin.write_all(content.as_bytes()).await?;
1728 stdin.flush().await?;
1729 drop(stdin);
1730 let output = child.output().await?.stdout;
1731 let sha = str::from_utf8(&output)?.trim();
1732
1733 log::debug!("indexing SHA: {sha}, path {path:?}");
1734
1735 let output = git
1736 .build_command(&["update-index", "--add", "--cacheinfo", mode, sha])
1737 .envs(env.iter())
1738 .arg(path.as_unix_str())
1739 .output()
1740 .await?;
1741
1742 anyhow::ensure!(
1743 output.status.success(),
1744 "Failed to stage:\n{}",
1745 String::from_utf8_lossy(&output.stderr)
1746 );
1747 } else {
1748 log::debug!("removing path {path:?} from the index");
1749 let output = git
1750 .build_command(&["update-index", "--force-remove", "--"])
1751 .envs(env.iter())
1752 .arg(path.as_unix_str())
1753 .output()
1754 .await?;
1755 anyhow::ensure!(
1756 output.status.success(),
1757 "Failed to unstage:\n{}",
1758 String::from_utf8_lossy(&output.stderr)
1759 );
1760 }
1761
1762 Ok(())
1763 })
1764 .boxed()
1765 }
1766
1767 fn remote_urls(&self) -> BoxFuture<'_, HashMap<String, String>> {
1768 let git = self.git_binary();
1769 self.executor
1770 .spawn(async move {
1771 let mut urls = HashMap::default();
1772 if let Ok(stdout) = git.run(&["remote", "-v"]).await {
1773 for line in stdout.lines() {
1774 if let Some(line) = line.strip_suffix(" (fetch)")
1775 && let Some((name, url)) = line.split_once(char::is_whitespace)
1776 {
1777 urls.insert(name.to_string(), url.trim_start().to_string());
1778 }
1779 }
1780 }
1781 urls
1782 })
1783 .boxed()
1784 }
1785
1786 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1787 let git = self.git_binary();
1788 self.executor
1789 .spawn(async move {
1790 let mut process = git
1791 .build_command(&["cat-file", "--batch-check=%(objectname)"])
1792 .stdin(Stdio::piped())
1793 .stdout(Stdio::piped())
1794 .stderr(Stdio::piped())
1795 .spawn()?;
1796
1797 let stdin = process
1798 .stdin
1799 .take()
1800 .context("no stdin for git cat-file subprocess")?;
1801 let mut stdin = BufWriter::new(stdin);
1802 for rev in &revs {
1803 stdin.write_all(rev.as_bytes()).await?;
1804 stdin.write_all(b"\n").await?;
1805 }
1806 stdin.flush().await?;
1807 drop(stdin);
1808
1809 let output = process.output().await?;
1810 let output = std::str::from_utf8(&output.stdout)?;
1811 let shas = output
1812 .lines()
1813 .map(|line| {
1814 if line.ends_with("missing") {
1815 None
1816 } else {
1817 Some(line.to_string())
1818 }
1819 })
1820 .collect::<Vec<_>>();
1821
1822 if shas.len() != revs.len() {
1823 // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
1824 bail!("unexpected number of shas")
1825 }
1826
1827 Ok(shas)
1828 })
1829 .boxed()
1830 }
1831
1832 fn load_revisions(&self, revisions: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1833 let git = self.git_binary();
1834 self.executor
1835 .spawn(async move {
1836 if revisions.is_empty() {
1837 return Ok(Vec::new());
1838 }
1839
1840 let mut process = git
1841 .build_command(&["cat-file", "--batch"])
1842 .stdin(Stdio::piped())
1843 .stdout(Stdio::piped())
1844 .stderr(Stdio::piped())
1845 .kill_on_drop(true)
1846 .spawn()?;
1847
1848 let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
1849 let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
1850 let mut newline = [0u8; 1];
1851
1852 let mut header_bytes = Vec::new();
1853 let mut results = Vec::with_capacity(revisions.len());
1854 for rev in &revisions {
1855 stdin.write_all(rev.as_bytes()).await?;
1856 stdin.write_all(b"\n").await?;
1857 stdin.flush().await?;
1858
1859 header_bytes.clear();
1860 stdout.read_until(b'\n', &mut header_bytes).await?;
1861 let header_line = String::from_utf8_lossy(&header_bytes);
1862
1863 let parts: Vec<&str> = header_line.trim().split(' ').collect();
1864 match parts[..] {
1865 [.., "missing"] => {
1866 results.push(None);
1867 }
1868 [_, object_type, size_str] => {
1869 let size: usize = size_str
1870 .parse()
1871 .with_context(|| format!("invalid object size: {size_str}"))?;
1872
1873 let mut content = vec![0u8; size];
1874 stdout.read_exact(&mut content).await?;
1875 stdout.read_exact(&mut newline).await?;
1876
1877 if object_type == "blob" {
1878 results.push(String::from_utf8(content).ok());
1879 } else {
1880 results.push(None);
1881 }
1882 }
1883 _ => bail!("invalid cat-file header: {header_line}"),
1884 }
1885 }
1886
1887 drop(stdin);
1888 process.output().await?;
1889 Ok(results)
1890 })
1891 .boxed()
1892 }
1893
1894 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
1895 let path = self.path().join("MERGE_MSG");
1896 self.executor
1897 .spawn(async move { std::fs::read_to_string(&path).ok() })
1898 .boxed()
1899 }
1900
1901 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
1902 let git = self.git_binary_in_worktree();
1903 let args = git_status_args(path_prefixes);
1904 log::debug!("Checking for git status in {path_prefixes:?}");
1905 self.executor.spawn(async move {
1906 let git = git?;
1907 let output = git.build_command(&args).output().await?;
1908 if output.status.success() {
1909 let stdout = String::from_utf8_lossy(&output.stdout);
1910 stdout.parse()
1911 } else {
1912 let stderr = String::from_utf8_lossy(&output.stderr);
1913 anyhow::bail!("git status failed: {stderr}");
1914 }
1915 })
1916 }
1917
1918 fn check_access(&self) -> BoxFuture<'_, Result<()>> {
1919 let git = self.git_binary_in_worktree();
1920 self.executor
1921 .spawn(async move {
1922 git?.run(&["rev-parse"]).await?;
1923 Ok(())
1924 })
1925 .boxed()
1926 }
1927
1928 fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
1929 let git = self.git_binary_in_worktree();
1930
1931 let mut args = vec![
1932 OsString::from("diff-tree"),
1933 OsString::from("-r"),
1934 OsString::from("-z"),
1935 OsString::from("--no-renames"),
1936 ];
1937 match request {
1938 DiffTreeType::MergeBase { base, head } => {
1939 args.push("--merge-base".into());
1940 args.push(OsString::from(base.as_str()));
1941 args.push(OsString::from(head.as_str()));
1942 }
1943 DiffTreeType::Since { base, head } => {
1944 args.push(OsString::from(base.as_str()));
1945 args.push(OsString::from(head.as_str()));
1946 }
1947 }
1948
1949 self.executor
1950 .spawn(async move {
1951 let git = git?;
1952 let output = git.build_command(&args).output().await?;
1953 if output.status.success() {
1954 let stdout = String::from_utf8_lossy(&output.stdout);
1955 stdout.parse()
1956 } else {
1957 let stderr = String::from_utf8_lossy(&output.stderr);
1958 anyhow::bail!("git status failed: {stderr}");
1959 }
1960 })
1961 .boxed()
1962 }
1963
1964 fn stash_entries(&self) -> BoxFuture<'static, Result<GitStash>> {
1965 let git = self.git_binary_in_worktree();
1966 self.executor
1967 .spawn(async move {
1968 let git = git?;
1969 let output = git
1970 .build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1971 .output()
1972 .await?;
1973 if output.status.success() {
1974 let stdout = String::from_utf8_lossy(&output.stdout);
1975 stdout.parse()
1976 } else {
1977 let stderr = String::from_utf8_lossy(&output.stderr);
1978 anyhow::bail!("git status failed: {stderr}");
1979 }
1980 })
1981 .boxed()
1982 }
1983
1984 fn branches(&self) -> BoxFuture<'_, Result<BranchesScanResult>> {
1985 let git = self.git_binary();
1986 self.executor
1987 .spawn(async move {
1988 let fields = [
1989 "%(HEAD)",
1990 "%(objectname)",
1991 "%(parent)",
1992 "%(refname)",
1993 "%(upstream)",
1994 "%(upstream:track)",
1995 "%(committerdate:unix)",
1996 "%(authorname)",
1997 "%(contents:subject)",
1998 ]
1999 .join("%00");
2000 let args = vec![
2001 "for-each-ref",
2002 "refs/heads/**/*",
2003 "refs/remotes/**/*",
2004 "--format",
2005 &fields,
2006 ];
2007 let output = git.build_command(&args).output().await?;
2008
2009 let error = if output.status.success() {
2010 None
2011 } else {
2012 let error = format_branch_scan_error(&output);
2013 log::warn!("failed to get git branches with commit metadata: {error}");
2014 Some(error.into())
2015 };
2016
2017 let input = String::from_utf8_lossy(&output.stdout);
2018 let mut branches = parse_branch_input(&input)?;
2019 if branches.is_empty() {
2020 let args = vec!["symbolic-ref", "--quiet", "HEAD"];
2021
2022 let output = git.build_command(&args).output().await?;
2023
2024 // git symbolic-ref returns a non-0 exit code if HEAD points
2025 // to something other than a branch
2026 if output.status.success() {
2027 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
2028
2029 branches.push(Branch {
2030 ref_name: name.into(),
2031 is_head: true,
2032 upstream: None,
2033 most_recent_commit: None,
2034 });
2035 }
2036 }
2037
2038 Ok(BranchesScanResult { branches, error })
2039 })
2040 .boxed()
2041 }
2042
2043 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
2044 let git = self.git_binary();
2045 let main_worktree_path = original_repo_path_from_common_dir(&self.common_dir);
2046 self.executor
2047 .spawn(async move {
2048 let output = git
2049 .build_command(&["worktree", "list", "--porcelain"])
2050 .output()
2051 .await?;
2052 if output.status.success() {
2053 let stdout = String::from_utf8_lossy(&output.stdout);
2054 Ok(parse_worktrees_from_str(
2055 &stdout,
2056 main_worktree_path.as_deref(),
2057 ))
2058 } else {
2059 let stderr = String::from_utf8_lossy(&output.stderr);
2060 anyhow::bail!("git worktree list failed: {stderr}");
2061 }
2062 })
2063 .boxed()
2064 }
2065
2066 fn worktree_created_at(
2067 &self,
2068 worktree_path: PathBuf,
2069 ) -> BoxFuture<'_, Result<Option<SystemTime>>> {
2070 self.executor
2071 .spawn(async move {
2072 match std::fs::metadata(&worktree_path) {
2073 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2074 return Ok(None);
2075 }
2076 Err(error) => {
2077 return Err(error).with_context(|| {
2078 format!("failed to stat {}", worktree_path.display())
2079 });
2080 }
2081 Ok(_) => {}
2082 }
2083 let git_dir = linked_worktree_git_dir(&worktree_path)?;
2084 let metadata = std::fs::metadata(&git_dir)
2085 .with_context(|| format!("failed to stat {}", git_dir.display()))?;
2086 let created_at = metadata.created().with_context(|| {
2087 format!("creation time unavailable for {}", git_dir.display())
2088 })?;
2089 Ok(Some(created_at))
2090 })
2091 .boxed()
2092 }
2093
2094 fn create_worktree(
2095 &self,
2096 target: CreateWorktreeTarget,
2097 path: PathBuf,
2098 ) -> BoxFuture<'_, Result<()>> {
2099 let git = self.git_binary();
2100 let mut args = vec![OsString::from("worktree"), OsString::from("add")];
2101
2102 match &target {
2103 CreateWorktreeTarget::ExistingBranch { branch_name } => {
2104 args.push(OsString::from("--"));
2105 args.push(OsString::from(path.as_os_str()));
2106 args.push(OsString::from(branch_name));
2107 }
2108 CreateWorktreeTarget::NewBranch {
2109 branch_name,
2110 base_sha: start_point,
2111 } => {
2112 args.push(OsString::from("-b"));
2113 args.push(OsString::from(branch_name));
2114 args.push(OsString::from("--"));
2115 args.push(OsString::from(path.as_os_str()));
2116 args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
2117 }
2118 CreateWorktreeTarget::Detached {
2119 base_sha: start_point,
2120 } => {
2121 args.push(OsString::from("--detach"));
2122 args.push(OsString::from("--"));
2123 args.push(OsString::from(path.as_os_str()));
2124 args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
2125 }
2126 }
2127
2128 self.executor
2129 .spawn(async move {
2130 std::fs::create_dir_all(path.parent().unwrap_or(&path))?;
2131 let output = git.build_command(&args).output().await?;
2132 if output.status.success() {
2133 Ok(())
2134 } else {
2135 let stderr = String::from_utf8_lossy(&output.stderr);
2136 anyhow::bail!("git worktree add failed: {stderr}");
2137 }
2138 })
2139 .boxed()
2140 }
2141
2142 fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> {
2143 let git = self.git_binary();
2144
2145 self.executor
2146 .spawn(async move {
2147 let mut args: Vec<OsString> = vec!["worktree".into(), "remove".into()];
2148 if force {
2149 args.push("--force".into());
2150 }
2151 args.push("--".into());
2152 args.push(path.as_os_str().into());
2153 git.run(&args).await?;
2154 anyhow::Ok(())
2155 })
2156 .boxed()
2157 }
2158
2159 fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> {
2160 let git = self.git_binary();
2161
2162 self.executor
2163 .spawn(async move {
2164 let args: Vec<OsString> = vec![
2165 "worktree".into(),
2166 "move".into(),
2167 "--".into(),
2168 old_path.as_os_str().into(),
2169 new_path.as_os_str().into(),
2170 ];
2171 git.run(&args).await?;
2172 anyhow::Ok(())
2173 })
2174 .boxed()
2175 }
2176
2177 fn checkout_branch_in_worktree(
2178 &self,
2179 branch_name: String,
2180 worktree_path: PathBuf,
2181 create: bool,
2182 ) -> BoxFuture<'_, Result<()>> {
2183 let git_binary = GitBinary::new(
2184 self.any_git_binary_path.clone(),
2185 worktree_path,
2186 self.path(),
2187 self.executor.clone(),
2188 self.is_trusted(),
2189 );
2190
2191 self.executor
2192 .spawn(async move {
2193 if create {
2194 git_binary.run(&["checkout", "-b", &branch_name]).await?;
2195 } else {
2196 git_binary.run(&["checkout", &branch_name]).await?;
2197 }
2198 anyhow::Ok(())
2199 })
2200 .boxed()
2201 }
2202
2203 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
2204 let git_binary = self.git_binary_in_worktree();
2205 self.executor
2206 .spawn(async move {
2207 let git_binary = git_binary?;
2208 let local_ref = format!("refs/heads/{name}");
2209 if git_binary
2210 .run(&["show-ref", "--verify", "--quiet", &local_ref])
2211 .await
2212 .is_ok()
2213 {
2214 git_binary.run(&["checkout", &name]).await?;
2215 return anyhow::Ok(());
2216 }
2217
2218 let remote_ref = format!("refs/remotes/{name}");
2219 if git_binary
2220 .run(&["show-ref", "--verify", "--quiet", &remote_ref])
2221 .await
2222 .is_ok()
2223 {
2224 let name = match git_binary.run(&["symbolic-ref", &remote_ref]).await {
2225 Ok(resolved) => resolved
2226 .strip_prefix("refs/remotes/")
2227 .map(str::to_owned)
2228 .unwrap_or(name),
2229 Err(_) => name,
2230 };
2231 let (_, branch_name) =
2232 name.split_once('/').context("Unexpected branch format")?;
2233 let local_branch_ref = format!("refs/heads/{branch_name}");
2234 if git_binary
2235 .run(&["show-ref", "--verify", "--quiet", &local_branch_ref])
2236 .await
2237 .is_ok()
2238 {
2239 git_binary
2240 .run(&["branch", "--set-upstream-to", &name, branch_name])
2241 .await?;
2242 } else {
2243 git_binary
2244 .run(&["branch", "--track", branch_name, &name])
2245 .await?;
2246 }
2247
2248 git_binary.run(&["checkout", branch_name]).await?;
2249 return anyhow::Ok(());
2250 }
2251
2252 anyhow::bail!("Branch '{}' not found", name);
2253 })
2254 .boxed()
2255 }
2256
2257 fn create_branch(
2258 &self,
2259 name: String,
2260 base_branch: Option<String>,
2261 ) -> BoxFuture<'_, Result<()>> {
2262 let git_binary = self.git_binary_in_worktree();
2263
2264 self.executor
2265 .spawn(async move {
2266 let git_binary = git_binary?;
2267 let mut args = vec!["switch", "-c", &name];
2268 let base_branch_str;
2269 if let Some(ref base) = base_branch {
2270 base_branch_str = base.clone();
2271 args.push(&base_branch_str);
2272 }
2273
2274 git_binary.run(&args).await?;
2275 anyhow::Ok(())
2276 })
2277 .boxed()
2278 }
2279
2280 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
2281 let git_binary = self.git_binary_in_worktree();
2282
2283 self.executor
2284 .spawn(async move {
2285 let git_binary = git_binary?;
2286 git_binary
2287 .run(&["branch", "-m", &branch, &new_name])
2288 .await?;
2289 anyhow::Ok(())
2290 })
2291 .boxed()
2292 }
2293
2294 fn delete_branch(
2295 &self,
2296 is_remote: bool,
2297 name: String,
2298 force: bool,
2299 ) -> BoxFuture<'_, Result<()>> {
2300 let git_binary = self.git_binary_in_worktree();
2301
2302 self.executor
2303 .spawn(async move {
2304 let git_binary = git_binary?;
2305 let flag = delete_branch_flag(is_remote, force);
2306 git_binary.run(&["branch", flag, &name]).await?;
2307 anyhow::Ok(())
2308 })
2309 .boxed()
2310 }
2311
2312 fn blame(
2313 &self,
2314 path: RepoPath,
2315 content: Rope,
2316 line_ending: LineEnding,
2317 ) -> BoxFuture<'_, Result<crate::blame::Blame>> {
2318 let git = self.git_binary_in_worktree();
2319
2320 self.executor
2321 .spawn(async move {
2322 let git = git?;
2323 crate::blame::Blame::for_path(&git, &path, &content, line_ending).await
2324 })
2325 .boxed()
2326 }
2327
2328 fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
2329 let git = self.git_binary_in_worktree();
2330 self.executor
2331 .spawn(async move {
2332 let git = git?;
2333 let output = match diff {
2334 DiffType::HeadToIndex => {
2335 git.build_command(&["diff", "--staged"]).output().await?
2336 }
2337 DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?,
2338 DiffType::MergeBase { base_ref } => {
2339 git.build_command(&["diff", "--merge-base", base_ref.as_ref()])
2340 .output()
2341 .await?
2342 }
2343 };
2344
2345 anyhow::ensure!(
2346 output.status.success(),
2347 "Failed to run git diff:\n{}",
2348 String::from_utf8_lossy(&output.stderr)
2349 );
2350 Ok(String::from_utf8_lossy(&output.stdout).to_string())
2351 })
2352 .boxed()
2353 }
2354
2355 fn diff_stat(
2356 &self,
2357 diff: DiffStatType,
2358 path_prefixes: &[RepoPath],
2359 ) -> BoxFuture<'static, Result<crate::status::GitDiffStat>> {
2360 let path_prefixes = path_prefixes.to_vec();
2361 let git_binary = self.git_binary_in_worktree();
2362
2363 self.executor
2364 .spawn(async move {
2365 let git_binary = git_binary?;
2366 let mut args: Vec<String> =
2367 vec!["diff".into(), "--numstat".into(), "--no-renames".into()];
2368 match diff {
2369 DiffStatType::HeadToIndex => args.extend(["--cached".into(), "HEAD".into()]),
2370 DiffStatType::HeadToWorktree => args.push("HEAD".into()),
2371 DiffStatType::IndexToWorktree => {}
2372 }
2373 if !path_prefixes.is_empty() {
2374 args.push("--".into());
2375 args.extend(
2376 path_prefixes
2377 .iter()
2378 .map(|p| p.as_std_path().to_string_lossy().into_owned()),
2379 );
2380 }
2381 let output = git_binary.run(&args).await?;
2382 Ok(crate::status::parse_numstat(&output))
2383 })
2384 .boxed()
2385 }
2386
2387 fn stage_paths(
2388 &self,
2389 paths: Vec<RepoPath>,
2390 env: Arc<HashMap<String, String>>,
2391 ) -> BoxFuture<'_, Result<()>> {
2392 let git = self.git_binary_in_worktree();
2393 self.executor
2394 .spawn(async move {
2395 let git = git?;
2396 if !paths.is_empty() {
2397 let output = git
2398 .build_command(&["update-index", "--add", "--remove", "--"])
2399 .envs(env.iter())
2400 .args(paths.iter().map(|p| p.as_unix_str()))
2401 .output()
2402 .await?;
2403 anyhow::ensure!(
2404 output.status.success(),
2405 "Failed to stage paths:\n{}",
2406 String::from_utf8_lossy(&output.stderr),
2407 );
2408 }
2409 Ok(())
2410 })
2411 .boxed()
2412 }
2413
2414 fn unstage_paths(
2415 &self,
2416 paths: Vec<RepoPath>,
2417 env: Arc<HashMap<String, String>>,
2418 ) -> BoxFuture<'_, Result<()>> {
2419 let git = self.git_binary_in_worktree();
2420
2421 self.executor
2422 .spawn(async move {
2423 let git = git?;
2424 if !paths.is_empty() {
2425 let output = git
2426 .build_command(&["reset", "--quiet", "--"])
2427 .envs(env.iter())
2428 .args(paths.iter().map(|p| p.as_std_path()))
2429 .output()
2430 .await?;
2431
2432 anyhow::ensure!(
2433 output.status.success(),
2434 "Failed to unstage:\n{}",
2435 String::from_utf8_lossy(&output.stderr),
2436 );
2437 }
2438 Ok(())
2439 })
2440 .boxed()
2441 }
2442
2443 fn stash_paths(
2444 &self,
2445 paths: Vec<RepoPath>,
2446 env: Arc<HashMap<String, String>>,
2447 ) -> BoxFuture<'_, Result<()>> {
2448 let git = self.git_binary_in_worktree();
2449 self.executor
2450 .spawn(async move {
2451 let git = git?;
2452 let output = git
2453 .build_command(&["stash", "push", "--quiet", "--include-untracked", "--"])
2454 .envs(env.iter())
2455 .args(paths.iter().map(|p| p.as_unix_str()))
2456 .output()
2457 .await?;
2458
2459 anyhow::ensure!(
2460 output.status.success(),
2461 "Failed to stash:\n{}",
2462 String::from_utf8_lossy(&output.stderr)
2463 );
2464 Ok(())
2465 })
2466 .boxed()
2467 }
2468
2469 fn stash_pop(
2470 &self,
2471 index: Option<usize>,
2472 env: Arc<HashMap<String, String>>,
2473 ) -> BoxFuture<'_, Result<()>> {
2474 let git = self.git_binary_in_worktree();
2475 self.executor
2476 .spawn(async move {
2477 let git = git?;
2478 let mut args = vec!["stash".to_string(), "pop".to_string()];
2479 if let Some(index) = index {
2480 args.push(format!("stash@{{{}}}", index));
2481 }
2482 let output = git.build_command(&args).envs(env.iter()).output().await?;
2483
2484 anyhow::ensure!(
2485 output.status.success(),
2486 "Failed to stash pop:\n{}",
2487 String::from_utf8_lossy(&output.stderr)
2488 );
2489 Ok(())
2490 })
2491 .boxed()
2492 }
2493
2494 fn stash_apply(
2495 &self,
2496 index: Option<usize>,
2497 env: Arc<HashMap<String, String>>,
2498 ) -> BoxFuture<'_, Result<()>> {
2499 let git = self.git_binary_in_worktree();
2500 self.executor
2501 .spawn(async move {
2502 let git = git?;
2503 let mut args = vec!["stash".to_string(), "apply".to_string()];
2504 if let Some(index) = index {
2505 args.push(format!("stash@{{{}}}", index));
2506 }
2507 let output = git.build_command(&args).envs(env.iter()).output().await?;
2508
2509 anyhow::ensure!(
2510 output.status.success(),
2511 "Failed to apply stash:\n{}",
2512 String::from_utf8_lossy(&output.stderr)
2513 );
2514 Ok(())
2515 })
2516 .boxed()
2517 }
2518
2519 fn stash_drop(
2520 &self,
2521 index: Option<usize>,
2522 env: Arc<HashMap<String, String>>,
2523 ) -> BoxFuture<'_, Result<()>> {
2524 let git = self.git_binary_in_worktree();
2525 self.executor
2526 .spawn(async move {
2527 let git = git?;
2528 let mut args = vec!["stash".to_string(), "drop".to_string()];
2529 if let Some(index) = index {
2530 args.push(format!("stash@{{{}}}", index));
2531 }
2532 let output = git.build_command(&args).envs(env.iter()).output().await?;
2533
2534 anyhow::ensure!(
2535 output.status.success(),
2536 "Failed to stash drop:\n{}",
2537 String::from_utf8_lossy(&output.stderr)
2538 );
2539 Ok(())
2540 })
2541 .boxed()
2542 }
2543
2544 fn commit(
2545 &self,
2546 message: SharedString,
2547 name_and_email: Option<(SharedString, SharedString)>,
2548 options: CommitOptions,
2549 ask_pass: AskPassDelegate,
2550 env: Arc<HashMap<String, String>>,
2551 ) -> BoxFuture<'_, Result<()>> {
2552 let git = self.git_binary_in_worktree();
2553 let executor = self.executor.clone();
2554 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2555 // which we want to block on.
2556 async move {
2557 let git = git?;
2558 let mut cmd = git.build_command(&["commit", "--quiet", "-m"]);
2559 cmd.envs(env.iter())
2560 .arg(&message.to_string())
2561 .arg("--cleanup=strip")
2562 .stdout(Stdio::piped())
2563 .stderr(Stdio::piped());
2564
2565 if options.amend {
2566 cmd.arg("--amend");
2567 }
2568
2569 if options.signoff {
2570 cmd.arg("--signoff");
2571 }
2572
2573 if options.allow_empty {
2574 cmd.arg("--allow-empty");
2575 }
2576
2577 if options.no_verify {
2578 cmd.arg("--no-verify");
2579 }
2580
2581 if let Some((name, email)) = name_and_email {
2582 cmd.arg("--author").arg(&format!("{name} <{email}>"));
2583 }
2584
2585 run_git_command(env, ask_pass, cmd, executor).await?;
2586
2587 Ok(())
2588 }
2589 .boxed()
2590 }
2591
2592 fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
2593 self.edit_ref(RefEdit::Update { ref_name, commit })
2594 }
2595
2596 fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
2597 self.edit_ref(RefEdit::Delete { ref_name })
2598 }
2599
2600 fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
2601 let git = self.git_binary();
2602 self.executor
2603 .spawn(async move {
2604 let args: Vec<OsString> = vec!["worktree".into(), "repair".into()];
2605 git.run(&args).await?;
2606 Ok(())
2607 })
2608 .boxed()
2609 }
2610
2611 fn push(
2612 &self,
2613 branch_name: String,
2614 remote_branch_name: String,
2615 remote_name: String,
2616 options: Option<PushOptions>,
2617 ask_pass: AskPassDelegate,
2618 env: Arc<HashMap<String, String>>,
2619 cx: AsyncApp,
2620 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2621 let working_directory = self.command_directory();
2622 let git_directory = self.path();
2623 let executor = cx.background_executor().clone();
2624 let git_binary_path = self.system_git_binary_path.clone();
2625 let is_trusted = self.is_trusted();
2626 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2627 // which we want to block on.
2628 async move {
2629 let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
2630 let git = GitBinary::new(
2631 git_binary_path,
2632 working_directory,
2633 git_directory,
2634 executor.clone(),
2635 is_trusted,
2636 );
2637 let mut command = git.build_command(&["push"]);
2638 command
2639 .envs(env.iter())
2640 .args(options.map(|option| match option {
2641 PushOptions::SetUpstream => "--set-upstream",
2642 PushOptions::Force => "--force-with-lease",
2643 }))
2644 .arg(remote_name)
2645 .arg(format!("{}:{}", branch_name, remote_branch_name))
2646 .stdin(Stdio::null())
2647 .stdout(Stdio::piped())
2648 .stderr(Stdio::piped());
2649
2650 run_git_command(env, ask_pass, command, executor).await
2651 }
2652 .boxed()
2653 }
2654
2655 fn pull(
2656 &self,
2657 branch_name: Option<String>,
2658 remote_name: String,
2659 rebase: bool,
2660 ask_pass: AskPassDelegate,
2661 env: Arc<HashMap<String, String>>,
2662 cx: AsyncApp,
2663 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2664 let working_directory = self.command_directory();
2665 let git_directory = self.path();
2666 let executor = cx.background_executor().clone();
2667 let git_binary_path = self.system_git_binary_path.clone();
2668 let is_trusted = self.is_trusted();
2669 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2670 // which we want to block on.
2671 async move {
2672 let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
2673 let git = GitBinary::new(
2674 git_binary_path,
2675 working_directory,
2676 git_directory,
2677 executor.clone(),
2678 is_trusted,
2679 );
2680 let mut command = git.build_command(&["pull"]);
2681 command.envs(env.iter());
2682
2683 if rebase {
2684 command.arg("--rebase");
2685 }
2686
2687 command
2688 .arg(remote_name)
2689 .args(branch_name)
2690 .stdout(Stdio::piped())
2691 .stderr(Stdio::piped());
2692
2693 run_git_command(env, ask_pass, command, executor).await
2694 }
2695 .boxed()
2696 }
2697
2698 fn fetch(
2699 &self,
2700 fetch_options: FetchOptions,
2701 ask_pass: AskPassDelegate,
2702 env: Arc<HashMap<String, String>>,
2703 cx: AsyncApp,
2704 ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2705 let working_directory = self.command_directory();
2706 let git_directory = self.path();
2707 let remote_name = format!("{}", fetch_options);
2708 let git_binary_path = self.system_git_binary_path.clone();
2709 let executor = cx.background_executor().clone();
2710 let is_trusted = self.is_trusted();
2711 // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2712 // which we want to block on.
2713 async move {
2714 let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
2715 let git = GitBinary::new(
2716 git_binary_path,
2717 working_directory,
2718 git_directory,
2719 executor.clone(),
2720 is_trusted,
2721 );
2722 let mut command = git.build_command(&["fetch", &remote_name]);
2723 command
2724 .envs(env.iter())
2725 .stdout(Stdio::piped())
2726 .stderr(Stdio::piped());
2727
2728 run_git_command(env, ask_pass, command, executor).await
2729 }
2730 .boxed()
2731 }
2732
2733 fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2734 let git = self.git_binary();
2735 self.executor
2736 .spawn(async move {
2737 let output = git
2738 .build_command(&["rev-parse", "--abbrev-ref"])
2739 .arg(format!("{branch}@{{push}}"))
2740 .output()
2741 .await?;
2742 if !output.status.success() {
2743 return Ok(None);
2744 }
2745 let remote_name = String::from_utf8_lossy(&output.stdout)
2746 .split('/')
2747 .next()
2748 .map(|name| Remote {
2749 name: name.trim().to_string().into(),
2750 });
2751
2752 Ok(remote_name)
2753 })
2754 .boxed()
2755 }
2756
2757 fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2758 let git = self.git_binary();
2759 self.executor
2760 .spawn(async move {
2761 let output = git
2762 .build_command(&["config", "--get"])
2763 .arg(format!("branch.{branch}.remote"))
2764 .output()
2765 .await?;
2766 if !output.status.success() {
2767 return Ok(None);
2768 }
2769
2770 let remote_name = String::from_utf8_lossy(&output.stdout);
2771 return Ok(Some(Remote {
2772 name: remote_name.trim().to_string().into(),
2773 }));
2774 })
2775 .boxed()
2776 }
2777
2778 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
2779 let git = self.git_binary();
2780 self.executor
2781 .spawn(async move {
2782 let output = git.build_command(&["remote", "-v"]).output().await?;
2783
2784 anyhow::ensure!(
2785 output.status.success(),
2786 "Failed to get all remotes:\n{}",
2787 String::from_utf8_lossy(&output.stderr)
2788 );
2789 let remote_names: HashSet<Remote> = String::from_utf8_lossy(&output.stdout)
2790 .lines()
2791 .filter(|line| !line.is_empty())
2792 .filter_map(|line| {
2793 let mut split_line = line.split_whitespace();
2794 let remote_name = split_line.next()?;
2795
2796 Some(Remote {
2797 name: remote_name.trim().to_string().into(),
2798 })
2799 })
2800 .collect();
2801
2802 Ok(remote_names.into_iter().collect())
2803 })
2804 .boxed()
2805 }
2806
2807 fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
2808 let git_binary = self.git_binary();
2809 self.executor
2810 .spawn(async move {
2811 git_binary.run(&["remote", "remove", &name]).await?;
2812 Ok(())
2813 })
2814 .boxed()
2815 }
2816
2817 fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
2818 let git_binary = self.git_binary();
2819 self.executor
2820 .spawn(async move {
2821 git_binary.run(&["remote", "add", &name, &url]).await?;
2822 Ok(())
2823 })
2824 .boxed()
2825 }
2826
2827 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
2828 let git = self.git_binary_in_worktree();
2829 self.executor
2830 .spawn(async move {
2831 // This command outputs a list of remote tracking refs, e.g.:
2832 // refs/remotes/origin/HEAD
2833 // refs/remotes/origin/main
2834 let Ok(output) = git?
2835 .run(&[
2836 "for-each-ref",
2837 "--format=%(refname)",
2838 "--contains",
2839 "HEAD",
2840 "refs/remotes/",
2841 ])
2842 .await
2843 else {
2844 return Ok(Vec::new());
2845 };
2846
2847 Ok(output
2848 .lines()
2849 .map(|line| line.trim())
2850 .filter(|line| !line.ends_with("/HEAD"))
2851 .filter_map(|line| line.strip_prefix("refs/remotes/"))
2852 .map(SharedString::from)
2853 .collect())
2854 })
2855 .boxed()
2856 }
2857
2858 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
2859 let git = self.git_binary_in_worktree();
2860 self.executor
2861 .spawn(async move {
2862 let mut git = git?.envs(checkpoint_author_envs());
2863 git.with_temp_index(async |git| {
2864 let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
2865 let mut excludes = exclude_files(git).await?;
2866
2867 git.run(&["add", "--all"]).await?;
2868 let tree = git.run(&["write-tree"]).await?;
2869 let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
2870 git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
2871 .await?
2872 } else {
2873 git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
2874 };
2875
2876 excludes.restore_original().await?;
2877
2878 Ok(GitRepositoryCheckpoint {
2879 commit_sha: checkpoint_sha.parse()?,
2880 })
2881 })
2882 .await
2883 })
2884 .boxed()
2885 }
2886
2887 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
2888 let git = self.git_binary_in_worktree();
2889 self.executor
2890 .spawn(async move {
2891 let git = git?;
2892 git.run(&[
2893 "restore",
2894 "--source",
2895 &checkpoint.commit_sha.to_string(),
2896 "--worktree",
2897 ".",
2898 ])
2899 .await?;
2900
2901 // TODO: We don't track binary and large files anymore,
2902 // so the following call would delete them.
2903 // Implement an alternative way to track files added by agent.
2904 //
2905 // git.with_temp_index(async move |git| {
2906 // git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
2907 // .await?;
2908 // git.run(&["clean", "-d", "--force"]).await
2909 // })
2910 // .await?;
2911
2912 Ok(())
2913 })
2914 .boxed()
2915 }
2916
2917 fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> {
2918 let git = self.git_binary_in_worktree();
2919 self.executor
2920 .spawn(async move {
2921 let mut git = git?.envs(checkpoint_author_envs());
2922 let head_sha = git
2923 .run(&["rev-parse", "HEAD"])
2924 .await
2925 .context("failed to read HEAD")?;
2926
2927 // Capture the staged state: write-tree reads the current index
2928 let staged_tree = git
2929 .run(&["write-tree"])
2930 .await
2931 .context("failed to write staged tree")?;
2932 let staged_sha = git
2933 .run(&[
2934 "commit-tree",
2935 &staged_tree,
2936 "-p",
2937 &head_sha,
2938 "-m",
2939 "WIP staged",
2940 ])
2941 .await
2942 .context("failed to create staged commit")?;
2943
2944 // Capture the full state (staged + unstaged + untracked) using
2945 // a temporary index so we don't disturb the real one.
2946 let unstaged_sha = git
2947 .with_temp_index(async |git| {
2948 git.run(&["add", "--all"]).await?;
2949 let full_tree = git.run(&["write-tree"]).await?;
2950 let sha = git
2951 .run(&[
2952 "commit-tree",
2953 &full_tree,
2954 "-p",
2955 &staged_sha,
2956 "-m",
2957 "WIP unstaged",
2958 ])
2959 .await?;
2960 Ok(sha)
2961 })
2962 .await
2963 .context("failed to create unstaged commit")?;
2964
2965 Ok((staged_sha, unstaged_sha))
2966 })
2967 .boxed()
2968 }
2969
2970 fn restore_archive_checkpoint(
2971 &self,
2972 staged_sha: String,
2973 unstaged_sha: String,
2974 ) -> BoxFuture<'_, Result<()>> {
2975 let git = self.git_binary_in_worktree();
2976 self.executor
2977 .spawn(async move {
2978 let git = git?;
2979 // First, set the index AND working tree to match the unstaged
2980 // tree. --reset -u computes a tree-level diff between the
2981 // current index and unstaged_sha's tree and applies additions,
2982 // modifications, and deletions to the working directory.
2983 git.run(&["read-tree", "--reset", "-u", &unstaged_sha])
2984 .await
2985 .context("failed to restore working directory from unstaged commit")?;
2986
2987 // Then replace just the index with the staged tree. Without -u
2988 // this doesn't touch the working directory, so the result is:
2989 // working tree = unstaged state, index = staged state.
2990 git.run(&["read-tree", &staged_sha])
2991 .await
2992 .context("failed to restore index from staged commit")?;
2993
2994 Ok(())
2995 })
2996 .boxed()
2997 }
2998
2999 fn compare_checkpoints(
3000 &self,
3001 left: GitRepositoryCheckpoint,
3002 right: GitRepositoryCheckpoint,
3003 ) -> BoxFuture<'_, Result<bool>> {
3004 let git = self.git_binary_in_worktree();
3005 self.executor
3006 .spawn(async move {
3007 let git = git?;
3008 let result = git
3009 .run(&[
3010 "diff-tree",
3011 "--quiet",
3012 &left.commit_sha.to_string(),
3013 &right.commit_sha.to_string(),
3014 ])
3015 .await;
3016 match result {
3017 Ok(_) => Ok(true),
3018 Err(error) => {
3019 if let Some(GitBinaryCommandError { status, .. }) =
3020 error.downcast_ref::<GitBinaryCommandError>()
3021 && status.code() == Some(1)
3022 {
3023 return Ok(false);
3024 }
3025
3026 Err(error)
3027 }
3028 }
3029 })
3030 .boxed()
3031 }
3032
3033 fn diff_checkpoints(
3034 &self,
3035 base_checkpoint: GitRepositoryCheckpoint,
3036 target_checkpoint: GitRepositoryCheckpoint,
3037 ) -> BoxFuture<'_, Result<String>> {
3038 let git = self.git_binary_in_worktree();
3039 self.executor
3040 .spawn(async move {
3041 let git = git?;
3042 git.run(&[
3043 "diff",
3044 "--find-renames",
3045 "--patch",
3046 &base_checkpoint.commit_sha.to_string(),
3047 &target_checkpoint.commit_sha.to_string(),
3048 ])
3049 .await
3050 })
3051 .boxed()
3052 }
3053
3054 fn default_branch(
3055 &self,
3056 include_remote_name: bool,
3057 ) -> BoxFuture<'_, Result<Option<SharedString>>> {
3058 let git = self.git_binary();
3059 self.executor
3060 .spawn(async move {
3061 let output = git
3062 .run(&[
3063 "for-each-ref",
3064 "--format=%(refname)\t%(symref)",
3065 "refs/remotes/upstream/HEAD",
3066 "refs/remotes/origin/HEAD",
3067 "refs/heads/",
3068 ])
3069 .await
3070 .unwrap_or_default();
3071 let refs: HashMap<&str, &str> = output
3072 .lines()
3073 .filter_map(|line| line.split_once('\t'))
3074 .collect();
3075
3076 if let Some(target) = refs.get("refs/remotes/upstream/HEAD") {
3077 let strip_prefix = if include_remote_name {
3078 "refs/remotes/"
3079 } else {
3080 "refs/remotes/upstream/"
3081 };
3082 if let Some(branch) = target.strip_prefix(strip_prefix) {
3083 return Ok(Some(branch.into()));
3084 }
3085 }
3086
3087 if let Some(target) = refs.get("refs/remotes/origin/HEAD") {
3088 let strip_prefix = if include_remote_name {
3089 "refs/remotes/"
3090 } else {
3091 "refs/remotes/origin/"
3092 };
3093 if let Some(branch) = target.strip_prefix(strip_prefix) {
3094 return Ok(Some(branch.into()));
3095 }
3096 }
3097
3098 let local_branch_exists =
3099 |branch: &str| refs.contains_key(format!("refs/heads/{branch}").as_str());
3100
3101 if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
3102 if local_branch_exists(&default_branch) {
3103 return Ok(Some(default_branch.into()));
3104 }
3105 }
3106
3107 if local_branch_exists("main") {
3108 return Ok(Some("main".into()));
3109 }
3110
3111 if local_branch_exists("master") {
3112 return Ok(Some("master".into()));
3113 }
3114
3115 Ok(None)
3116 })
3117 .boxed()
3118 }
3119
3120 fn run_hook(
3121 &self,
3122 hook: RunHook,
3123 env: Arc<HashMap<String, String>>,
3124 ) -> BoxFuture<'_, Result<()>> {
3125 let git_binary = self.git_binary_in_worktree();
3126 let git_dir = self.git_dir.clone();
3127 let help_output = self.any_git_binary_help_output();
3128
3129 // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
3130 async move {
3131 let git_binary = git_binary?;
3132 let working_directory = git_binary.working_directory.clone();
3133 if !help_output
3134 .await
3135 .lines()
3136 .any(|line| line.trim().starts_with("hook "))
3137 {
3138 let hook_abs_path = git_dir.join("hooks").join(hook.as_str());
3139 if hook_abs_path.is_file() && git_binary.is_trusted {
3140 #[allow(clippy::disallowed_methods)]
3141 let output = new_command(&hook_abs_path)
3142 .envs(env.iter())
3143 .current_dir(&working_directory)
3144 .output()
3145 .await?;
3146
3147 if !output.status.success() {
3148 return Err(GitBinaryCommandError {
3149 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3150 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3151 status: output.status,
3152 }
3153 .into());
3154 }
3155 }
3156
3157 return Ok(());
3158 }
3159
3160 if git_binary.is_trusted {
3161 let git_binary = git_binary.envs(HashMap::clone(&env));
3162 git_binary
3163 .run(&["hook", "run", "--ignore-missing", hook.as_str()])
3164 .await?;
3165 }
3166 Ok(())
3167 }
3168 .boxed()
3169 }
3170
3171 fn initial_graph_data(
3172 &self,
3173 log_source: LogSource,
3174 log_order: LogOrder,
3175 request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
3176 ) -> BoxFuture<'_, Result<()>> {
3177 let git = self.git_binary();
3178
3179 async move {
3180 let log_source_args = log_source.get_args();
3181 let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()];
3182 git_log_command.extend(log_source_args.iter().map(|arg| arg.as_ref()));
3183 let mut command = git.build_command(&git_log_command);
3184 command.stdout(Stdio::piped());
3185 command.stderr(Stdio::piped());
3186
3187 let mut child = command.spawn()?;
3188 let stdout = child.stdout.take().context("failed to get stdout")?;
3189 let stderr = child.stderr.take().context("failed to get stderr")?;
3190 let mut reader = BufReader::new(stdout);
3191
3192 let mut line_buffer = String::new();
3193 let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
3194
3195 loop {
3196 line_buffer.clear();
3197 let bytes_read = reader.read_line(&mut line_buffer).await?;
3198
3199 if bytes_read == 0 {
3200 if !lines.is_empty() {
3201 let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
3202 if request_tx.send(commits).await.is_err() {
3203 log::warn!(
3204 "initial_graph_data: receiver dropped while sending commits"
3205 );
3206 }
3207 }
3208 break;
3209 }
3210
3211 let line = line_buffer.trim_end_matches('\n').to_string();
3212 lines.push(line);
3213
3214 if lines.len() >= GRAPH_CHUNK_SIZE {
3215 let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
3216 if request_tx.send(commits).await.is_err() {
3217 log::warn!("initial_graph_data: receiver dropped while streaming commits");
3218 break;
3219 }
3220 lines.clear();
3221 }
3222 }
3223
3224 let status = child.status().await?;
3225 if !status.success() {
3226 let mut stderr_output = String::new();
3227 BufReader::new(stderr)
3228 .read_to_string(&mut stderr_output)
3229 .await
3230 .log_err();
3231
3232 if stderr_output.is_empty() {
3233 anyhow::bail!("git log command failed with {}", status);
3234 } else {
3235 anyhow::bail!("git log command failed with {}: {}", status, stderr_output);
3236 }
3237 }
3238 Ok(())
3239 }
3240 .boxed()
3241 }
3242
3243 fn search_commits(
3244 &self,
3245 log_source: LogSource,
3246 search_args: SearchCommitArgs,
3247 request_tx: Sender<Oid>,
3248 ) -> BoxFuture<'_, Result<()>> {
3249 let git = self.git_binary();
3250
3251 async move {
3252 let log_source_args = log_source.get_args();
3253 let mut args = vec!["log", SEARCH_COMMIT_FORMAT];
3254 let hash_query = commit_hash_search_query(search_args.query.as_str())
3255 .map(|query| query.to_ascii_lowercase());
3256
3257 if hash_query.is_none() {
3258 args.push("--fixed-strings");
3259
3260 if !search_args.case_sensitive {
3261 args.push("--regexp-ignore-case");
3262 }
3263
3264 args.push("--grep");
3265 args.push(search_args.query.as_str());
3266 }
3267
3268 args.extend(log_source_args.iter().map(|arg| arg.as_ref()));
3269 let mut command = git.build_command(&args);
3270 command.stdout(Stdio::piped());
3271 command.stderr(Stdio::null());
3272
3273 let mut child = command.spawn()?;
3274 let stdout = child.stdout.take().context("failed to get stdout")?;
3275 let mut reader = BufReader::new(stdout);
3276
3277 let mut line_buffer = String::new();
3278
3279 loop {
3280 line_buffer.clear();
3281 let bytes_read = reader.read_line(&mut line_buffer).await?;
3282
3283 if bytes_read == 0 {
3284 break;
3285 }
3286
3287 let sha = line_buffer.trim_end_matches('\n');
3288 if let Some(hash_query) = hash_query.as_ref()
3289 && !sha.to_ascii_lowercase().starts_with(hash_query)
3290 {
3291 continue;
3292 }
3293
3294 if let Ok(oid) = Oid::from_str(sha)
3295 && request_tx.send(oid).await.is_err()
3296 {
3297 break;
3298 }
3299 }
3300
3301 child.status().await?;
3302 Ok(())
3303 }
3304 .boxed()
3305 }
3306
3307 fn file_history_changed_files(
3308 &self,
3309 paths: Vec<RepoPath>,
3310 commit_limit: usize,
3311 ) -> BoxFuture<'_, Result<Vec<FileHistoryChangedFileSets>>> {
3312 let git = self.git_binary();
3313
3314 async move {
3315 if paths.is_empty() {
3316 return Ok(Vec::new());
3317 }
3318
3319 if commit_limit == 0 {
3320 return Ok(vec![FileHistoryChangedFileSets::default(); paths.len()]);
3321 }
3322
3323 let max_count_arg = format!("--max-count={commit_limit}");
3324 let mut args = [
3325 "log",
3326 max_count_arg.as_str(),
3327 "--full-diff",
3328 "--no-renames",
3329 "--name-only",
3330 "-z",
3331 "--format=%x1e",
3332 "--",
3333 ]
3334 .map(OsString::from)
3335 .to_vec();
3336 args.extend(paths.iter().map(|path| OsString::from(path.as_unix_str())));
3337
3338 let output = git.build_command(&args).output().await?;
3339 anyhow::ensure!(
3340 output.status.success(),
3341 "git log failed:\n{}",
3342 String::from_utf8_lossy(&output.stderr)
3343 );
3344
3345 let stdout = String::from_utf8_lossy(&output.stdout);
3346 Ok(parse_file_history_changed_files_output(&stdout, &paths))
3347 }
3348 .boxed()
3349 }
3350
3351 fn commit_data_reader(&self) -> Result<CommitDataReader> {
3352 let git_binary = self.git_binary();
3353
3354 let (request_tx, request_rx) = async_channel::bounded::<CommitDataRequest>(64);
3355
3356 let task = self.executor.spawn(async move {
3357 if let Err(error) = run_commit_data_reader(git_binary, request_rx).await {
3358 log::error!("commit data reader failed: {error:?}");
3359 }
3360 });
3361
3362 Ok(CommitDataReader {
3363 request_tx,
3364 _task: task,
3365 })
3366 }
3367
3368 fn set_trusted(&self, trusted: bool) {
3369 self.is_trusted
3370 .store(trusted, std::sync::atomic::Ordering::Release);
3371 }
3372
3373 fn is_trusted(&self) -> bool {
3374 self.is_trusted.load(std::sync::atomic::Ordering::Acquire)
3375 }
3376}
3377
3378async fn run_commit_data_reader(
3379 git: GitBinary,
3380 request_rx: async_channel::Receiver<CommitDataRequest>,
3381) -> Result<()> {
3382 let mut process = git
3383 .build_command(&["cat-file", "--batch"])
3384 .stdin(Stdio::piped())
3385 .stdout(Stdio::piped())
3386 .stderr(Stdio::piped())
3387 .spawn()
3388 .context("starting git cat-file --batch process")?;
3389
3390 let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
3391 let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
3392
3393 const MAX_BATCH_SIZE: usize = 64;
3394
3395 while let Ok(first_request) = request_rx.recv().await {
3396 let mut pending_requests = vec![first_request];
3397
3398 while pending_requests.len() < MAX_BATCH_SIZE {
3399 match request_rx.try_recv() {
3400 Ok(request) => pending_requests.push(request),
3401 Err(_) => break,
3402 }
3403 }
3404
3405 for request in &pending_requests {
3406 stdin.write_all(request.sha.to_string().as_bytes()).await?;
3407 stdin.write_all(b"\n").await?;
3408 }
3409 stdin.flush().await?;
3410
3411 for request in pending_requests {
3412 let result = read_single_commit_response(&mut stdout, &request.sha).await;
3413 request.response_tx.send(result).ok();
3414 }
3415 }
3416
3417 drop(stdin);
3418 process.kill().ok();
3419
3420 Ok(())
3421}
3422
3423async fn read_single_commit_response<R: smol::io::AsyncBufRead + Unpin>(
3424 stdout: &mut R,
3425 sha: &Oid,
3426) -> Result<CommitData> {
3427 let mut header_bytes = Vec::new();
3428 stdout.read_until(b'\n', &mut header_bytes).await?;
3429 let header_line = String::from_utf8_lossy(&header_bytes);
3430
3431 let parts: Vec<&str> = header_line.trim().split(' ').collect();
3432 if parts.len() < 3 {
3433 bail!("invalid cat-file header: {header_line}");
3434 }
3435
3436 let object_type = parts[1];
3437 if object_type == "missing" {
3438 bail!("object not found: {}", sha);
3439 }
3440
3441 if object_type != "commit" {
3442 bail!("expected commit object, got {object_type}");
3443 }
3444
3445 let size: usize = parts[2]
3446 .parse()
3447 .with_context(|| format!("invalid object size: {}", parts[2]))?;
3448
3449 let mut content = vec![0u8; size];
3450 stdout.read_exact(&mut content).await?;
3451
3452 let mut newline = [0u8; 1];
3453 stdout.read_exact(&mut newline).await?;
3454
3455 let content_str = String::from_utf8_lossy(&content);
3456 parse_cat_file_commit(*sha, &content_str)
3457 .ok_or_else(|| anyhow!("failed to parse commit {}", sha))
3458}
3459
3460fn parse_file_history_changed_files_output(
3461 output: &str,
3462 queried_paths: &[RepoPath],
3463) -> Vec<FileHistoryChangedFileSets> {
3464 let mut histories = vec![FileHistoryChangedFileSets::default(); queried_paths.len()];
3465
3466 for record in output.split('\x1e') {
3467 let changed_files = record
3468 .split('\0')
3469 .filter_map(|field| {
3470 let path = field.trim_start_matches('\n');
3471 if path.is_empty() {
3472 return None;
3473 }
3474 RepoPath::new(path).ok()
3475 })
3476 .collect::<std::collections::BTreeSet<_>>();
3477
3478 if changed_files.is_empty() {
3479 continue;
3480 }
3481
3482 let file_set = changed_files.iter().cloned().collect::<Vec<_>>();
3483 for (index, queried_path) in queried_paths.iter().enumerate() {
3484 if changed_files.contains(queried_path) {
3485 histories[index].file_sets.push(file_set.clone());
3486 }
3487 }
3488 }
3489
3490 histories
3491}
3492
3493fn parse_initial_graph_output<'a>(
3494 lines: impl Iterator<Item = &'a str>,
3495) -> Vec<Arc<InitialGraphCommitData>> {
3496 lines
3497 .filter(|line| !line.is_empty())
3498 .filter_map(|line| {
3499 // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
3500 let mut parts = line.split('\x00');
3501
3502 let sha = Oid::from_str(parts.next()?).ok()?;
3503 let parents_str = parts.next()?;
3504 let parents = parents_str
3505 .split_whitespace()
3506 .filter_map(|p| Oid::from_str(p).ok())
3507 .collect();
3508
3509 let ref_names_str = parts.next().unwrap_or("");
3510 let ref_names = if ref_names_str.is_empty() {
3511 Vec::new()
3512 } else {
3513 ref_names_str
3514 .split(", ")
3515 .map(|s| SharedString::from(s.to_string()))
3516 .collect()
3517 };
3518
3519 Some(Arc::new(InitialGraphCommitData {
3520 sha,
3521 parents,
3522 ref_names,
3523 }))
3524 })
3525 .collect()
3526}
3527
3528fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
3529 let mut args = vec![
3530 OsString::from("status"),
3531 OsString::from("--porcelain=v1"),
3532 OsString::from("--untracked-files=all"),
3533 OsString::from("--no-renames"),
3534 OsString::from("-z"),
3535 OsString::from("--"),
3536 ];
3537 args.extend(path_prefixes.iter().map(|path_prefix| {
3538 if path_prefix.is_empty() {
3539 Path::new(".").into()
3540 } else {
3541 path_prefix.as_std_path().into()
3542 }
3543 }));
3544 args
3545}
3546
3547/// Temporarily git-ignore commonly ignored files and files over 2MB
3548async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
3549 const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
3550 let mut excludes = git.with_exclude_overrides().await?;
3551 excludes
3552 .add_excludes(include_str!("./checkpoint.gitignore"))
3553 .await?;
3554
3555 let working_directory = git.working_directory.clone();
3556 let untracked_files = git.list_untracked_files().await?;
3557 let excluded_paths = untracked_files.into_iter().map(|path| {
3558 let working_directory = working_directory.clone();
3559 smol::spawn(async move {
3560 let full_path = working_directory.join(path.clone());
3561 match smol::fs::metadata(&full_path).await {
3562 Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
3563 Some(PathBuf::from("/").join(path.clone()))
3564 }
3565 _ => None,
3566 }
3567 })
3568 });
3569
3570 let excluded_paths = futures::future::join_all(excluded_paths).await;
3571 let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
3572
3573 if !excluded_paths.is_empty() {
3574 let exclude_patterns = excluded_paths
3575 .into_iter()
3576 .map(|path| path.to_string_lossy().into_owned())
3577 .collect::<Vec<_>>()
3578 .join("\n");
3579 excludes.add_excludes(&exclude_patterns).await?;
3580 }
3581
3582 Ok(excludes)
3583}
3584
3585pub(crate) struct GitBinary {
3586 git_binary_path: PathBuf,
3587 working_directory: PathBuf,
3588 git_directory: PathBuf,
3589 executor: BackgroundExecutor,
3590 index_file_path: Option<PathBuf>,
3591 envs: HashMap<String, String>,
3592 is_trusted: bool,
3593}
3594
3595impl GitBinary {
3596 pub(crate) fn new(
3597 git_binary_path: PathBuf,
3598 working_directory: PathBuf,
3599 git_directory: PathBuf,
3600 executor: BackgroundExecutor,
3601 is_trusted: bool,
3602 ) -> Self {
3603 Self {
3604 git_binary_path,
3605 working_directory,
3606 git_directory,
3607 executor,
3608 index_file_path: None,
3609 envs: HashMap::default(),
3610 is_trusted,
3611 }
3612 }
3613
3614 async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
3615 let status_output = self
3616 .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
3617 .await?;
3618
3619 let paths = status_output
3620 .split('\0')
3621 .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
3622 .map(|entry| PathBuf::from(&entry[3..]))
3623 .collect::<Vec<_>>();
3624 Ok(paths)
3625 }
3626
3627 fn envs(mut self, envs: HashMap<String, String>) -> Self {
3628 self.envs = envs;
3629 self
3630 }
3631
3632 pub async fn with_temp_index<R>(
3633 &mut self,
3634 f: impl AsyncFnOnce(&Self) -> Result<R>,
3635 ) -> Result<R> {
3636 let index_file_path = self.path_for_index_id(Uuid::new_v4());
3637
3638 let delete_temp_index = util::defer({
3639 let index_file_path = index_file_path.clone();
3640 let executor = self.executor.clone();
3641 move || {
3642 executor
3643 .spawn(async move {
3644 smol::fs::remove_file(index_file_path).await.log_err();
3645 })
3646 .detach();
3647 }
3648 });
3649
3650 // Copy the default index file so that Git doesn't have to rebuild the
3651 // whole index from scratch. This might fail if this is an empty repository.
3652 smol::fs::copy(self.git_directory.join("index"), &index_file_path)
3653 .await
3654 .ok();
3655
3656 self.index_file_path = Some(index_file_path.clone());
3657 let result = f(self).await;
3658 self.index_file_path = None;
3659 let result = result?;
3660
3661 smol::fs::remove_file(index_file_path).await.ok();
3662 delete_temp_index.abort();
3663
3664 Ok(result)
3665 }
3666
3667 pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
3668 let path = self.git_directory.join("info").join("exclude");
3669
3670 GitExcludeOverride::new(path).await
3671 }
3672
3673 fn path_for_index_id(&self, id: Uuid) -> PathBuf {
3674 self.git_directory.join(format!("index-{}.tmp", id))
3675 }
3676
3677 pub async fn run<S>(&self, args: &[S]) -> Result<String>
3678 where
3679 S: AsRef<OsStr>,
3680 {
3681 let mut stdout = self.run_raw(args).await?;
3682 if stdout.chars().last() == Some('\n') {
3683 stdout.pop();
3684 }
3685 Ok(stdout)
3686 }
3687
3688 /// Returns the result of the command without trimming the trailing newline.
3689 pub async fn run_raw<S>(&self, args: &[S]) -> Result<String>
3690 where
3691 S: AsRef<OsStr>,
3692 {
3693 let mut command = self.build_command(args);
3694 let output = command.output().await?;
3695 anyhow::ensure!(
3696 output.status.success(),
3697 GitBinaryCommandError {
3698 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3699 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3700 status: output.status,
3701 }
3702 );
3703 Ok(String::from_utf8(output.stdout)?)
3704 }
3705
3706 #[allow(clippy::disallowed_methods)]
3707 pub(crate) fn build_command<S>(&self, args: &[S]) -> util::command::Command
3708 where
3709 S: AsRef<OsStr>,
3710 {
3711 let mut command = new_command(&self.git_binary_path);
3712 command.current_dir(&self.working_directory);
3713 // Disabled to stop malicious actors from running arbitrary commands via fsmonitor hooks
3714 command.args(["-c", "core.fsmonitor=false"]);
3715 // Prepended signature lines would corrupt our --format parsers.
3716 command.args(["-c", "log.showSignature=false"]);
3717 command.arg("--no-optional-locks");
3718 // Internal commands must be non-interactive so background tasks never block on user input.
3719 command.arg("--no-pager");
3720
3721 if !self.is_trusted {
3722 command.args(["-c", "core.hooksPath=/dev/null"]);
3723 command.args(["-c", "core.sshCommand=ssh"]);
3724 command.args(["-c", "credential.helper="]);
3725 command.args(["-c", "protocol.ext.allow=never"]);
3726 command.args(["-c", "diff.external="]);
3727 }
3728 command.args(args);
3729
3730 // If the `diff` command is being used, we'll want to add the
3731 // `--no-ext-diff` flag when working on an untrusted repository,
3732 // preventing any external diff programs from being invoked.
3733 if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
3734 command.arg("--no-ext-diff");
3735 }
3736
3737 if let Some(index_file_path) = self.index_file_path.as_ref() {
3738 command.env("GIT_INDEX_FILE", index_file_path);
3739 }
3740 command.envs(&self.envs);
3741 command
3742 }
3743}
3744
3745#[derive(Error, Debug)]
3746#[error("Git command failed:\n{stdout}{stderr}\n")]
3747struct GitBinaryCommandError {
3748 stdout: String,
3749 stderr: String,
3750 status: ExitStatus,
3751}
3752
3753async fn run_git_command(
3754 env: Arc<HashMap<String, String>>,
3755 ask_pass: AskPassDelegate,
3756 mut command: util::command::Command,
3757 executor: BackgroundExecutor,
3758) -> Result<RemoteCommandOutput> {
3759 if env.contains_key("GIT_ASKPASS") {
3760 let git_process = command.spawn()?;
3761 let output = git_process.output().await?;
3762 anyhow::ensure!(
3763 output.status.success(),
3764 "{}",
3765 String::from_utf8_lossy(&output.stderr)
3766 );
3767 Ok(RemoteCommandOutput {
3768 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3769 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3770 })
3771 } else {
3772 let ask_pass = AskPassSession::new(executor, ask_pass).await?;
3773 command
3774 .env("GIT_ASKPASS", ask_pass.script_path())
3775 .env("SSH_ASKPASS", ask_pass.script_path())
3776 .env("SSH_ASKPASS_REQUIRE", "force");
3777
3778 if !env.contains_key("GIT_CONFIG_COUNT")
3779 && let Some(gpg_wrapper) = ask_pass.gpg_wrapper_path()
3780 {
3781 command
3782 .env("GIT_CONFIG_COUNT", "1")
3783 .env("GIT_CONFIG_KEY_0", "gpg.program")
3784 .env("GIT_CONFIG_VALUE_0", gpg_wrapper);
3785 }
3786
3787 #[cfg(target_os = "windows")]
3788 command.env("ZED_ASKPASS_SOCKET", ask_pass.socket_path());
3789 let git_process = command.spawn()?;
3790
3791 run_askpass_command(ask_pass, git_process).await
3792 }
3793}
3794
3795async fn run_askpass_command(
3796 mut ask_pass: AskPassSession,
3797 git_process: util::command::Child,
3798) -> anyhow::Result<RemoteCommandOutput> {
3799 select_biased! {
3800 // Git can legitimately run long without prompting (e.g. large fetches,
3801 // hooks), so completion is determined by the process itself.
3802 result = ask_pass.run(None).fuse() => {
3803 match result {
3804 AskPassResult::CancelledByUser => {
3805 Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
3806 }
3807 AskPassResult::Timedout => {
3808 // Unreachable since no timeout is passed to run()
3809 Err(anyhow!("Connecting to host timed out"))?
3810 }
3811 }
3812 }
3813 output = git_process.output().fuse() => {
3814 let output = output?;
3815 anyhow::ensure!(
3816 output.status.success(),
3817 "{}",
3818 String::from_utf8_lossy(&output.stderr)
3819 );
3820 Ok(RemoteCommandOutput {
3821 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3822 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3823 })
3824 }
3825 }
3826}
3827
3828#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
3829pub struct RepoPath(Arc<RelPath>);
3830
3831impl std::fmt::Debug for RepoPath {
3832 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3833 self.0.fmt(f)
3834 }
3835}
3836
3837impl RepoPath {
3838 pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
3839 let rel_path = RelPath::from_unix_str(s.as_ref())?;
3840 Ok(Self::from_rel_path(rel_path))
3841 }
3842
3843 pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
3844 let rel_path = RelPath::new(path, path_style)?;
3845 Ok(Self::from_rel_path(&rel_path))
3846 }
3847
3848 pub fn from_proto(proto: &str) -> Result<Self> {
3849 let rel_path = RelPath::from_unix_str(proto)?.into();
3850 Ok(Self(rel_path))
3851 }
3852
3853 pub fn from_rel_path(path: &RelPath) -> RepoPath {
3854 Self(Arc::from(path))
3855 }
3856
3857 pub fn as_std_path(&self) -> &Path {
3858 if self.is_empty() {
3859 Path::new(".")
3860 } else {
3861 self.0.as_std_path()
3862 }
3863 }
3864}
3865
3866#[cfg(any(test, feature = "test-support"))]
3867pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
3868 RepoPath(RelPath::from_unix_str(s.as_ref()).unwrap().into())
3869}
3870
3871impl AsRef<Arc<RelPath>> for RepoPath {
3872 fn as_ref(&self) -> &Arc<RelPath> {
3873 &self.0
3874 }
3875}
3876
3877impl std::ops::Deref for RepoPath {
3878 type Target = RelPath;
3879
3880 fn deref(&self) -> &Self::Target {
3881 &self.0
3882 }
3883}
3884
3885#[derive(Debug)]
3886pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
3887
3888impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
3889 fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
3890 if key.starts_with(self.0) {
3891 Ordering::Greater
3892 } else {
3893 self.0.cmp(key)
3894 }
3895 }
3896}
3897
3898fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
3899 let mut branches = Vec::new();
3900 for line in input.split('\n') {
3901 if line.is_empty() {
3902 continue;
3903 }
3904 let mut fields = line.split('\x00');
3905 let Some(head) = fields.next() else {
3906 continue;
3907 };
3908 let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
3909 continue;
3910 };
3911 let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
3912 continue;
3913 };
3914 let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
3915 continue;
3916 };
3917 let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
3918 continue;
3919 };
3920 let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
3921 else {
3922 continue;
3923 };
3924 let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
3925 continue;
3926 };
3927 let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
3928 continue;
3929 };
3930 let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
3931 continue;
3932 };
3933
3934 branches.push(Branch {
3935 is_head: head == "*",
3936 ref_name,
3937 most_recent_commit: Some(CommitSummary {
3938 sha: head_sha,
3939 subject,
3940 commit_timestamp: commiterdate,
3941 author_name: author_name,
3942 has_parent: !parent_sha.is_empty(),
3943 }),
3944 upstream: if upstream_name.is_empty() {
3945 None
3946 } else {
3947 Some(Upstream {
3948 ref_name: upstream_name.into(),
3949 tracking: upstream_tracking,
3950 })
3951 },
3952 })
3953 }
3954
3955 Ok(branches)
3956}
3957
3958fn format_branch_scan_error(output: &Output) -> String {
3959 let stderr = String::from_utf8_lossy(&output.stderr)
3960 .trim()
3961 .replace('\n', " ");
3962 if stderr.is_empty() {
3963 format!("git for-each-ref exited with {}", output.status)
3964 } else {
3965 stderr
3966 }
3967}
3968
3969fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
3970 if upstream_track.is_empty() {
3971 return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3972 ahead: 0,
3973 behind: 0,
3974 }));
3975 }
3976
3977 let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
3978 let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
3979 let mut ahead: u32 = 0;
3980 let mut behind: u32 = 0;
3981 for component in upstream_track.split(", ") {
3982 if component == "gone" {
3983 return Ok(UpstreamTracking::Gone);
3984 }
3985 if let Some(ahead_num) = component.strip_prefix("ahead ") {
3986 ahead = ahead_num.parse::<u32>()?;
3987 }
3988 if let Some(behind_num) = component.strip_prefix("behind ") {
3989 behind = behind_num.parse::<u32>()?;
3990 }
3991 }
3992 Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3993 ahead,
3994 behind,
3995 }))
3996}
3997
3998fn checkpoint_author_envs() -> HashMap<String, String> {
3999 HashMap::from_iter([
4000 ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
4001 ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
4002 ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
4003 ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
4004 ])
4005}
4006
4007#[cfg(test)]
4008mod tests {
4009 use std::{
4010 ffi::{OsStr, OsString},
4011 fs,
4012 };
4013
4014 use super::*;
4015 use gpui::TestAppContext;
4016
4017 fn disable_git_global_config() {
4018 unsafe {
4019 std::env::set_var("GIT_CONFIG_GLOBAL", "");
4020 std::env::set_var("GIT_CONFIG_SYSTEM", "");
4021 }
4022 }
4023
4024 #[allow(clippy::disallowed_methods)]
4025 #[track_caller]
4026 fn git_command_output<I, S>(working_directory: &Path, arguments: I) -> String
4027 where
4028 I: IntoIterator<Item = S>,
4029 S: AsRef<OsStr>,
4030 {
4031 let output = std::process::Command::new("git")
4032 .args(arguments)
4033 .current_dir(working_directory)
4034 .env("GIT_CONFIG_GLOBAL", "")
4035 .env("GIT_CONFIG_SYSTEM", "")
4036 .env("GIT_AUTHOR_NAME", "test")
4037 .env("GIT_AUTHOR_EMAIL", "test@zed.dev")
4038 .env("GIT_COMMITTER_NAME", "test")
4039 .env("GIT_COMMITTER_EMAIL", "test@zed.dev")
4040 .output()
4041 .expect("failed to run git command");
4042 assert!(
4043 output.status.success(),
4044 "git command failed: {}",
4045 String::from_utf8_lossy(&output.stderr)
4046 );
4047 String::from_utf8(output.stdout)
4048 .expect("git command output was not valid UTF-8")
4049 .trim()
4050 .to_string()
4051 }
4052
4053 #[track_caller]
4054 fn git_command<I, S>(working_directory: &Path, arguments: I)
4055 where
4056 I: IntoIterator<Item = S>,
4057 S: AsRef<OsStr>,
4058 {
4059 git_command_output(working_directory, arguments);
4060 }
4061
4062 fn git_init_repo(path: &Path) {
4063 fs::create_dir_all(path).expect("failed to create repo directory");
4064 git_command(path, ["init", "-b", "main"]);
4065 }
4066
4067 fn clone_remote_repository_with_main_and_feature(temp_dir: &Path) -> (PathBuf, PathBuf) {
4068 let remote_directory = temp_dir.join("remote.git");
4069 let seed_directory = temp_dir.join("seed");
4070 let clone_directory = temp_dir.join("clone");
4071
4072 git_command(
4073 temp_dir,
4074 [
4075 OsString::from("init"),
4076 OsString::from("--bare"),
4077 OsString::from("-b"),
4078 OsString::from("main"),
4079 remote_directory.as_os_str().into(),
4080 ],
4081 );
4082 git_init_repo(&seed_directory);
4083 fs::write(seed_directory.join("file.txt"), "main").unwrap();
4084 git_command(&seed_directory, ["add", "file.txt"]);
4085 git_command(&seed_directory, ["commit", "-m", "initial"]);
4086 git_command(&seed_directory, ["switch", "-c", "feature"]);
4087 fs::write(seed_directory.join("feature.txt"), "feature").unwrap();
4088 git_command(&seed_directory, ["add", "feature.txt"]);
4089 git_command(&seed_directory, ["commit", "-m", "feature"]);
4090 git_command(
4091 &seed_directory,
4092 [
4093 OsString::from("remote"),
4094 OsString::from("add"),
4095 OsString::from("origin"),
4096 remote_directory.as_os_str().into(),
4097 ],
4098 );
4099 git_command(&seed_directory, ["push", "-u", "origin", "main"]);
4100 git_command(&seed_directory, ["push", "-u", "origin", "feature"]);
4101 git_command(
4102 temp_dir,
4103 [
4104 OsString::from("clone"),
4105 remote_directory.as_os_str().into(),
4106 clone_directory.as_os_str().into(),
4107 ],
4108 );
4109
4110 (remote_directory, clone_directory)
4111 }
4112
4113 fn test_commit_envs() -> HashMap<String, String> {
4114 let mut env = checkpoint_author_envs();
4115 env.insert("GIT_ASKPASS".to_string(), "false".to_string());
4116 env
4117 }
4118
4119 #[track_caller]
4120 fn assert_same_path(left: impl AsRef<Path>, right: impl AsRef<Path>) {
4121 assert_eq!(
4122 fs::canonicalize(left.as_ref()).unwrap(),
4123 fs::canonicalize(right.as_ref()).unwrap()
4124 );
4125 }
4126
4127 #[gpui::test]
4128 async fn test_real_git_repository_new_resolves_normal_repository_paths(
4129 cx: &mut TestAppContext,
4130 ) {
4131 disable_git_global_config();
4132 cx.executor().allow_parking();
4133
4134 let repo_dir = tempfile::tempdir().unwrap();
4135 git_init_repo(repo_dir.path());
4136
4137 let repository = RealGitRepository::new(
4138 &repo_dir.path().join(".git"),
4139 None,
4140 Some("git".into()),
4141 cx.executor(),
4142 )
4143 .unwrap();
4144
4145 assert_same_path(&repository.git_dir, repo_dir.path().join(".git"));
4146 assert_same_path(&repository.common_dir, repo_dir.path().join(".git"));
4147 assert_same_path(
4148 repository.working_directory.as_ref().unwrap(),
4149 repo_dir.path(),
4150 );
4151 assert_same_path(
4152 original_repo_path_from_common_dir(&repository.common_dir).unwrap(),
4153 repo_dir.path(),
4154 );
4155 }
4156
4157 #[gpui::test]
4158 async fn test_load_commit_with_type_changed_file(cx: &mut TestAppContext) {
4159 disable_git_global_config();
4160 cx.executor().allow_parking();
4161
4162 let repo_dir = tempfile::tempdir().expect("failed to create temporary repository");
4163 git_init_repo(repo_dir.path());
4164 fs::write(repo_dir.path().join("file.txt"), "regular contents\n")
4165 .expect("failed to write regular file");
4166 git_command(repo_dir.path(), ["add", "file.txt"]);
4167 git_command(repo_dir.path(), ["commit", "-m", "initial"]);
4168
4169 let repository = RealGitRepository::new(
4170 &repo_dir.path().join(".git"),
4171 None,
4172 Some("git".into()),
4173 cx.executor(),
4174 )
4175 .expect("failed to open repository");
4176 fs::write(repo_dir.path().join("file.txt"), "target")
4177 .expect("failed to write symlink target");
4178
4179 let symlink_blob = repository
4180 .git_binary()
4181 .run(&["hash-object", "-w", "file.txt"])
4182 .await
4183 .expect("failed to write symlink blob");
4184 git_command(
4185 repo_dir.path(),
4186 [
4187 OsString::from("update-index"),
4188 OsString::from("--cacheinfo"),
4189 OsString::from("120000"),
4190 OsString::from(symlink_blob),
4191 OsString::from("file.txt"),
4192 ],
4193 );
4194 git_command(repo_dir.path(), ["commit", "-m", "type change"]);
4195
4196 let commit_diff = repository
4197 .load_commit("HEAD".to_string(), cx.to_async())
4198 .await
4199 .expect("failed to load type-changed commit");
4200 assert_eq!(commit_diff.files.len(), 1);
4201
4202 let file = commit_diff
4203 .files
4204 .first()
4205 .expect("type-changed file should be present");
4206 assert_eq!(file.path.as_unix_str(), "file.txt");
4207 assert_eq!(file.old_text.as_deref(), Some("regular contents\n"));
4208 assert_eq!(file.new_text.as_deref(), Some("target"));
4209 assert_eq!(file.status(), CommitFileStatus::Modified);
4210 }
4211
4212 #[gpui::test]
4213 async fn test_load_commit_with_gitlink_changes(cx: &mut TestAppContext) {
4214 const FIRST_SUBMODULE_COMMIT: &str = "1111111111111111111111111111111111111111";
4215 const SECOND_SUBMODULE_COMMIT: &str = "2222222222222222222222222222222222222222";
4216
4217 disable_git_global_config();
4218 cx.executor().allow_parking();
4219
4220 let repo_dir = tempfile::tempdir().expect("failed to create temporary repository");
4221 git_init_repo(repo_dir.path());
4222 fs::write(repo_dir.path().join("README.md"), "parent repository\n")
4223 .expect("failed to write regular file");
4224 git_command(repo_dir.path(), ["add", "README.md"]);
4225 git_command(
4226 repo_dir.path(),
4227 [
4228 "update-index",
4229 "--add",
4230 "--cacheinfo",
4231 crate::commit::GITLINK_MODE,
4232 FIRST_SUBMODULE_COMMIT,
4233 "modules/example",
4234 ],
4235 );
4236 git_command(repo_dir.path(), ["commit", "-m", "add submodule"]);
4237
4238 let repository = RealGitRepository::new(
4239 &repo_dir.path().join(".git"),
4240 None,
4241 Some("git".into()),
4242 cx.executor(),
4243 )
4244 .expect("failed to open repository");
4245
4246 let commit_diff = repository
4247 .load_commit("HEAD".to_string(), cx.to_async())
4248 .await
4249 .expect("failed to load commit that adds a gitlink");
4250 assert_eq!(commit_diff.files.len(), 2);
4251 let gitlink = commit_diff
4252 .files
4253 .iter()
4254 .find(|file| file.path.as_unix_str() == "modules/example")
4255 .expect("gitlink should be present alongside the regular file");
4256 assert_eq!(gitlink.status(), CommitFileStatus::Added);
4257 assert_eq!(gitlink.old_text, None);
4258 assert_eq!(
4259 gitlink.new_text.as_deref(),
4260 Some("Subproject commit 1111111111111111111111111111111111111111\n")
4261 );
4262 assert!(!gitlink.is_binary);
4263
4264 git_command(
4265 repo_dir.path(),
4266 [
4267 "update-index",
4268 "--cacheinfo",
4269 crate::commit::GITLINK_MODE,
4270 SECOND_SUBMODULE_COMMIT,
4271 "modules/example",
4272 ],
4273 );
4274 git_command(repo_dir.path(), ["commit", "-m", "update submodule"]);
4275
4276 let commit_diff = repository
4277 .load_commit("HEAD".to_string(), cx.to_async())
4278 .await
4279 .expect("failed to load commit that updates a gitlink");
4280 let [gitlink] = commit_diff.files.as_slice() else {
4281 panic!("expected one updated gitlink");
4282 };
4283 assert_eq!(gitlink.status(), CommitFileStatus::Modified);
4284 assert_eq!(
4285 gitlink.old_text.as_deref(),
4286 Some("Subproject commit 1111111111111111111111111111111111111111\n")
4287 );
4288 assert_eq!(
4289 gitlink.new_text.as_deref(),
4290 Some("Subproject commit 2222222222222222222222222222222222222222\n")
4291 );
4292 assert!(!gitlink.is_binary);
4293
4294 git_command(repo_dir.path(), ["rm", "--cached", "modules/example"]);
4295 git_command(repo_dir.path(), ["commit", "-m", "remove submodule"]);
4296
4297 let commit_diff = repository
4298 .load_commit("HEAD".to_string(), cx.to_async())
4299 .await
4300 .expect("failed to load commit that deletes a gitlink");
4301 let [gitlink] = commit_diff.files.as_slice() else {
4302 panic!("expected one deleted gitlink");
4303 };
4304 assert_eq!(gitlink.status(), CommitFileStatus::Deleted);
4305 assert_eq!(
4306 gitlink.old_text.as_deref(),
4307 Some("Subproject commit 2222222222222222222222222222222222222222\n")
4308 );
4309 assert_eq!(gitlink.new_text, None);
4310 assert!(!gitlink.is_binary);
4311 }
4312
4313 #[gpui::test]
4314 async fn test_check_access(cx: &mut TestAppContext) {
4315 disable_git_global_config();
4316 cx.executor().allow_parking();
4317
4318 let repo_dir = tempfile::tempdir().unwrap();
4319 let repository = RealGitRepository::new(
4320 &repo_dir.path().join(".git"),
4321 None,
4322 Some("git".into()),
4323 cx.executor(),
4324 )
4325 .unwrap();
4326
4327 assert!(repository.check_access().await.is_err());
4328 git_init_repo(repo_dir.path());
4329 assert!(repository.check_access().await.is_ok());
4330 }
4331
4332 #[gpui::test]
4333 async fn test_real_git_repository_new_resolves_linked_worktree_paths(cx: &mut TestAppContext) {
4334 disable_git_global_config();
4335 cx.executor().allow_parking();
4336
4337 let temp_dir = tempfile::tempdir().unwrap();
4338 let repo_dir = temp_dir.path().join("repo");
4339 let worktree_dir = temp_dir.path().join("worktree");
4340 git_init_repo(&repo_dir);
4341 fs::write(repo_dir.join("file.txt"), "initial").unwrap();
4342 git_command(&repo_dir, ["add", "file.txt"]);
4343 git_command(&repo_dir, ["commit", "-m", "initial"]);
4344 git_command(
4345 &repo_dir,
4346 [
4347 OsString::from("worktree"),
4348 OsString::from("add"),
4349 OsString::from("-b"),
4350 OsString::from("feature"),
4351 worktree_dir.as_os_str().into(),
4352 ],
4353 );
4354
4355 let repository = RealGitRepository::new(
4356 &worktree_dir.join(".git"),
4357 None,
4358 Some("git".into()),
4359 cx.executor(),
4360 )
4361 .unwrap();
4362
4363 assert_same_path(
4364 repository.working_directory.as_ref().unwrap(),
4365 &worktree_dir,
4366 );
4367 assert_same_path(&repository.common_dir, repo_dir.join(".git"));
4368 assert_same_path(
4369 original_repo_path_from_common_dir(&repository.common_dir).unwrap(),
4370 repo_dir,
4371 );
4372 }
4373
4374 #[gpui::test]
4375 async fn test_real_git_repository_new_supports_bare_repositories(cx: &mut TestAppContext) {
4376 disable_git_global_config();
4377 cx.executor().allow_parking();
4378
4379 let temp_dir = tempfile::tempdir().unwrap();
4380 let repo_dir = temp_dir.path().join("repo.git");
4381 git_command(
4382 temp_dir.path(),
4383 [
4384 OsString::from("init"),
4385 OsString::from("--bare"),
4386 repo_dir.as_os_str().into(),
4387 ],
4388 );
4389
4390 let repository =
4391 RealGitRepository::new(&repo_dir, None, Some("git".into()), cx.executor()).unwrap();
4392
4393 assert_same_path(&repository.git_dir, &repo_dir);
4394 assert_same_path(&repository.common_dir, &repo_dir);
4395 assert_eq!(repository.working_directory, None);
4396 assert_same_path(repository.main_repository_path(), &repo_dir);
4397 assert_eq!(
4398 repository
4399 .git_binary()
4400 .run(&["rev-parse", "--is-bare-repository"])
4401 .await
4402 .unwrap(),
4403 "true"
4404 );
4405 }
4406
4407 #[gpui::test]
4408 async fn test_change_branch_creates_local_tracking_branch_from_remote(cx: &mut TestAppContext) {
4409 disable_git_global_config();
4410 cx.executor().allow_parking();
4411
4412 let temp_dir = tempfile::tempdir().unwrap();
4413 let (_remote_directory, clone_directory) =
4414 clone_remote_repository_with_main_and_feature(temp_dir.path());
4415
4416 let repository = RealGitRepository::new(
4417 &clone_directory.join(".git"),
4418 None,
4419 Some("git".into()),
4420 cx.executor(),
4421 )
4422 .unwrap();
4423 let git = repository.git_binary_in_worktree().unwrap();
4424 assert!(
4425 git.run(&[
4426 "show-ref",
4427 "--verify",
4428 "--quiet",
4429 "refs/remotes/origin/feature"
4430 ])
4431 .await
4432 .is_ok()
4433 );
4434 assert!(
4435 git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"])
4436 .await
4437 .is_err()
4438 );
4439
4440 repository
4441 .change_branch("origin/feature".to_string())
4442 .await
4443 .unwrap();
4444
4445 let git = repository.git_binary_in_worktree().unwrap();
4446 assert_eq!(
4447 git.run(&["branch", "--show-current"]).await.unwrap(),
4448 "feature"
4449 );
4450 assert_eq!(
4451 git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
4452 .await
4453 .unwrap(),
4454 "origin/feature"
4455 );
4456
4457 git.run(&["checkout", "main"]).await.unwrap();
4458 git.run(&["branch", "--unset-upstream", "feature"])
4459 .await
4460 .unwrap();
4461
4462 repository
4463 .change_branch("origin/feature".to_string())
4464 .await
4465 .unwrap();
4466
4467 let git = repository.git_binary_in_worktree().unwrap();
4468 assert_eq!(
4469 git.run(&["branch", "--show-current"]).await.unwrap(),
4470 "feature"
4471 );
4472 assert_eq!(
4473 git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
4474 .await
4475 .unwrap(),
4476 "origin/feature"
4477 );
4478 }
4479
4480 #[gpui::test]
4481 async fn test_change_branch_resolves_remote_head_to_tracking_branch(cx: &mut TestAppContext) {
4482 disable_git_global_config();
4483 cx.executor().allow_parking();
4484
4485 let temp_dir = tempfile::tempdir().unwrap();
4486 let (_remote_directory, clone_directory) =
4487 clone_remote_repository_with_main_and_feature(temp_dir.path());
4488
4489 let repository = RealGitRepository::new(
4490 &clone_directory.join(".git"),
4491 None,
4492 Some("git".into()),
4493 cx.executor(),
4494 )
4495 .unwrap();
4496 let git = repository.git_binary_in_worktree().unwrap();
4497 git.run(&[
4498 "symbolic-ref",
4499 "refs/remotes/origin/HEAD",
4500 "refs/remotes/origin/feature",
4501 ])
4502 .await
4503 .unwrap();
4504 assert_eq!(
4505 git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
4506 .await
4507 .unwrap(),
4508 "refs/remotes/origin/feature"
4509 );
4510 assert!(
4511 git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"])
4512 .await
4513 .is_err()
4514 );
4515
4516 repository
4517 .change_branch("origin/HEAD".to_string())
4518 .await
4519 .unwrap();
4520
4521 let git = repository.git_binary_in_worktree().unwrap();
4522 assert_eq!(
4523 git.run(&["branch", "--show-current"]).await.unwrap(),
4524 "feature"
4525 );
4526 assert_eq!(
4527 git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
4528 .await
4529 .unwrap(),
4530 "origin/feature"
4531 );
4532 }
4533
4534 #[gpui::test]
4535 async fn test_change_branch_resolves_non_origin_remote_head(cx: &mut TestAppContext) {
4536 disable_git_global_config();
4537 cx.executor().allow_parking();
4538
4539 let temp_dir = tempfile::tempdir().unwrap();
4540 let (remote_directory, clone_directory) =
4541 clone_remote_repository_with_main_and_feature(temp_dir.path());
4542
4543 git_command(
4544 &clone_directory,
4545 [
4546 OsString::from("remote"),
4547 OsString::from("add"),
4548 OsString::from("upstream"),
4549 remote_directory.as_os_str().into(),
4550 ],
4551 );
4552 git_command(&clone_directory, ["fetch", "upstream"]);
4553
4554 let repository = RealGitRepository::new(
4555 &clone_directory.join(".git"),
4556 None,
4557 Some("git".into()),
4558 cx.executor(),
4559 )
4560 .unwrap();
4561 let git = repository.git_binary_in_worktree().unwrap();
4562 git.run(&[
4563 "symbolic-ref",
4564 "refs/remotes/upstream/HEAD",
4565 "refs/remotes/upstream/main",
4566 ])
4567 .await
4568 .unwrap();
4569 git.run(&["checkout", "-b", "scratch"]).await.unwrap();
4570
4571 repository
4572 .change_branch("upstream/HEAD".to_string())
4573 .await
4574 .unwrap();
4575
4576 let git = repository.git_binary_in_worktree().unwrap();
4577 assert_eq!(
4578 git.run(&["branch", "--show-current"]).await.unwrap(),
4579 "main"
4580 );
4581 assert_eq!(
4582 git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
4583 .await
4584 .unwrap(),
4585 "upstream/main"
4586 );
4587 }
4588
4589 #[gpui::test]
4590 fn test_real_git_repository_new_rejects_malformed_git_file(cx: &mut TestAppContext) {
4591 disable_git_global_config();
4592
4593 let temp_dir = tempfile::tempdir().unwrap();
4594 let worktree_dir = temp_dir.path().join("worktree");
4595 fs::create_dir_all(&worktree_dir).unwrap();
4596 fs::write(worktree_dir.join(".git"), "not a gitdir file\n").unwrap();
4597
4598 let error = match RealGitRepository::new(
4599 &worktree_dir.join(".git"),
4600 None,
4601 Some("git".into()),
4602 cx.executor(),
4603 ) {
4604 Ok(_) => panic!("malformed .git file should be rejected"),
4605 Err(error) => error,
4606 };
4607
4608 assert!(
4609 error
4610 .to_string()
4611 .contains("expected .git file to start with 'gitdir: '"),
4612 "unexpected error: {error:#}"
4613 );
4614 }
4615
4616 #[test]
4617 fn test_initial_graph_commit_data_tag_names() {
4618 let commit = InitialGraphCommitData {
4619 sha: Oid::from_bytes(&[0; 20]).unwrap(),
4620 parents: SmallVec::new(),
4621 ref_names: vec![
4622 SharedString::from("HEAD -> main"),
4623 SharedString::from("origin/main"),
4624 SharedString::from("tag: v1.0.0"),
4625 SharedString::from("tag: v1.1.0"),
4626 SharedString::from("tag: "),
4627 SharedString::from("refs/heads/feature"),
4628 ],
4629 };
4630
4631 assert_eq!(commit.tag_names(), ["v1.0.0", "v1.1.0"]);
4632 }
4633
4634 #[test]
4635 fn test_parse_file_history_changed_files_output() {
4636 let queried_paths = vec![
4637 RepoPath::new("src/a.rs").unwrap(),
4638 RepoPath::new("src/b.rs").unwrap(),
4639 ];
4640 let output = concat!(
4641 "\x1e\0\nsrc/a.rs\0src/shared.rs\0",
4642 "\x1e\0\nsrc/b.rs\0src/shared.rs\0",
4643 "\x1e\0\nsrc/a.rs\0src/b.rs\0src/shared.rs\0",
4644 );
4645
4646 let histories = parse_file_history_changed_files_output(output, &queried_paths);
4647
4648 assert_eq!(histories.len(), 2);
4649 assert_eq!(
4650 histories[0].file_sets,
4651 vec![
4652 vec![
4653 RepoPath::new("src/a.rs").unwrap(),
4654 RepoPath::new("src/shared.rs").unwrap(),
4655 ],
4656 vec![
4657 RepoPath::new("src/a.rs").unwrap(),
4658 RepoPath::new("src/b.rs").unwrap(),
4659 RepoPath::new("src/shared.rs").unwrap(),
4660 ],
4661 ]
4662 );
4663 assert_eq!(
4664 histories[1].file_sets,
4665 vec![
4666 vec![
4667 RepoPath::new("src/b.rs").unwrap(),
4668 RepoPath::new("src/shared.rs").unwrap(),
4669 ],
4670 vec![
4671 RepoPath::new("src/a.rs").unwrap(),
4672 RepoPath::new("src/b.rs").unwrap(),
4673 RepoPath::new("src/shared.rs").unwrap(),
4674 ],
4675 ]
4676 );
4677 }
4678
4679 #[gpui::test]
4680 async fn test_initial_graph_data_accepts_sha_log_source(cx: &mut TestAppContext) {
4681 disable_git_global_config();
4682
4683 cx.executor().allow_parking();
4684
4685 let repo_dir = tempfile::tempdir().unwrap();
4686
4687 git_init_repo(repo_dir.path());
4688 fs::write(repo_dir.path().join("file"), "initial").unwrap();
4689 git_command(repo_dir.path(), ["add", "file"]);
4690 git_command(repo_dir.path(), ["commit", "-m", "Initial commit"]);
4691
4692 let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"])
4693 .parse()
4694 .unwrap();
4695
4696 let repo = RealGitRepository::new(
4697 &repo_dir.path().join(".git"),
4698 None,
4699 Some("git".into()),
4700 cx.executor(),
4701 )
4702 .unwrap();
4703
4704 let (request_tx, request_rx) = async_channel::unbounded();
4705
4706 repo.initial_graph_data(LogSource::Sha(commit_sha), LogOrder::DateOrder, request_tx)
4707 .await
4708 .unwrap();
4709
4710 let graph_data = request_rx.recv().await.unwrap();
4711 assert_eq!(graph_data.len(), 1);
4712 assert_eq!(graph_data[0].sha, commit_sha);
4713 }
4714
4715 #[gpui::test]
4716 async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
4717 cx.executor().allow_parking();
4718 let dir = tempfile::tempdir().unwrap();
4719 let git = GitBinary::new(
4720 PathBuf::from("git"),
4721 dir.path().to_path_buf(),
4722 dir.path().join(".git"),
4723 cx.executor(),
4724 false,
4725 );
4726 let output = git
4727 .build_command(&["version"])
4728 .output()
4729 .await
4730 .expect("git version should succeed");
4731 assert!(output.status.success());
4732
4733 let git = GitBinary::new(
4734 PathBuf::from("git"),
4735 dir.path().to_path_buf(),
4736 dir.path().join(".git"),
4737 cx.executor(),
4738 false,
4739 );
4740 let output = git
4741 .build_command(&["config", "--get", "core.fsmonitor"])
4742 .output()
4743 .await
4744 .expect("git config should run");
4745 let stdout = String::from_utf8_lossy(&output.stdout);
4746 assert_eq!(
4747 stdout.trim(),
4748 "false",
4749 "fsmonitor should be disabled for untrusted repos"
4750 );
4751
4752 git_init_repo(dir.path());
4753 let git = GitBinary::new(
4754 PathBuf::from("git"),
4755 dir.path().to_path_buf(),
4756 dir.path().join(".git"),
4757 cx.executor(),
4758 false,
4759 );
4760 let output = git
4761 .build_command(&["config", "--get", "core.hooksPath"])
4762 .output()
4763 .await
4764 .expect("git config should run");
4765 let stdout = String::from_utf8_lossy(&output.stdout);
4766 assert_eq!(
4767 stdout.trim(),
4768 "/dev/null",
4769 "hooksPath should be /dev/null for untrusted repos"
4770 );
4771 }
4772
4773 #[gpui::test]
4774 async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) {
4775 cx.executor().allow_parking();
4776 let dir = tempfile::tempdir().unwrap();
4777 git_init_repo(dir.path());
4778
4779 let git = GitBinary::new(
4780 PathBuf::from("git"),
4781 dir.path().to_path_buf(),
4782 dir.path().join(".git"),
4783 cx.executor(),
4784 true,
4785 );
4786 let output = git
4787 .build_command(&["config", "--get", "core.fsmonitor"])
4788 .output()
4789 .await
4790 .expect("git config should run");
4791 let stdout = String::from_utf8_lossy(&output.stdout);
4792 assert_eq!(
4793 stdout.trim(),
4794 "false",
4795 "fsmonitor should be disabled even for trusted repos"
4796 );
4797
4798 let git = GitBinary::new(
4799 PathBuf::from("git"),
4800 dir.path().to_path_buf(),
4801 dir.path().join(".git"),
4802 cx.executor(),
4803 true,
4804 );
4805 let output = git
4806 .build_command(&["config", "--get", "core.hooksPath"])
4807 .output()
4808 .await
4809 .expect("git config should run");
4810 assert!(
4811 !output.status.success(),
4812 "hooksPath should NOT be overridden for trusted repos"
4813 );
4814 }
4815
4816 #[gpui::test]
4817 async fn test_build_command_disables_log_show_signature(cx: &mut TestAppContext) {
4818 cx.executor().allow_parking();
4819 let dir = tempfile::tempdir().unwrap();
4820 git_init_repo(dir.path());
4821
4822 let git = GitBinary::new(
4823 PathBuf::from("git"),
4824 dir.path().to_path_buf(),
4825 dir.path().join(".git"),
4826 cx.executor(),
4827 true,
4828 );
4829 let output = git
4830 .build_command(&["config", "--get", "log.showSignature"])
4831 .output()
4832 .await
4833 .expect("git config should run");
4834 let stdout = String::from_utf8_lossy(&output.stdout);
4835 assert_eq!(
4836 stdout.trim(),
4837 "false",
4838 "log.showSignature should be disabled for trusted repos"
4839 );
4840
4841 let git = GitBinary::new(
4842 PathBuf::from("git"),
4843 dir.path().to_path_buf(),
4844 dir.path().join(".git"),
4845 cx.executor(),
4846 false,
4847 );
4848 let output = git
4849 .build_command(&["config", "--get", "log.showSignature"])
4850 .output()
4851 .await
4852 .expect("git config should run");
4853 let stdout = String::from_utf8_lossy(&output.stdout);
4854 assert_eq!(
4855 stdout.trim(),
4856 "false",
4857 "log.showSignature should be disabled for untrusted repos"
4858 );
4859 }
4860
4861 #[gpui::test]
4862 async fn test_path_for_index_id_uses_real_git_directory(cx: &mut TestAppContext) {
4863 cx.executor().allow_parking();
4864 let working_directory = PathBuf::from("/code/worktree");
4865 let git_directory = PathBuf::from("/code/repo/.git/modules/worktree");
4866 let git = GitBinary::new(
4867 PathBuf::from("git"),
4868 working_directory,
4869 git_directory.clone(),
4870 cx.executor(),
4871 false,
4872 );
4873
4874 let path = git.path_for_index_id(Uuid::nil());
4875
4876 assert_eq!(
4877 path,
4878 git_directory.join(format!("index-{}.tmp", Uuid::nil()))
4879 );
4880 }
4881
4882 #[gpui::test]
4883 async fn test_checkpoint_basic(cx: &mut TestAppContext) {
4884 disable_git_global_config();
4885
4886 cx.executor().allow_parking();
4887
4888 let repo_dir = tempfile::tempdir().unwrap();
4889
4890 git_init_repo(repo_dir.path());
4891 let file_path = repo_dir.path().join("file");
4892 smol::fs::write(&file_path, "initial").await.unwrap();
4893
4894 let repo = RealGitRepository::new(
4895 &repo_dir.path().join(".git"),
4896 None,
4897 Some("git".into()),
4898 cx.executor(),
4899 )
4900 .unwrap();
4901
4902 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
4903 .await
4904 .unwrap();
4905 repo.commit(
4906 "Initial commit".into(),
4907 None,
4908 CommitOptions::default(),
4909 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4910 Arc::new(test_commit_envs()),
4911 )
4912 .await
4913 .unwrap();
4914
4915 smol::fs::write(&file_path, "modified before checkpoint")
4916 .await
4917 .unwrap();
4918 smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
4919 .await
4920 .unwrap();
4921 let checkpoint = repo.checkpoint().await.unwrap();
4922
4923 // Ensure the user can't see any branches after creating a checkpoint.
4924 assert_eq!(repo.branches().await.unwrap().branches.len(), 1);
4925
4926 smol::fs::write(&file_path, "modified after checkpoint")
4927 .await
4928 .unwrap();
4929 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
4930 .await
4931 .unwrap();
4932 repo.commit(
4933 "Commit after checkpoint".into(),
4934 None,
4935 CommitOptions::default(),
4936 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4937 Arc::new(test_commit_envs()),
4938 )
4939 .await
4940 .unwrap();
4941
4942 smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
4943 .await
4944 .unwrap();
4945 smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
4946 .await
4947 .unwrap();
4948
4949 // Ensure checkpoint stays alive even after a Git GC.
4950 repo.gc().await.unwrap();
4951 repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
4952
4953 assert_eq!(
4954 smol::fs::read_to_string(&file_path).await.unwrap(),
4955 "modified before checkpoint"
4956 );
4957 assert_eq!(
4958 smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
4959 .await
4960 .unwrap(),
4961 "1"
4962 );
4963 // See TODO above
4964 // assert_eq!(
4965 // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
4966 // .await
4967 // .ok(),
4968 // None
4969 // );
4970 }
4971
4972 #[cfg(unix)]
4973 #[gpui::test]
4974 async fn test_commit_runs_git_hooks(cx: &mut TestAppContext) {
4975 use std::os::unix::fs::PermissionsExt as _;
4976
4977 disable_git_global_config();
4978 cx.executor().allow_parking();
4979
4980 let repo_dir = tempfile::tempdir().unwrap();
4981 git_init_repo(repo_dir.path());
4982 let repo = RealGitRepository::new(
4983 &repo_dir.path().join(".git"),
4984 None,
4985 Some("git".into()),
4986 cx.executor(),
4987 )
4988 .unwrap();
4989
4990 let hooks_dir = repo_dir.path().join(".git").join("hooks");
4991 fs::create_dir_all(&hooks_dir).unwrap();
4992 let write_hook = |name: &str, contents: &str| {
4993 let path = hooks_dir.join(name);
4994 fs::write(&path, contents).unwrap();
4995 fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
4996 };
4997
4998 write_hook("pre-commit", "#!/bin/sh\nexit 1\n");
4999
5000 fs::write(repo_dir.path().join("file"), "one").unwrap();
5001 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
5002 .await
5003 .unwrap();
5004
5005 // Hooks must not run for untrusted repositories.
5006 repo.commit(
5007 "Commit in untrusted repo".into(),
5008 None,
5009 CommitOptions::default(),
5010 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5011 Arc::new(test_commit_envs()),
5012 )
5013 .await
5014 .expect("failing pre-commit hook should be skipped in untrusted repos");
5015
5016 repo.set_trusted(true);
5017
5018 fs::write(repo_dir.path().join("file"), "two").unwrap();
5019 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
5020 .await
5021 .unwrap();
5022
5023 repo.commit(
5024 "Commit blocked by hook".into(),
5025 None,
5026 CommitOptions::default(),
5027 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5028 Arc::new(test_commit_envs()),
5029 )
5030 .await
5031 .expect_err("failing pre-commit hook should abort the commit");
5032
5033 write_hook("pre-commit", "#!/bin/sh\nexit 0\n");
5034 write_hook(
5035 "commit-msg",
5036 "#!/bin/sh\necho 'rewritten by commit-msg hook' > \"$1\"\n",
5037 );
5038
5039 repo.commit(
5040 "Original message".into(),
5041 None,
5042 CommitOptions::default(),
5043 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5044 Arc::new(test_commit_envs()),
5045 )
5046 .await
5047 .unwrap();
5048
5049 let message = git_command_output(repo_dir.path(), ["log", "-1", "--pretty=%B"]);
5050 assert_eq!(message, "rewritten by commit-msg hook");
5051
5052 write_hook("pre-commit", "#!/bin/sh\nexit 1\n");
5053 fs::write(repo_dir.path().join("file"), "three").unwrap();
5054 repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
5055 .await
5056 .unwrap();
5057
5058 repo.commit(
5059 "Commit without verification".into(),
5060 None,
5061 CommitOptions {
5062 no_verify: true,
5063 ..Default::default()
5064 },
5065 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5066 Arc::new(test_commit_envs()),
5067 )
5068 .await
5069 .expect("--no-verify should skip pre-commit and commit-msg hooks");
5070
5071 let message = git_command_output(repo_dir.path(), ["log", "-1", "--pretty=%B"]);
5072 assert_eq!(message, "Commit without verification");
5073 }
5074
5075 #[gpui::test]
5076 async fn test_load_revisions(cx: &mut TestAppContext) {
5077 disable_git_global_config();
5078 cx.executor().allow_parking();
5079
5080 let repo_dir = tempfile::tempdir().unwrap();
5081 git_init_repo(repo_dir.path());
5082
5083 let file1_path = repo_dir.path().join("file1");
5084 let file2_path = repo_dir.path().join("file2");
5085 let space_file_path = repo_dir.path().join("file with spaces");
5086
5087 smol::fs::write(&file1_path, "file1 committed contents")
5088 .await
5089 .unwrap();
5090 smol::fs::write(&file2_path, "file2 committed contents")
5091 .await
5092 .unwrap();
5093 smol::fs::write(&space_file_path, "space file committed contents")
5094 .await
5095 .unwrap();
5096
5097 let repo = RealGitRepository::new(
5098 &repo_dir.path().join(".git"),
5099 None,
5100 Some("git".into()),
5101 cx.executor(),
5102 )
5103 .unwrap();
5104
5105 // Stage files and commit
5106 repo.stage_paths(
5107 vec![
5108 repo_path("file1"),
5109 repo_path("file2"),
5110 repo_path("file with spaces"),
5111 ],
5112 Arc::new(HashMap::default()),
5113 )
5114 .await
5115 .unwrap();
5116 repo.commit(
5117 "Initial commit".into(),
5118 None,
5119 CommitOptions::default(),
5120 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5121 Arc::new(test_commit_envs()),
5122 )
5123 .await
5124 .unwrap();
5125
5126 // Now modify files in index but not yet committed
5127 smol::fs::write(&file1_path, "file1 index contents")
5128 .await
5129 .unwrap();
5130 repo.stage_paths(vec![repo_path("file1")], Arc::new(HashMap::default()))
5131 .await
5132 .unwrap();
5133
5134 // Write working tree contents (not indexed, not committed)
5135 smol::fs::write(&file1_path, "file1 worktree contents")
5136 .await
5137 .unwrap();
5138
5139 // Now test load_revisions
5140 let results = repo
5141 .load_revisions(
5142 [
5143 "HEAD:file1",
5144 ":file1",
5145 "HEAD:file2",
5146 ":file2",
5147 "HEAD:nonexistent",
5148 "HEAD:file with spaces",
5149 "HEAD:nonexistent file with spaces",
5150 ]
5151 .into_iter()
5152 .map(String::from)
5153 .collect(),
5154 )
5155 .await
5156 .unwrap();
5157
5158 assert_eq!(
5159 results,
5160 vec![
5161 Some("file1 committed contents".into()),
5162 Some("file1 index contents".into()),
5163 Some("file2 committed contents".into()),
5164 Some("file2 committed contents".into()), // untouched in index, should match HEAD
5165 None,
5166 Some("space file committed contents".into()),
5167 None,
5168 ]
5169 );
5170 }
5171
5172 #[gpui::test]
5173 async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
5174 disable_git_global_config();
5175
5176 cx.executor().allow_parking();
5177
5178 let repo_dir = tempfile::tempdir().unwrap();
5179 git_init_repo(repo_dir.path());
5180 let repo = RealGitRepository::new(
5181 &repo_dir.path().join(".git"),
5182 None,
5183 Some("git".into()),
5184 cx.executor(),
5185 )
5186 .unwrap();
5187
5188 smol::fs::write(repo_dir.path().join("foo"), "foo")
5189 .await
5190 .unwrap();
5191 let checkpoint_sha = repo.checkpoint().await.unwrap();
5192
5193 // Ensure the user can't see any branches after creating a checkpoint.
5194 assert_eq!(repo.branches().await.unwrap().branches.len(), 1);
5195
5196 smol::fs::write(repo_dir.path().join("foo"), "bar")
5197 .await
5198 .unwrap();
5199 smol::fs::write(repo_dir.path().join("baz"), "qux")
5200 .await
5201 .unwrap();
5202 repo.restore_checkpoint(checkpoint_sha).await.unwrap();
5203 assert_eq!(
5204 smol::fs::read_to_string(repo_dir.path().join("foo"))
5205 .await
5206 .unwrap(),
5207 "foo"
5208 );
5209 // See TODOs above
5210 // assert_eq!(
5211 // smol::fs::read_to_string(repo_dir.path().join("baz"))
5212 // .await
5213 // .ok(),
5214 // None
5215 // );
5216 }
5217
5218 #[gpui::test]
5219 async fn test_branches_return_head_when_commit_metadata_cannot_be_read(
5220 cx: &mut TestAppContext,
5221 ) {
5222 disable_git_global_config();
5223
5224 cx.executor().allow_parking();
5225
5226 let repo_dir = tempfile::tempdir().unwrap();
5227 git_init_repo(repo_dir.path());
5228 let repo = RealGitRepository::new(
5229 &repo_dir.path().join(".git"),
5230 None,
5231 Some("git".into()),
5232 cx.executor(),
5233 )
5234 .unwrap();
5235
5236 smol::fs::write(repo_dir.path().join("file.txt"), "content")
5237 .await
5238 .unwrap();
5239 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
5240 .await
5241 .unwrap();
5242 repo.commit(
5243 "Initial commit".into(),
5244 None,
5245 CommitOptions::default(),
5246 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5247 Arc::new(test_commit_envs()),
5248 )
5249 .await
5250 .unwrap();
5251
5252 smol::fs::write(
5253 repo_dir.path().join(".git").join("refs/heads/broken"),
5254 "0a103ede22f159c792dc6405e0c8304d9bd4dc29\n",
5255 )
5256 .await
5257 .unwrap();
5258
5259 let branches_scan = repo.branches().await.unwrap();
5260 assert!(branches_scan.error.is_some());
5261 let head_branch = branches_scan
5262 .branches
5263 .iter()
5264 .find(|branch| branch.is_head)
5265 .expect("branch list should include HEAD");
5266 assert!(head_branch.ref_name.starts_with("refs/heads/"));
5267
5268 assert!(
5269 branches_scan
5270 .branches
5271 .iter()
5272 .all(|branch| branch.ref_name.as_ref() != "refs/heads/broken")
5273 );
5274 }
5275
5276 #[gpui::test]
5277 async fn test_compare_checkpoints(cx: &mut TestAppContext) {
5278 disable_git_global_config();
5279
5280 cx.executor().allow_parking();
5281
5282 let repo_dir = tempfile::tempdir().unwrap();
5283 git_init_repo(repo_dir.path());
5284 let repo = RealGitRepository::new(
5285 &repo_dir.path().join(".git"),
5286 None,
5287 Some("git".into()),
5288 cx.executor(),
5289 )
5290 .unwrap();
5291
5292 smol::fs::write(repo_dir.path().join("file1"), "content1")
5293 .await
5294 .unwrap();
5295 let checkpoint1 = repo.checkpoint().await.unwrap();
5296
5297 smol::fs::write(repo_dir.path().join("file2"), "content2")
5298 .await
5299 .unwrap();
5300 let checkpoint2 = repo.checkpoint().await.unwrap();
5301
5302 assert!(
5303 !repo
5304 .compare_checkpoints(checkpoint1, checkpoint2.clone())
5305 .await
5306 .unwrap()
5307 );
5308
5309 let checkpoint3 = repo.checkpoint().await.unwrap();
5310 assert!(
5311 repo.compare_checkpoints(checkpoint2, checkpoint3)
5312 .await
5313 .unwrap()
5314 );
5315 }
5316
5317 #[gpui::test]
5318 async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
5319 disable_git_global_config();
5320
5321 cx.executor().allow_parking();
5322
5323 let repo_dir = tempfile::tempdir().unwrap();
5324 let text_path = repo_dir.path().join("main.rs");
5325 let bin_path = repo_dir.path().join("binary.o");
5326
5327 git_init_repo(repo_dir.path());
5328
5329 smol::fs::write(&text_path, "fn main() {}").await.unwrap();
5330
5331 smol::fs::write(&bin_path, "some binary file here")
5332 .await
5333 .unwrap();
5334
5335 let repo = RealGitRepository::new(
5336 &repo_dir.path().join(".git"),
5337 None,
5338 Some("git".into()),
5339 cx.executor(),
5340 )
5341 .unwrap();
5342
5343 // initial commit
5344 repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
5345 .await
5346 .unwrap();
5347 repo.commit(
5348 "Initial commit".into(),
5349 None,
5350 CommitOptions::default(),
5351 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5352 Arc::new(test_commit_envs()),
5353 )
5354 .await
5355 .unwrap();
5356
5357 let checkpoint = repo.checkpoint().await.unwrap();
5358
5359 smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
5360 .await
5361 .unwrap();
5362 smol::fs::write(&bin_path, "Modified binary file")
5363 .await
5364 .unwrap();
5365
5366 repo.restore_checkpoint(checkpoint).await.unwrap();
5367
5368 // Text files should be restored to checkpoint state,
5369 // but binaries should not (they aren't tracked)
5370 assert_eq!(
5371 smol::fs::read_to_string(&text_path).await.unwrap(),
5372 "fn main() {}"
5373 );
5374
5375 assert_eq!(
5376 smol::fs::read_to_string(&bin_path).await.unwrap(),
5377 "Modified binary file"
5378 );
5379 }
5380
5381 #[test]
5382 fn test_branches_parsing() {
5383 // suppress "help: octal escapes are not supported, `\0` is always null"
5384 #[allow(clippy::octal_escapes)]
5385 let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
5386 assert_eq!(
5387 parse_branch_input(input).unwrap(),
5388 vec![Branch {
5389 is_head: true,
5390 ref_name: "refs/heads/zed-patches".into(),
5391 upstream: Some(Upstream {
5392 ref_name: "refs/remotes/origin/zed-patches".into(),
5393 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5394 ahead: 0,
5395 behind: 0
5396 })
5397 }),
5398 most_recent_commit: Some(CommitSummary {
5399 sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
5400 subject: "generated protobuf".into(),
5401 commit_timestamp: 1733187470,
5402 author_name: SharedString::new_static("John Doe"),
5403 has_parent: false,
5404 })
5405 }]
5406 )
5407 }
5408
5409 #[test]
5410 fn test_branches_parsing_containing_refs_with_missing_fields() {
5411 #[allow(clippy::octal_escapes)]
5412 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";
5413
5414 let branches = parse_branch_input(input).unwrap();
5415 assert_eq!(branches.len(), 2);
5416 assert_eq!(
5417 branches,
5418 vec![
5419 Branch {
5420 is_head: false,
5421 ref_name: "refs/heads/dev".into(),
5422 upstream: None,
5423 most_recent_commit: Some(CommitSummary {
5424 sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
5425 subject: "Add feature".into(),
5426 commit_timestamp: 1762948725,
5427 author_name: SharedString::new_static("Zed"),
5428 has_parent: true,
5429 })
5430 },
5431 Branch {
5432 is_head: true,
5433 ref_name: "refs/heads/main".into(),
5434 upstream: None,
5435 most_recent_commit: Some(CommitSummary {
5436 sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
5437 subject: "Initial commit".into(),
5438 commit_timestamp: 1762948695,
5439 author_name: SharedString::new_static("Zed"),
5440 has_parent: false,
5441 })
5442 }
5443 ]
5444 )
5445 }
5446
5447 #[test]
5448 fn test_upstream_branch_name() {
5449 let upstream = Upstream {
5450 ref_name: "refs/remotes/origin/feature/branch".into(),
5451 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5452 ahead: 0,
5453 behind: 0,
5454 }),
5455 };
5456 assert_eq!(upstream.branch_name(), Some("feature/branch"));
5457
5458 let upstream = Upstream {
5459 ref_name: "refs/remotes/upstream/main".into(),
5460 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5461 ahead: 0,
5462 behind: 0,
5463 }),
5464 };
5465 assert_eq!(upstream.branch_name(), Some("main"));
5466
5467 let upstream = Upstream {
5468 ref_name: "refs/heads/local".into(),
5469 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5470 ahead: 0,
5471 behind: 0,
5472 }),
5473 };
5474 assert_eq!(upstream.branch_name(), None);
5475
5476 // Test case where upstream branch name differs from what might be the local branch name
5477 let upstream = Upstream {
5478 ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
5479 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5480 ahead: 0,
5481 behind: 0,
5482 }),
5483 };
5484 assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
5485 }
5486
5487 #[test]
5488 fn test_parse_worktrees_from_str() {
5489 // Empty input
5490 let result = parse_worktrees_from_str("", None);
5491 assert!(result.is_empty());
5492
5493 // Single worktree (main)
5494 let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n";
5495 let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5496 assert_eq!(result.len(), 1);
5497 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5498 assert_eq!(result[0].sha.as_ref(), "abc123def");
5499 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5500 assert!(result[0].is_main);
5501 assert!(!result[0].is_bare);
5502
5503 // Multiple worktrees
5504 let input = "worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n\
5505 worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n";
5506 let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5507 assert_eq!(result.len(), 2);
5508 assert_eq!(result[0].path, PathBuf::from("/home/user/project-wt"));
5509 assert_eq!(result[0].ref_name, Some("refs/heads/feature".into()));
5510 assert!(!result[0].is_main);
5511 assert!(!result[0].is_bare);
5512 assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
5513 assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
5514 assert!(result[1].is_main);
5515 assert!(!result[1].is_bare);
5516
5517 // Detached HEAD entry (included with ref_name: None)
5518 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
5519 worktree /home/user/detached\nHEAD def456\ndetached\n\n";
5520 let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5521 assert_eq!(result.len(), 2);
5522 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5523 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5524 assert!(result[0].is_main);
5525 assert_eq!(result[1].path, PathBuf::from("/home/user/detached"));
5526 assert_eq!(result[1].ref_name, None);
5527 assert_eq!(result[1].sha.as_ref(), "def456");
5528 assert!(!result[1].is_main);
5529 assert!(!result[1].is_bare);
5530
5531 // Bare repo entry with no main worktree.
5532 let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
5533 worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
5534 let result = parse_worktrees_from_str(input, None);
5535 assert_eq!(result.len(), 2);
5536 assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
5537 assert_eq!(result[0].ref_name, None);
5538 assert!(!result[0].is_main);
5539 assert!(result[0].is_bare);
5540 assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
5541 assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
5542 assert!(!result[1].is_main);
5543 assert!(!result[1].is_bare);
5544
5545 // Extra porcelain lines (locked, prunable) should be ignored
5546 let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
5547 worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\
5548 worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n";
5549 let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5550 assert_eq!(result.len(), 3);
5551 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5552 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5553 assert!(result[0].is_main);
5554 assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
5555 assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into()));
5556 assert!(!result[1].is_main);
5557 assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
5558 assert_eq!(
5559 result[2].ref_name,
5560 Some("refs/heads/prunable-branch".into())
5561 );
5562 assert!(!result[2].is_main);
5563
5564 // Leading/trailing whitespace on lines should be tolerated
5565 let input =
5566 " worktree /home/user/project \n HEAD abc123 \n branch refs/heads/main \n\n";
5567 let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5568 assert_eq!(result.len(), 1);
5569 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5570 assert_eq!(result[0].sha.as_ref(), "abc123");
5571 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5572 assert!(result[0].is_main);
5573
5574 // Windows-style line endings should be handled
5575 let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n";
5576 let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5577 assert_eq!(result.len(), 1);
5578 assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5579 assert_eq!(result[0].sha.as_ref(), "abc123");
5580 assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5581 assert!(result[0].is_main);
5582 }
5583
5584 #[gpui::test]
5585 async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
5586 disable_git_global_config();
5587 cx.executor().allow_parking();
5588
5589 let temp_dir = tempfile::tempdir().unwrap();
5590 let repo_dir = temp_dir.path().join("repo");
5591 let worktrees_dir = temp_dir.path().join("worktrees");
5592
5593 fs::create_dir_all(&repo_dir).unwrap();
5594 fs::create_dir_all(&worktrees_dir).unwrap();
5595
5596 git_init_repo(&repo_dir);
5597
5598 let repo = RealGitRepository::new(
5599 &repo_dir.join(".git"),
5600 None,
5601 Some("git".into()),
5602 cx.executor(),
5603 )
5604 .unwrap();
5605
5606 // Create an initial commit (required for worktrees)
5607 smol::fs::write(repo_dir.join("file.txt"), "content")
5608 .await
5609 .unwrap();
5610 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
5611 .await
5612 .unwrap();
5613 repo.commit(
5614 "Initial commit".into(),
5615 None,
5616 CommitOptions::default(),
5617 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5618 Arc::new(test_commit_envs()),
5619 )
5620 .await
5621 .unwrap();
5622
5623 // List worktrees — should have just the main one
5624 let worktrees = repo.worktrees().await.unwrap();
5625 assert_eq!(worktrees.len(), 1);
5626 assert_eq!(
5627 worktrees[0].path.canonicalize().unwrap(),
5628 repo_dir.canonicalize().unwrap()
5629 );
5630
5631 let worktree_path = worktrees_dir.join("some-worktree");
5632
5633 // Create a new worktree
5634 repo.create_worktree(
5635 CreateWorktreeTarget::NewBranch {
5636 branch_name: "test-branch".to_string(),
5637 base_sha: Some("HEAD".to_string()),
5638 },
5639 worktree_path.clone(),
5640 )
5641 .await
5642 .unwrap();
5643
5644 // List worktrees — should have two
5645 let worktrees = repo.worktrees().await.unwrap();
5646 assert_eq!(worktrees.len(), 2);
5647
5648 let new_worktree = worktrees
5649 .iter()
5650 .find(|w| w.display_name() == "test-branch")
5651 .expect("should find worktree with test-branch");
5652 assert_eq!(
5653 new_worktree.path.canonicalize().unwrap(),
5654 worktree_path.canonicalize().unwrap(),
5655 );
5656
5657 // The new worktree's git metadata directory should report a creation
5658 // time, resolved via the worktree's `.git` file.
5659 let created_at = repo
5660 .worktree_created_at(worktree_path.clone())
5661 .await
5662 .unwrap();
5663 assert!(
5664 created_at.is_some(),
5665 "creation time should be available for a freshly created worktree"
5666 );
5667
5668 // A path with no worktree at all reports `None`.
5669 let missing = repo
5670 .worktree_created_at(worktrees_dir.join("does-not-exist"))
5671 .await
5672 .unwrap();
5673 assert_eq!(missing, None);
5674 }
5675
5676 #[gpui::test]
5677 async fn test_remove_worktree(cx: &mut TestAppContext) {
5678 disable_git_global_config();
5679 cx.executor().allow_parking();
5680
5681 let temp_dir = tempfile::tempdir().unwrap();
5682 let repo_dir = temp_dir.path().join("repo");
5683 let worktrees_dir = temp_dir.path().join("worktrees");
5684 git_init_repo(&repo_dir);
5685
5686 let repo = RealGitRepository::new(
5687 &repo_dir.join(".git"),
5688 None,
5689 Some("git".into()),
5690 cx.executor(),
5691 )
5692 .unwrap();
5693
5694 // Create an initial commit
5695 smol::fs::write(repo_dir.join("file.txt"), "content")
5696 .await
5697 .unwrap();
5698 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
5699 .await
5700 .unwrap();
5701 repo.commit(
5702 "Initial commit".into(),
5703 None,
5704 CommitOptions::default(),
5705 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5706 Arc::new(test_commit_envs()),
5707 )
5708 .await
5709 .unwrap();
5710
5711 // Create a worktree
5712 let worktree_path = worktrees_dir.join("worktree-to-remove");
5713 repo.create_worktree(
5714 CreateWorktreeTarget::NewBranch {
5715 branch_name: "to-remove".to_string(),
5716 base_sha: Some("HEAD".to_string()),
5717 },
5718 worktree_path.clone(),
5719 )
5720 .await
5721 .unwrap();
5722
5723 // Remove the worktree
5724 repo.remove_worktree(worktree_path.clone(), false)
5725 .await
5726 .unwrap();
5727
5728 // Verify the directory is removed
5729 let worktrees = repo.worktrees().await.unwrap();
5730 assert_eq!(worktrees.len(), 1);
5731 assert!(
5732 worktrees.iter().all(|w| w.display_name() != "to-remove"),
5733 "removed worktree should not appear in list"
5734 );
5735 assert!(!worktree_path.exists());
5736
5737 // Create a worktree
5738 let worktree_path = worktrees_dir.join("dirty-wt");
5739 repo.create_worktree(
5740 CreateWorktreeTarget::NewBranch {
5741 branch_name: "dirty-wt".to_string(),
5742 base_sha: Some("HEAD".to_string()),
5743 },
5744 worktree_path.clone(),
5745 )
5746 .await
5747 .unwrap();
5748
5749 assert!(worktree_path.exists());
5750
5751 // Add uncommitted changes in the worktree
5752 smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
5753 .await
5754 .unwrap();
5755
5756 // Non-force removal should fail with dirty worktree
5757 let result = repo.remove_worktree(worktree_path.clone(), false).await;
5758 assert!(
5759 result.is_err(),
5760 "non-force removal of dirty worktree should fail"
5761 );
5762
5763 // Force removal should succeed
5764 repo.remove_worktree(worktree_path.clone(), true)
5765 .await
5766 .unwrap();
5767
5768 let worktrees = repo.worktrees().await.unwrap();
5769 assert_eq!(worktrees.len(), 1);
5770 assert!(!worktree_path.exists());
5771 }
5772
5773 #[gpui::test]
5774 async fn test_rename_worktree(cx: &mut TestAppContext) {
5775 disable_git_global_config();
5776 cx.executor().allow_parking();
5777
5778 let temp_dir = tempfile::tempdir().unwrap();
5779 let repo_dir = temp_dir.path().join("repo");
5780 let worktrees_dir = temp_dir.path().join("worktrees");
5781
5782 git_init_repo(&repo_dir);
5783
5784 let repo = RealGitRepository::new(
5785 &repo_dir.join(".git"),
5786 None,
5787 Some("git".into()),
5788 cx.executor(),
5789 )
5790 .unwrap();
5791
5792 // Create an initial commit
5793 smol::fs::write(repo_dir.join("file.txt"), "content")
5794 .await
5795 .unwrap();
5796 repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
5797 .await
5798 .unwrap();
5799 repo.commit(
5800 "Initial commit".into(),
5801 None,
5802 CommitOptions::default(),
5803 AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5804 Arc::new(test_commit_envs()),
5805 )
5806 .await
5807 .unwrap();
5808
5809 // Create a worktree
5810 let old_path = worktrees_dir.join("old-worktree-name");
5811 repo.create_worktree(
5812 CreateWorktreeTarget::NewBranch {
5813 branch_name: "old-name".to_string(),
5814 base_sha: Some("HEAD".to_string()),
5815 },
5816 old_path.clone(),
5817 )
5818 .await
5819 .unwrap();
5820
5821 assert!(old_path.exists());
5822
5823 // Move the worktree to a new path
5824 let new_path = worktrees_dir.join("new-worktree-name");
5825 repo.rename_worktree(old_path.clone(), new_path.clone())
5826 .await
5827 .unwrap();
5828
5829 // Verify the old path is gone and new path exists
5830 assert!(!old_path.exists());
5831 assert!(new_path.exists());
5832
5833 // Verify it shows up in worktree list at the new path
5834 let worktrees = repo.worktrees().await.unwrap();
5835 assert_eq!(worktrees.len(), 2);
5836 let moved_worktree = worktrees
5837 .iter()
5838 .find(|w| w.display_name() == "old-name")
5839 .expect("should find worktree by branch name");
5840 assert_eq!(
5841 moved_worktree.path.canonicalize().unwrap(),
5842 new_path.canonicalize().unwrap()
5843 );
5844 }
5845
5846 #[gpui::test]
5847 async fn test_initial_graph_data_ref_set(cx: &mut TestAppContext) {
5848 disable_git_global_config();
5849 cx.executor().allow_parking();
5850
5851 let repo_dir = tempfile::tempdir().unwrap();
5852 git_init_repo(repo_dir.path());
5853
5854 let repo = RealGitRepository::new(
5855 &repo_dir.path().join(".git"),
5856 None,
5857 Some("git".into()),
5858 cx.executor(),
5859 )
5860 .unwrap();
5861 let git = repo.git_binary();
5862
5863 let graph_commits = async || {
5864 let (tx, rx) = smol::channel::unbounded();
5865 repo.initial_graph_data(LogSource::All, LogOrder::DateOrder, tx)
5866 .await
5867 .unwrap();
5868 let mut commits = std::collections::HashSet::new();
5869 while let Ok(chunk) = rx.try_recv() {
5870 for commit in chunk {
5871 commits.insert(commit.sha);
5872 }
5873 }
5874 commits
5875 };
5876
5877 smol::fs::write(repo_dir.path().join("file1"), "1")
5878 .await
5879 .unwrap();
5880 let branch_sha = repo.checkpoint().await.unwrap().commit_sha;
5881 repo.update_ref("refs/heads/main".into(), branch_sha.to_string())
5882 .await
5883 .unwrap();
5884
5885 smol::fs::write(repo_dir.path().join("file2"), "2")
5886 .await
5887 .unwrap();
5888 let hidden_sha = repo.checkpoint().await.unwrap().commit_sha;
5889 repo.update_ref("refs/custom/hidden".into(), hidden_sha.to_string())
5890 .await
5891 .unwrap();
5892
5893 let graph = graph_commits().await;
5894 assert!(graph.contains(&branch_sha));
5895 assert!(!graph.contains(&hidden_sha));
5896
5897 git.build_command(&["update-ref", "--no-deref", "HEAD", &hidden_sha.to_string()])
5898 .output()
5899 .await
5900 .unwrap();
5901
5902 let graph = graph_commits().await;
5903 assert!(graph.contains(&branch_sha));
5904 assert!(graph.contains(&hidden_sha));
5905 }
5906
5907 #[gpui::test]
5908 async fn test_check_for_pushed_commit(cx: &mut TestAppContext) {
5909 disable_git_global_config();
5910 cx.executor().allow_parking();
5911
5912 let temp_dir = tempfile::tempdir().unwrap();
5913 let repo_dir = temp_dir.path().join("repo");
5914 git_init_repo(&repo_dir);
5915
5916 let repo = RealGitRepository::new(
5917 &repo_dir.join(".git"),
5918 None,
5919 Some("git".into()),
5920 cx.executor(),
5921 )
5922 .unwrap();
5923
5924 // New repo doesn't have any commits yet
5925 assert!(repo.check_for_pushed_commit().await.unwrap().is_empty());
5926
5927 git_command(
5928 &repo_dir,
5929 ["commit", "--allow-empty", "-m", "Initial commit"],
5930 );
5931
5932 // No remote branches exist yet
5933 assert!(repo.check_for_pushed_commit().await.unwrap().is_empty());
5934
5935 // Create simulated remote branches
5936 git_command(
5937 &repo_dir,
5938 ["update-ref", "refs/remotes/origin/main", "HEAD"],
5939 );
5940 git_command(
5941 &repo_dir,
5942 ["update-ref", "refs/remotes/origin/other-branch", "HEAD"],
5943 );
5944 assert_eq!(
5945 repo.check_for_pushed_commit().await.unwrap(),
5946 vec![
5947 SharedString::from("origin/main"),
5948 SharedString::from("origin/other-branch")
5949 ]
5950 );
5951
5952 // Switch to a new branch, commit but do not push
5953 git_command(&repo_dir, ["switch", "-c", "local-feature"]);
5954 git_command(&repo_dir, ["commit", "--allow-empty", "-m", "Local commit"]);
5955
5956 // New commit has not been pushed
5957 assert!(repo.check_for_pushed_commit().await.unwrap().is_empty());
5958 }
5959
5960 #[test]
5961 fn test_original_repo_path_from_common_dir() {
5962 // Normal repo: common_dir is <work_dir>/.git
5963 assert_eq!(
5964 original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
5965 Some(PathBuf::from("/code/zed5"))
5966 );
5967
5968 // Worktree: common_dir is the main repo's .git
5969 // (same result — that's the point, it always traces back to the original)
5970 assert_eq!(
5971 original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
5972 Some(PathBuf::from("/code/zed5"))
5973 );
5974
5975 // Bare repo: no .git suffix, returns None (no working-tree root)
5976 assert_eq!(
5977 original_repo_path_from_common_dir(Path::new("/code/zed5.git")),
5978 None
5979 );
5980
5981 // Root-level .git directory
5982 assert_eq!(
5983 original_repo_path_from_common_dir(Path::new("/.git")),
5984 Some(PathBuf::from("/"))
5985 );
5986 }
5987
5988 #[gpui::test]
5989 async fn test_default_branch(cx: &mut TestAppContext) {
5990 disable_git_global_config();
5991 cx.executor().allow_parking();
5992
5993 let repo_dir = tempfile::tempdir().unwrap();
5994 git_init_repo(repo_dir.path());
5995
5996 let repo = RealGitRepository::new(
5997 &repo_dir.path().join(".git"),
5998 None,
5999 Some("git".into()),
6000 cx.executor(),
6001 )
6002 .unwrap();
6003
6004 assert_eq!(repo.default_branch(false).await.unwrap(), None);
6005
6006 git_command(
6007 repo_dir.path(),
6008 ["commit", "--allow-empty", "-m", "Initial commit"],
6009 );
6010
6011 assert_eq!(
6012 repo.default_branch(false).await.unwrap(),
6013 Some("main".into())
6014 );
6015
6016 git_command(
6017 repo_dir.path(),
6018 ["update-ref", "refs/remotes/origin/main", "HEAD"],
6019 );
6020 git_command(
6021 repo_dir.path(),
6022 [
6023 "symbolic-ref",
6024 "refs/remotes/origin/HEAD",
6025 "refs/remotes/origin/main",
6026 ],
6027 );
6028
6029 assert_eq!(
6030 repo.default_branch(false).await.unwrap(),
6031 Some("main".into())
6032 );
6033 assert_eq!(
6034 repo.default_branch(true).await.unwrap(),
6035 Some("origin/main".into())
6036 );
6037 }
6038
6039 impl RealGitRepository {
6040 /// Force a Git garbage collection on the repository.
6041 fn gc(&self) -> BoxFuture<'_, Result<()>> {
6042 let working_directory = self.command_directory();
6043 let git_directory = self.path();
6044 let git_binary_path = self.any_git_binary_path.clone();
6045 let executor = self.executor.clone();
6046 self.executor
6047 .spawn(async move {
6048 let git_binary_path = git_binary_path.clone();
6049 let git = GitBinary::new(
6050 git_binary_path,
6051 working_directory,
6052 git_directory,
6053 executor,
6054 true,
6055 );
6056 git.run(&["gc", "--prune"]).await?;
6057 Ok(())
6058 })
6059 .boxed()
6060 }
6061 }
6062
6063 #[gpui::test]
6064 async fn test_remote_urls(cx: &mut TestAppContext) {
6065 disable_git_global_config();
6066 cx.executor().allow_parking();
6067
6068 let temp_dir = tempfile::tempdir().unwrap();
6069 let repo_dir = temp_dir.path().join("repo");
6070 std::fs::create_dir_all(&repo_dir).unwrap();
6071
6072 git_init_repo(&repo_dir);
6073
6074 let repo = RealGitRepository::new(
6075 &repo_dir.join(".git"),
6076 None,
6077 Some("git".into()),
6078 cx.executor(),
6079 )
6080 .unwrap();
6081
6082 let git = repo.git_binary();
6083 git.run(&[
6084 "remote",
6085 "add",
6086 "origin",
6087 "https://github.com/zed-industries/zed.git",
6088 ])
6089 .await
6090 .unwrap();
6091 git.run(&[
6092 "remote",
6093 "add",
6094 "upstream",
6095 "/Users/user/My Projects/upstream.git",
6096 ])
6097 .await
6098 .unwrap();
6099
6100 let remote_urls = repo.remote_urls().await;
6101 assert_eq!(remote_urls.len(), 2);
6102 assert_eq!(
6103 remote_urls.get("origin").unwrap(),
6104 "https://github.com/zed-industries/zed.git"
6105 );
6106 assert_eq!(
6107 remote_urls.get("upstream").unwrap(),
6108 "/Users/user/My Projects/upstream.git"
6109 );
6110 }
6111}
6112