mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Git's `-d` flag deletes a branch only if it's fully merged into its upstream or HEAD - this is what we were using before, which caused the "not fully merged" error. The `-D` flag force deletes a branch even with unmerged changes (equivalent to `--delete --force`). ### Before Deleting an unmerged branch failed with a "not fully merged" error toast. ### After - Deleting an unmerged branch prompts for confirmation to force delete - Delete button tooltip shows "Hold alt to force delete" hint - Holding **alt** turns the delete icon red and tooltip changes to "Force Delete Branch" - Force delete keybinding: `cmd-alt-shift-backspace` Release Notes: - Added confirmation prompt when deleting unmerged git branches, with option to force delete. - Added alt+click on delete button to force delete a branch immediately.
4543 lines
151 KiB
Rust
4543 lines
151 KiB
Rust
use crate::commit::parse_git_diff_name_status;
|
|
use crate::stash::GitStash;
|
|
use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
|
|
use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
|
|
use anyhow::{Context as _, Result, anyhow, bail};
|
|
use async_channel::Sender;
|
|
use collections::HashMap;
|
|
use futures::channel::oneshot;
|
|
use futures::future::BoxFuture;
|
|
use futures::io::BufWriter;
|
|
use futures::{AsyncWriteExt, FutureExt as _, select_biased};
|
|
use git2::{BranchType, ErrorCode};
|
|
use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
|
|
use parking_lot::Mutex;
|
|
use rope::Rope;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use smallvec::SmallVec;
|
|
use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
|
|
use text::LineEnding;
|
|
|
|
use std::collections::HashSet;
|
|
use std::ffi::{OsStr, OsString};
|
|
use std::sync::atomic::AtomicBool;
|
|
|
|
use std::process::ExitStatus;
|
|
use std::str::FromStr;
|
|
use std::{
|
|
cmp::Ordering,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
};
|
|
use sum_tree::MapSeekTarget;
|
|
use thiserror::Error;
|
|
use util::command::{Stdio, new_command};
|
|
use util::paths::PathStyle;
|
|
use util::rel_path::RelPath;
|
|
use util::{ResultExt, paths};
|
|
use uuid::Uuid;
|
|
|
|
pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
|
|
|
|
pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
|
|
|
|
/// Format string used in graph log to get initial data for the git graph
|
|
/// %H - Full commit hash
|
|
/// %P - Parent hashes
|
|
/// %D - Ref names
|
|
/// %x00 - Null byte separator, used to split up commit data
|
|
static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D";
|
|
|
|
/// Used to get commits that match with a search
|
|
/// %H - Full commit hash
|
|
static SEARCH_COMMIT_FORMAT: &str = "--format=%H";
|
|
|
|
/// Number of commits to load per chunk for the git graph.
|
|
pub const GRAPH_CHUNK_SIZE: usize = 1000;
|
|
|
|
/// Default value for the `git.worktree_directory` setting.
|
|
pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees";
|
|
|
|
/// Given the git common directory (from `commondir()`), derive the original
|
|
/// repository's working directory.
|
|
///
|
|
/// For a standard checkout, `common_dir` is `<work_dir>/.git`, so the parent
|
|
/// is the working directory. For a git worktree, `common_dir` is the **main**
|
|
/// repo's `.git` directory, so the parent is the original repo's working directory.
|
|
///
|
|
/// Returns `None` if `common_dir` doesn't end with `.git` (e.g. bare repos),
|
|
/// because there is no working-tree root to resolve to in that case.
|
|
pub fn original_repo_path_from_common_dir(common_dir: &Path) -> Option<PathBuf> {
|
|
if common_dir.file_name() == Some(OsStr::new(".git")) {
|
|
common_dir.parent().map(|p| p.to_path_buf())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Commit data needed for the git graph visualization.
|
|
#[derive(Debug, Clone)]
|
|
pub struct CommitData {
|
|
pub sha: Oid,
|
|
/// Most commits have a single parent, so we use a SmallVec to avoid allocations.
|
|
pub parents: SmallVec<[Oid; 1]>,
|
|
pub author_name: SharedString,
|
|
pub author_email: SharedString,
|
|
pub commit_timestamp: i64,
|
|
pub subject: SharedString,
|
|
pub message: SharedString,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct InitialGraphCommitData {
|
|
pub sha: Oid,
|
|
pub parents: SmallVec<[Oid; 1]>,
|
|
pub ref_names: Vec<SharedString>,
|
|
}
|
|
|
|
struct CommitDataRequest {
|
|
sha: Oid,
|
|
response_tx: oneshot::Sender<Result<CommitData>>,
|
|
}
|
|
|
|
pub struct CommitDataReader {
|
|
request_tx: async_channel::Sender<CommitDataRequest>,
|
|
_task: Task<()>,
|
|
}
|
|
|
|
impl CommitDataReader {
|
|
pub async fn read(&self, sha: Oid) -> Result<CommitData> {
|
|
let (response_tx, response_rx) = oneshot::channel();
|
|
self.request_tx
|
|
.send(CommitDataRequest { sha, response_tx })
|
|
.await
|
|
.map_err(|_| anyhow!("commit data reader task closed"))?;
|
|
response_rx
|
|
.await
|
|
.map_err(|_| anyhow!("commit data reader task dropped response"))?
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn for_test(
|
|
executor: BackgroundExecutor,
|
|
resolve: impl 'static + Send + Sync + Fn(Oid) -> Result<CommitData>,
|
|
) -> Self {
|
|
let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
|
|
let resolve = Arc::new(resolve);
|
|
let delay_executor = executor.clone();
|
|
let task = executor.spawn(async move {
|
|
while let Ok(CommitDataRequest { sha, response_tx }) = request_rx.recv().await {
|
|
delay_executor.simulate_random_delay().await;
|
|
response_tx.send(resolve(sha)).ok();
|
|
}
|
|
});
|
|
|
|
Self {
|
|
request_tx,
|
|
_task: task,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_cat_file_commit(sha: Oid, content: &str) -> Option<CommitData> {
|
|
let mut parents = SmallVec::new();
|
|
let mut author_name = SharedString::default();
|
|
let mut author_email = SharedString::default();
|
|
let mut commit_timestamp = 0i64;
|
|
let mut in_headers = true;
|
|
let mut subject = None;
|
|
let mut message_lines = Vec::new();
|
|
|
|
for line in content.lines() {
|
|
if in_headers {
|
|
if line.is_empty() {
|
|
in_headers = false;
|
|
continue;
|
|
}
|
|
|
|
if let Some(parent_sha) = line.strip_prefix("parent ") {
|
|
if let Ok(oid) = Oid::from_str(parent_sha.trim()) {
|
|
parents.push(oid);
|
|
}
|
|
} else if let Some(author_line) = line.strip_prefix("author ") {
|
|
if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') {
|
|
if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') {
|
|
if let Ok(ts) = timestamp_str.parse::<i64>() {
|
|
commit_timestamp = ts;
|
|
}
|
|
if let Some((name, email)) = name_email.rsplit_once(" <") {
|
|
author_name = SharedString::from(name.to_string());
|
|
author_email =
|
|
SharedString::from(email.trim_end_matches('>').to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if subject.is_none() {
|
|
subject = Some(SharedString::from(line.to_string()));
|
|
}
|
|
message_lines.push(line);
|
|
}
|
|
}
|
|
|
|
Some(CommitData {
|
|
sha,
|
|
parents,
|
|
author_name,
|
|
author_email,
|
|
commit_timestamp,
|
|
subject: subject.unwrap_or_default(),
|
|
message: SharedString::from(message_lines.join("\n")),
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
|
pub struct Branch {
|
|
pub is_head: bool,
|
|
pub ref_name: SharedString,
|
|
pub upstream: Option<Upstream>,
|
|
pub most_recent_commit: Option<CommitSummary>,
|
|
}
|
|
|
|
impl Branch {
|
|
pub fn name(&self) -> &str {
|
|
self.ref_name
|
|
.as_ref()
|
|
.strip_prefix("refs/heads/")
|
|
.or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
|
|
.unwrap_or(self.ref_name.as_ref())
|
|
}
|
|
|
|
pub fn is_remote(&self) -> bool {
|
|
self.ref_name.starts_with("refs/remotes/")
|
|
}
|
|
|
|
pub fn remote_name(&self) -> Option<&str> {
|
|
self.ref_name
|
|
.strip_prefix("refs/remotes/")
|
|
.and_then(|stripped| stripped.split("/").next())
|
|
}
|
|
|
|
pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
|
|
self.upstream
|
|
.as_ref()
|
|
.and_then(|upstream| upstream.tracking.status())
|
|
}
|
|
|
|
pub fn priority_key(&self) -> (bool, Option<i64>) {
|
|
(
|
|
self.is_head,
|
|
self.most_recent_commit
|
|
.as_ref()
|
|
.map(|commit| commit.commit_timestamp),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
|
pub struct Worktree {
|
|
pub path: PathBuf,
|
|
pub ref_name: Option<SharedString>,
|
|
// todo(git_worktree) This type should be a Oid
|
|
pub sha: SharedString,
|
|
pub is_main: bool,
|
|
pub is_bare: bool,
|
|
}
|
|
|
|
/// Describes how a new worktree should choose or create its checked-out HEAD.
|
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
|
pub enum CreateWorktreeTarget {
|
|
/// Check out an existing local branch in the new worktree.
|
|
ExistingBranch {
|
|
/// The existing local branch to check out.
|
|
branch_name: String,
|
|
},
|
|
/// Create a new local branch for the new worktree.
|
|
NewBranch {
|
|
/// The new local branch to create and check out.
|
|
branch_name: String,
|
|
/// The commit or ref to create the branch from. Uses `HEAD` when `None`.
|
|
base_sha: Option<String>,
|
|
},
|
|
/// Check out a commit or ref in detached HEAD state.
|
|
Detached {
|
|
/// The commit or ref to check out. Uses `HEAD` when `None`.
|
|
base_sha: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl CreateWorktreeTarget {
|
|
pub fn branch_name(&self) -> Option<&str> {
|
|
match self {
|
|
Self::ExistingBranch { branch_name } | Self::NewBranch { branch_name, .. } => {
|
|
Some(branch_name)
|
|
}
|
|
Self::Detached { .. } => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Worktree {
|
|
/// Returns the branch name if the worktree is attached to a branch.
|
|
pub fn branch_name(&self) -> Option<&str> {
|
|
self.ref_name.as_ref().map(|ref_name| {
|
|
ref_name
|
|
.strip_prefix("refs/heads/")
|
|
.or_else(|| ref_name.strip_prefix("refs/remotes/"))
|
|
.unwrap_or(ref_name)
|
|
})
|
|
}
|
|
|
|
/// Returns a display name for the worktree, suitable for use in the UI.
|
|
///
|
|
/// If the worktree is attached to a branch, returns the branch name.
|
|
/// Otherwise, returns the short SHA of the worktree's HEAD commit.
|
|
pub fn display_name(&self) -> &str {
|
|
self.branch_name()
|
|
.unwrap_or(&self.sha[..self.sha.len().min(SHORT_SHA_LENGTH)])
|
|
}
|
|
|
|
pub fn directory_name(&self, main_worktree_path: Option<&Path>) -> String {
|
|
if self.is_main {
|
|
return "main worktree".to_string();
|
|
}
|
|
|
|
let dir_name = self
|
|
.path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or(self.display_name());
|
|
|
|
if let Some(main_path) = main_worktree_path {
|
|
let main_dir = main_path.file_name().and_then(|n| n.to_str());
|
|
if main_dir == Some(dir_name) {
|
|
if let Some(parent_name) = self
|
|
.path
|
|
.parent()
|
|
.and_then(|p| p.file_name())
|
|
.and_then(|n| n.to_str())
|
|
{
|
|
return parent_name.to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
dir_name.to_string()
|
|
}
|
|
}
|
|
|
|
pub fn parse_worktrees_from_str<T: AsRef<str>>(
|
|
raw_worktrees: T,
|
|
main_worktree_path: Option<&Path>,
|
|
) -> Vec<Worktree> {
|
|
let mut worktrees = Vec::new();
|
|
let normalized = raw_worktrees.as_ref().replace("\r\n", "\n");
|
|
let entries = normalized.split("\n\n");
|
|
for entry in entries {
|
|
let mut path = None;
|
|
let mut sha = None;
|
|
let mut ref_name = None;
|
|
|
|
let mut is_bare = false;
|
|
|
|
for line in entry.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some(rest) = line.strip_prefix("worktree ") {
|
|
path = Some(rest.to_string());
|
|
} else if let Some(rest) = line.strip_prefix("HEAD ") {
|
|
sha = Some(rest.to_string());
|
|
} else if let Some(rest) = line.strip_prefix("branch ") {
|
|
ref_name = Some(rest.to_string());
|
|
} else if line == "bare" {
|
|
is_bare = true;
|
|
}
|
|
// Ignore other lines: detached, locked, prunable, etc.
|
|
}
|
|
|
|
if let (Some(path), Some(sha)) = (path, sha) {
|
|
let path = PathBuf::from(path);
|
|
let is_main =
|
|
main_worktree_path.is_some_and(|main_worktree_path| path == main_worktree_path);
|
|
worktrees.push(Worktree {
|
|
path,
|
|
ref_name: ref_name.map(Into::into),
|
|
sha: sha.into(),
|
|
is_main,
|
|
is_bare,
|
|
});
|
|
}
|
|
}
|
|
|
|
worktrees
|
|
}
|
|
|
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
|
pub struct Upstream {
|
|
pub ref_name: SharedString,
|
|
pub tracking: UpstreamTracking,
|
|
}
|
|
|
|
impl Upstream {
|
|
pub fn is_remote(&self) -> bool {
|
|
self.remote_name().is_some()
|
|
}
|
|
|
|
pub fn remote_name(&self) -> Option<&str> {
|
|
self.ref_name
|
|
.strip_prefix("refs/remotes/")
|
|
.and_then(|stripped| stripped.split("/").next())
|
|
}
|
|
|
|
pub fn stripped_ref_name(&self) -> Option<&str> {
|
|
self.ref_name.strip_prefix("refs/remotes/")
|
|
}
|
|
|
|
pub fn branch_name(&self) -> Option<&str> {
|
|
self.ref_name
|
|
.strip_prefix("refs/remotes/")
|
|
.and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Default)]
|
|
pub struct CommitOptions {
|
|
pub amend: bool,
|
|
pub signoff: bool,
|
|
pub allow_empty: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
|
pub enum UpstreamTracking {
|
|
/// Remote ref not present in local repository.
|
|
Gone,
|
|
/// Remote ref present in local repository (fetched from remote).
|
|
Tracked(UpstreamTrackingStatus),
|
|
}
|
|
|
|
impl From<UpstreamTrackingStatus> for UpstreamTracking {
|
|
fn from(status: UpstreamTrackingStatus) -> Self {
|
|
UpstreamTracking::Tracked(status)
|
|
}
|
|
}
|
|
|
|
impl UpstreamTracking {
|
|
pub fn is_gone(&self) -> bool {
|
|
matches!(self, UpstreamTracking::Gone)
|
|
}
|
|
|
|
pub fn status(&self) -> Option<UpstreamTrackingStatus> {
|
|
match self {
|
|
UpstreamTracking::Gone => None,
|
|
UpstreamTracking::Tracked(status) => Some(*status),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RemoteCommandOutput {
|
|
pub stdout: String,
|
|
pub stderr: String,
|
|
}
|
|
|
|
impl RemoteCommandOutput {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.stdout.is_empty() && self.stderr.is_empty()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
|
pub struct UpstreamTrackingStatus {
|
|
pub ahead: u32,
|
|
pub behind: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
|
pub struct CommitSummary {
|
|
pub sha: SharedString,
|
|
pub subject: SharedString,
|
|
/// This is a unix timestamp
|
|
pub commit_timestamp: i64,
|
|
pub author_name: SharedString,
|
|
pub has_parent: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
|
|
pub struct CommitDetails {
|
|
pub sha: SharedString,
|
|
pub message: SharedString,
|
|
pub commit_timestamp: i64,
|
|
pub author_email: SharedString,
|
|
pub author_name: SharedString,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct CommitDiff {
|
|
pub files: Vec<CommitFile>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum CommitFileStatus {
|
|
Added,
|
|
Modified,
|
|
Deleted,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct CommitFile {
|
|
pub path: RepoPath,
|
|
pub old_text: Option<String>,
|
|
pub new_text: Option<String>,
|
|
pub is_binary: bool,
|
|
}
|
|
|
|
impl CommitFile {
|
|
pub fn status(&self) -> CommitFileStatus {
|
|
match (&self.old_text, &self.new_text) {
|
|
(None, Some(_)) => CommitFileStatus::Added,
|
|
(Some(_), None) => CommitFileStatus::Deleted,
|
|
_ => CommitFileStatus::Modified,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CommitDetails {
|
|
pub fn short_sha(&self) -> SharedString {
|
|
self.sha[..SHORT_SHA_LENGTH].to_string().into()
|
|
}
|
|
}
|
|
|
|
/// Detects if content is binary by checking for NUL bytes in the first 8000 bytes.
|
|
/// This matches git's binary detection heuristic.
|
|
pub fn is_binary_content(content: &[u8]) -> bool {
|
|
let check_len = content.len().min(8000);
|
|
content[..check_len].contains(&0)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
|
pub struct Remote {
|
|
pub name: SharedString,
|
|
}
|
|
|
|
pub enum ResetMode {
|
|
/// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
|
|
/// committed are now staged).
|
|
Soft,
|
|
/// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
|
|
/// committed are now unstaged).
|
|
Mixed,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
|
pub enum FetchOptions {
|
|
All,
|
|
Remote(Remote),
|
|
}
|
|
|
|
impl FetchOptions {
|
|
pub fn to_proto(&self) -> Option<String> {
|
|
match self {
|
|
FetchOptions::All => None,
|
|
FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
|
|
}
|
|
}
|
|
|
|
pub fn from_proto(remote_name: Option<String>) -> Self {
|
|
match remote_name {
|
|
Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
|
|
None => FetchOptions::All,
|
|
}
|
|
}
|
|
|
|
pub fn name(&self) -> SharedString {
|
|
match self {
|
|
Self::All => "Fetch all remotes".into(),
|
|
Self::Remote(remote) => remote.name.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for FetchOptions {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
FetchOptions::All => write!(f, "--all"),
|
|
FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Modifies .git/info/exclude temporarily
|
|
pub struct GitExcludeOverride {
|
|
git_exclude_path: PathBuf,
|
|
original_excludes: Option<String>,
|
|
added_excludes: Option<String>,
|
|
}
|
|
|
|
impl GitExcludeOverride {
|
|
const START_BLOCK_MARKER: &str = "\n\n# ====== Auto-added by Zed: =======\n";
|
|
const END_BLOCK_MARKER: &str = "\n# ====== End of auto-added by Zed =======\n";
|
|
|
|
pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
|
|
let original_excludes =
|
|
smol::fs::read_to_string(&git_exclude_path)
|
|
.await
|
|
.ok()
|
|
.map(|content| {
|
|
// Auto-generated lines are normally cleaned up in
|
|
// `restore_original()` or `drop()`, but may stuck in rare cases.
|
|
// Make sure to remove them.
|
|
Self::remove_auto_generated_block(&content)
|
|
});
|
|
|
|
Ok(GitExcludeOverride {
|
|
git_exclude_path,
|
|
original_excludes,
|
|
added_excludes: None,
|
|
})
|
|
}
|
|
|
|
pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
|
|
self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
|
|
format!("{already_added}\n{excludes}")
|
|
} else {
|
|
excludes.to_string()
|
|
});
|
|
|
|
let mut content = self.original_excludes.clone().unwrap_or_default();
|
|
|
|
content.push_str(Self::START_BLOCK_MARKER);
|
|
content.push_str(self.added_excludes.as_ref().unwrap());
|
|
content.push_str(Self::END_BLOCK_MARKER);
|
|
|
|
smol::fs::write(&self.git_exclude_path, content).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn restore_original(&mut self) -> Result<()> {
|
|
if let Some(ref original) = self.original_excludes {
|
|
smol::fs::write(&self.git_exclude_path, original).await?;
|
|
} else if self.git_exclude_path.exists() {
|
|
smol::fs::remove_file(&self.git_exclude_path).await?;
|
|
}
|
|
|
|
self.added_excludes = None;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_auto_generated_block(content: &str) -> String {
|
|
let start_marker = Self::START_BLOCK_MARKER;
|
|
let end_marker = Self::END_BLOCK_MARKER;
|
|
let mut content = content.to_string();
|
|
|
|
let start_index = content.find(start_marker);
|
|
let end_index = content.rfind(end_marker);
|
|
|
|
if let (Some(start), Some(end)) = (start_index, end_index) {
|
|
if end > start {
|
|
content.replace_range(start..end + end_marker.len(), "");
|
|
}
|
|
}
|
|
|
|
// Older versions of Zed didn't have end-of-block markers,
|
|
// so it's impossible to determine auto-generated lines.
|
|
// Conservatively remove the standard list of excludes
|
|
let standard_excludes = format!(
|
|
"{}{}",
|
|
Self::START_BLOCK_MARKER,
|
|
include_str!("./checkpoint.gitignore")
|
|
);
|
|
content = content.replace(&standard_excludes, "");
|
|
|
|
content
|
|
}
|
|
}
|
|
|
|
impl Drop for GitExcludeOverride {
|
|
fn drop(&mut self) {
|
|
if self.added_excludes.is_some() {
|
|
let git_exclude_path = self.git_exclude_path.clone();
|
|
let original_excludes = self.original_excludes.clone();
|
|
smol::spawn(async move {
|
|
if let Some(original) = original_excludes {
|
|
smol::fs::write(&git_exclude_path, original).await
|
|
} else {
|
|
smol::fs::remove_file(&git_exclude_path).await
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)]
|
|
pub enum LogOrder {
|
|
#[default]
|
|
DateOrder,
|
|
TopoOrder,
|
|
AuthorDateOrder,
|
|
ReverseChronological,
|
|
}
|
|
|
|
impl LogOrder {
|
|
pub fn as_arg(&self) -> &'static str {
|
|
match self {
|
|
LogOrder::DateOrder => "--date-order",
|
|
LogOrder::TopoOrder => "--topo-order",
|
|
LogOrder::AuthorDateOrder => "--author-date-order",
|
|
LogOrder::ReverseChronological => "--reverse",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
|
pub enum LogSource {
|
|
#[default]
|
|
All,
|
|
Branch(SharedString),
|
|
Sha(Oid),
|
|
Path(RepoPath),
|
|
}
|
|
|
|
impl LogSource {
|
|
fn get_arg(&self) -> Result<&str> {
|
|
match self {
|
|
LogSource::All => Ok("--all"),
|
|
LogSource::Branch(branch) => Ok(branch.as_str()),
|
|
LogSource::Sha(oid) => {
|
|
str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")
|
|
}
|
|
LogSource::Path(_) => Ok("--follow"),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct SearchCommitArgs {
|
|
pub query: SharedString,
|
|
pub case_sensitive: bool,
|
|
}
|
|
|
|
pub fn delete_branch_flag(is_remote_tracking_ref: bool, force: bool) -> &'static str {
|
|
match (is_remote_tracking_ref, force) {
|
|
(true, true) => "-Dr",
|
|
(true, false) => "-dr",
|
|
(false, true) => "-D",
|
|
(false, false) => "-d",
|
|
}
|
|
}
|
|
|
|
pub trait GitRepository: Send + Sync {
|
|
fn reload_index(&self);
|
|
|
|
/// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
|
|
///
|
|
/// Also returns `None` for symlinks.
|
|
fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
|
|
|
|
/// 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.
|
|
///
|
|
/// Also returns `None` for symlinks.
|
|
fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
|
|
fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
|
|
|
|
fn set_index_text(
|
|
&self,
|
|
path: RepoPath,
|
|
content: Option<String>,
|
|
env: Arc<HashMap<String, String>>,
|
|
is_executable: bool,
|
|
) -> BoxFuture<'_, anyhow::Result<()>>;
|
|
|
|
/// Returns the URL of the remote with the given name.
|
|
fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>>;
|
|
|
|
/// Resolve a list of refs to SHAs.
|
|
fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
|
|
|
|
fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
|
|
async move {
|
|
self.revparse_batch(vec!["HEAD".into()])
|
|
.await
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.next()
|
|
.flatten()
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
|
|
|
|
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
|
|
fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
|
|
|
|
fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
|
|
|
|
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
|
|
|
|
fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
|
|
fn create_branch(&self, name: String, base_branch: Option<String>)
|
|
-> BoxFuture<'_, Result<()>>;
|
|
fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn delete_branch(
|
|
&self,
|
|
is_remote: bool,
|
|
name: String,
|
|
force: bool,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
|
|
|
|
fn create_worktree(
|
|
&self,
|
|
target: CreateWorktreeTarget,
|
|
path: PathBuf,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn checkout_branch_in_worktree(
|
|
&self,
|
|
branch_name: String,
|
|
worktree_path: PathBuf,
|
|
create: bool,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn reset(
|
|
&self,
|
|
commit: String,
|
|
mode: ResetMode,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn checkout_files(
|
|
&self,
|
|
commit: String,
|
|
paths: Vec<RepoPath>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
|
|
|
|
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
|
|
fn blame(
|
|
&self,
|
|
path: RepoPath,
|
|
content: Rope,
|
|
line_ending: LineEnding,
|
|
) -> BoxFuture<'_, Result<crate::blame::Blame>>;
|
|
|
|
/// Returns the absolute path to the repository. For worktrees, this will be the path to the
|
|
/// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
|
|
fn path(&self) -> PathBuf;
|
|
|
|
fn main_repository_path(&self) -> PathBuf;
|
|
|
|
/// Updates the index to match the worktree at the given paths.
|
|
///
|
|
/// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
|
|
fn stage_paths(
|
|
&self,
|
|
paths: Vec<RepoPath>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
/// Updates the index to match HEAD at the given paths.
|
|
///
|
|
/// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
|
|
fn unstage_paths(
|
|
&self,
|
|
paths: Vec<RepoPath>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn run_hook(
|
|
&self,
|
|
hook: RunHook,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn commit(
|
|
&self,
|
|
message: SharedString,
|
|
name_and_email: Option<(SharedString, SharedString)>,
|
|
options: CommitOptions,
|
|
askpass: AskPassDelegate,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn stash_paths(
|
|
&self,
|
|
paths: Vec<RepoPath>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn stash_pop(
|
|
&self,
|
|
index: Option<usize>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn stash_apply(
|
|
&self,
|
|
index: Option<usize>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn stash_drop(
|
|
&self,
|
|
index: Option<usize>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn push(
|
|
&self,
|
|
branch_name: String,
|
|
remote_branch_name: String,
|
|
upstream_name: String,
|
|
options: Option<PushOptions>,
|
|
askpass: AskPassDelegate,
|
|
env: Arc<HashMap<String, String>>,
|
|
// This method takes an AsyncApp to ensure it's invoked on the main thread,
|
|
// otherwise git-credentials-manager won't work.
|
|
cx: AsyncApp,
|
|
) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
|
|
|
|
fn pull(
|
|
&self,
|
|
branch_name: Option<String>,
|
|
upstream_name: String,
|
|
rebase: bool,
|
|
askpass: AskPassDelegate,
|
|
env: Arc<HashMap<String, String>>,
|
|
// This method takes an AsyncApp to ensure it's invoked on the main thread,
|
|
// otherwise git-credentials-manager won't work.
|
|
cx: AsyncApp,
|
|
) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
|
|
|
|
fn fetch(
|
|
&self,
|
|
fetch_options: FetchOptions,
|
|
askpass: AskPassDelegate,
|
|
env: Arc<HashMap<String, String>>,
|
|
// This method takes an AsyncApp to ensure it's invoked on the main thread,
|
|
// otherwise git-credentials-manager won't work.
|
|
cx: AsyncApp,
|
|
) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
|
|
|
|
fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
|
|
|
|
fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
|
|
|
|
fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
|
|
|
|
fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>;
|
|
|
|
/// returns a list of remote branches that contain HEAD
|
|
fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
|
|
|
|
/// Run git diff
|
|
fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
|
|
|
|
fn diff_stat(
|
|
&self,
|
|
path_prefixes: &[RepoPath],
|
|
) -> BoxFuture<'_, Result<crate::status::GitDiffStat>>;
|
|
|
|
/// Creates a checkpoint for the repository.
|
|
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
|
|
|
|
/// Resets to a previously-created checkpoint.
|
|
fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
|
|
|
|
/// Creates two detached commits capturing the current staged and unstaged
|
|
/// state without moving any branch. Returns (staged_sha, unstaged_sha).
|
|
fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>>;
|
|
|
|
/// Restores the working directory and index from archive checkpoint SHAs.
|
|
/// Assumes HEAD is already at the correct commit (original_commit_hash).
|
|
/// Restores the index to match staged_sha's tree, and the working
|
|
/// directory to match unstaged_sha's tree.
|
|
fn restore_archive_checkpoint(
|
|
&self,
|
|
staged_sha: String,
|
|
unstaged_sha: String,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
/// Compares two checkpoints, returning true if they are equal
|
|
fn compare_checkpoints(
|
|
&self,
|
|
left: GitRepositoryCheckpoint,
|
|
right: GitRepositoryCheckpoint,
|
|
) -> BoxFuture<'_, Result<bool>>;
|
|
|
|
/// Computes a diff between two checkpoints.
|
|
fn diff_checkpoints(
|
|
&self,
|
|
base_checkpoint: GitRepositoryCheckpoint,
|
|
target_checkpoint: GitRepositoryCheckpoint,
|
|
) -> BoxFuture<'_, Result<String>>;
|
|
|
|
fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>>;
|
|
|
|
fn default_branch(
|
|
&self,
|
|
include_remote_name: bool,
|
|
) -> BoxFuture<'_, Result<Option<SharedString>>>;
|
|
|
|
/// Runs `git rev-list --parents` to get the commit graph structure.
|
|
/// Returns commit SHAs and their parent SHAs for building the graph visualization.
|
|
fn initial_graph_data(
|
|
&self,
|
|
log_source: LogSource,
|
|
log_order: LogOrder,
|
|
request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn search_commits(
|
|
&self,
|
|
log_source: LogSource,
|
|
search_args: SearchCommitArgs,
|
|
request_tx: Sender<Oid>,
|
|
) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn commit_data_reader(&self) -> Result<CommitDataReader>;
|
|
|
|
fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>>;
|
|
|
|
fn set_trusted(&self, trusted: bool);
|
|
fn is_trusted(&self) -> bool;
|
|
}
|
|
|
|
pub enum DiffType {
|
|
HeadToIndex,
|
|
HeadToWorktree,
|
|
MergeBase { base_ref: SharedString },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
|
|
pub enum PushOptions {
|
|
SetUpstream,
|
|
Force,
|
|
}
|
|
|
|
impl std::fmt::Debug for dyn GitRepository {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("dyn GitRepository<...>").finish()
|
|
}
|
|
}
|
|
|
|
pub struct RealGitRepository {
|
|
pub repository: Arc<Mutex<git2::Repository>>,
|
|
pub system_git_binary_path: Option<PathBuf>,
|
|
pub any_git_binary_path: PathBuf,
|
|
any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
|
|
executor: BackgroundExecutor,
|
|
is_trusted: Arc<AtomicBool>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum RefEdit {
|
|
Update { ref_name: String, commit: String },
|
|
Delete { ref_name: String },
|
|
}
|
|
|
|
impl RefEdit {
|
|
fn into_args(self) -> Vec<OsString> {
|
|
match self {
|
|
Self::Update { ref_name, commit } => {
|
|
vec!["update-ref".into(), ref_name.into(), commit.into()]
|
|
}
|
|
Self::Delete { ref_name } => {
|
|
vec!["update-ref".into(), "-d".into(), ref_name.into()]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RealGitRepository {
|
|
pub fn new(
|
|
dotgit_path: &Path,
|
|
bundled_git_binary_path: Option<PathBuf>,
|
|
system_git_binary_path: Option<PathBuf>,
|
|
executor: BackgroundExecutor,
|
|
) -> Result<Self> {
|
|
let any_git_binary_path = system_git_binary_path
|
|
.clone()
|
|
.or(bundled_git_binary_path)
|
|
.context("no git binary available")?;
|
|
log::info!(
|
|
"opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}"
|
|
);
|
|
let workdir_root = dotgit_path.parent().context(".git has no parent")?;
|
|
let repository =
|
|
git2::Repository::open(workdir_root).context("creating libgit2 repository")?;
|
|
Ok(Self {
|
|
repository: Arc::new(Mutex::new(repository)),
|
|
system_git_binary_path,
|
|
any_git_binary_path,
|
|
executor,
|
|
any_git_binary_help_output: Arc::new(Mutex::new(None)),
|
|
is_trusted: Arc::new(AtomicBool::new(false)),
|
|
})
|
|
}
|
|
|
|
fn working_directory(&self) -> Result<PathBuf> {
|
|
self.repository
|
|
.lock()
|
|
.workdir()
|
|
.context("failed to read git work directory")
|
|
.map(Path::to_path_buf)
|
|
}
|
|
|
|
fn git_binary_in_worktree(&self) -> Result<GitBinary> {
|
|
Ok(GitBinary::new(
|
|
self.any_git_binary_path.clone(),
|
|
self.working_directory()
|
|
.with_context(|| "Can't run git commands without a working directory")?,
|
|
self.path(),
|
|
self.executor.clone(),
|
|
self.is_trusted(),
|
|
))
|
|
}
|
|
|
|
fn git_binary(&self) -> GitBinary {
|
|
let repository = self.repository.lock();
|
|
let working_directory = repository
|
|
.workdir()
|
|
.unwrap_or_else(|| repository.path())
|
|
.to_path_buf();
|
|
GitBinary::new(
|
|
self.any_git_binary_path.clone(),
|
|
working_directory,
|
|
repository.path().to_path_buf(),
|
|
self.executor.clone(),
|
|
self.is_trusted(),
|
|
)
|
|
}
|
|
|
|
fn edit_ref(&self, edit: RefEdit) -> BoxFuture<'_, Result<()>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let args = edit.into_args();
|
|
git.run(&args).await?;
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
async fn any_git_binary_help_output(&self) -> SharedString {
|
|
if let Some(output) = self.any_git_binary_help_output.lock().clone() {
|
|
return output;
|
|
}
|
|
let git = self.git_binary();
|
|
let output: SharedString = self
|
|
.executor
|
|
.spawn(async move { git.run(&["help", "-a"]).await })
|
|
.await
|
|
.unwrap_or_default()
|
|
.into();
|
|
*self.any_git_binary_help_output.lock() = Some(output.clone());
|
|
output
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct GitRepositoryCheckpoint {
|
|
pub commit_sha: Oid,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct GitCommitter {
|
|
pub name: Option<String>,
|
|
pub email: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct GitCommitTemplate {
|
|
pub template: String,
|
|
}
|
|
|
|
pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
|
|
if cfg!(any(feature = "test-support", test)) {
|
|
return GitCommitter {
|
|
name: None,
|
|
email: None,
|
|
};
|
|
}
|
|
|
|
let git_binary_path =
|
|
if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
|
|
cx.update(|cx| {
|
|
cx.path_for_auxiliary_executable("git")
|
|
.context("could not find git binary path")
|
|
.log_err()
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let git = GitBinary::new(
|
|
git_binary_path.unwrap_or(PathBuf::from("git")),
|
|
paths::home_dir().clone(),
|
|
paths::home_dir().join(".git"),
|
|
cx.background_executor().clone(),
|
|
true,
|
|
);
|
|
|
|
cx.background_spawn(async move {
|
|
let name = git
|
|
.run(&["config", "--global", "user.name"])
|
|
.await
|
|
.log_err();
|
|
let email = git
|
|
.run(&["config", "--global", "user.email"])
|
|
.await
|
|
.log_err();
|
|
GitCommitter { name, email }
|
|
})
|
|
.await
|
|
}
|
|
|
|
impl GitRepository for RealGitRepository {
|
|
fn reload_index(&self) {
|
|
if let Ok(mut index) = self.repository.lock().index() {
|
|
_ = index.read(false);
|
|
}
|
|
}
|
|
|
|
fn path(&self) -> PathBuf {
|
|
let repo = self.repository.lock();
|
|
repo.path().into()
|
|
}
|
|
|
|
fn main_repository_path(&self) -> PathBuf {
|
|
let repo = self.repository.lock();
|
|
repo.commondir().into()
|
|
}
|
|
|
|
fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let output = git
|
|
.build_command(&[
|
|
"show",
|
|
"--no-patch",
|
|
"--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
|
|
&commit,
|
|
])
|
|
.output()
|
|
.await?;
|
|
let output = std::str::from_utf8(&output.stdout)?;
|
|
let fields = output.split('\0').collect::<Vec<_>>();
|
|
if fields.len() != 6 {
|
|
bail!("unexpected git-show output for {commit:?}: {output:?}")
|
|
}
|
|
let sha = fields[0].to_string().into();
|
|
let message = fields[1].to_string().into();
|
|
let commit_timestamp = fields[2].parse()?;
|
|
let author_email = fields[3].to_string().into();
|
|
let author_name = fields[4].to_string().into();
|
|
Ok(CommitDetails {
|
|
sha,
|
|
message,
|
|
commit_timestamp,
|
|
author_email,
|
|
author_name,
|
|
})
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
|
|
let git = self.git_binary();
|
|
cx.background_spawn(async move {
|
|
let show_output = git
|
|
.build_command(&[
|
|
"show",
|
|
"--format=",
|
|
"-z",
|
|
"--no-renames",
|
|
"--name-status",
|
|
"--first-parent",
|
|
])
|
|
.arg(&commit)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.await
|
|
.context("starting git show process")?;
|
|
|
|
let show_stdout = String::from_utf8_lossy(&show_output.stdout);
|
|
let changes = parse_git_diff_name_status(&show_stdout);
|
|
let parent_sha = format!("{}^", commit);
|
|
|
|
let mut cat_file_process = git
|
|
.build_command(&["cat-file", "--batch=%(objectsize)"])
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.context("starting git cat-file process")?;
|
|
|
|
let mut files = Vec::<CommitFile>::new();
|
|
let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
|
|
let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
|
|
let mut info_line = String::new();
|
|
let mut newline = [b'\0'];
|
|
for (path, status_code) in changes {
|
|
// git-show outputs `/`-delimited paths even on Windows.
|
|
let Some(rel_path) = RelPath::unix(path).log_err() else {
|
|
continue;
|
|
};
|
|
|
|
match status_code {
|
|
StatusCode::Modified => {
|
|
stdin.write_all(commit.as_bytes()).await?;
|
|
stdin.write_all(b":").await?;
|
|
stdin.write_all(path.as_bytes()).await?;
|
|
stdin.write_all(b"\n").await?;
|
|
stdin.write_all(parent_sha.as_bytes()).await?;
|
|
stdin.write_all(b":").await?;
|
|
stdin.write_all(path.as_bytes()).await?;
|
|
stdin.write_all(b"\n").await?;
|
|
}
|
|
StatusCode::Added => {
|
|
stdin.write_all(commit.as_bytes()).await?;
|
|
stdin.write_all(b":").await?;
|
|
stdin.write_all(path.as_bytes()).await?;
|
|
stdin.write_all(b"\n").await?;
|
|
}
|
|
StatusCode::Deleted => {
|
|
stdin.write_all(parent_sha.as_bytes()).await?;
|
|
stdin.write_all(b":").await?;
|
|
stdin.write_all(path.as_bytes()).await?;
|
|
stdin.write_all(b"\n").await?;
|
|
}
|
|
_ => continue,
|
|
}
|
|
stdin.flush().await?;
|
|
|
|
info_line.clear();
|
|
stdout.read_line(&mut info_line).await?;
|
|
|
|
let len = info_line.trim_end().parse().with_context(|| {
|
|
format!("invalid object size output from cat-file {info_line}")
|
|
})?;
|
|
let mut text_bytes = vec![0; len];
|
|
stdout.read_exact(&mut text_bytes).await?;
|
|
stdout.read_exact(&mut newline).await?;
|
|
|
|
let mut old_text = None;
|
|
let mut new_text = None;
|
|
let mut is_binary = is_binary_content(&text_bytes);
|
|
let text = if is_binary {
|
|
String::new()
|
|
} else {
|
|
String::from_utf8_lossy(&text_bytes).to_string()
|
|
};
|
|
|
|
match status_code {
|
|
StatusCode::Modified => {
|
|
info_line.clear();
|
|
stdout.read_line(&mut info_line).await?;
|
|
let len = info_line.trim_end().parse().with_context(|| {
|
|
format!("invalid object size output from cat-file {}", info_line)
|
|
})?;
|
|
let mut parent_bytes = vec![0; len];
|
|
stdout.read_exact(&mut parent_bytes).await?;
|
|
stdout.read_exact(&mut newline).await?;
|
|
is_binary = is_binary || is_binary_content(&parent_bytes);
|
|
if is_binary {
|
|
old_text = Some(String::new());
|
|
new_text = Some(String::new());
|
|
} else {
|
|
old_text = Some(String::from_utf8_lossy(&parent_bytes).to_string());
|
|
new_text = Some(text);
|
|
}
|
|
}
|
|
StatusCode::Added => new_text = Some(text),
|
|
StatusCode::Deleted => old_text = Some(text),
|
|
_ => continue,
|
|
}
|
|
|
|
files.push(CommitFile {
|
|
path: RepoPath(Arc::from(rel_path)),
|
|
old_text,
|
|
new_text,
|
|
is_binary,
|
|
})
|
|
}
|
|
|
|
Ok(CommitDiff { files })
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn reset(
|
|
&self,
|
|
commit: String,
|
|
mode: ResetMode,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
async move {
|
|
let mode_flag = match mode {
|
|
ResetMode::Mixed => "--mixed",
|
|
ResetMode::Soft => "--soft",
|
|
};
|
|
|
|
let git = git_binary?;
|
|
let output = git
|
|
.build_command(&["reset", mode_flag, &commit])
|
|
.envs(env.iter())
|
|
.output()
|
|
.await?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to reset:\n{}",
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn checkout_files(
|
|
&self,
|
|
commit: String,
|
|
paths: Vec<RepoPath>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
async move {
|
|
if paths.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let git = git_binary?;
|
|
let output = git
|
|
.build_command(&["checkout", &commit, "--"])
|
|
.envs(env.iter())
|
|
.args(paths.iter().map(|path| path.as_unix_str()))
|
|
.output()
|
|
.await?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to checkout files:\n{}",
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
|
|
// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
|
|
const GIT_MODE_SYMLINK: u32 = 0o120000;
|
|
|
|
let repo = self.repository.clone();
|
|
self.executor
|
|
.spawn(async move {
|
|
fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
|
|
let mut index = repo.index()?;
|
|
index.read(false)?;
|
|
|
|
const STAGE_NORMAL: i32 = 0;
|
|
// git2 unwraps internally on empty paths or `.`
|
|
if path.is_empty() {
|
|
bail!("empty path has no index text");
|
|
}
|
|
let Some(entry) = index.get_path(path.as_std_path(), STAGE_NORMAL) else {
|
|
return Ok(None);
|
|
};
|
|
if entry.mode == GIT_MODE_SYMLINK {
|
|
return Ok(None);
|
|
}
|
|
|
|
let content = repo.find_blob(entry.id)?.content().to_owned();
|
|
Ok(String::from_utf8(content).ok())
|
|
}
|
|
|
|
logic(&repo.lock(), &path)
|
|
.context("loading index text")
|
|
.log_err()
|
|
.flatten()
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
|
|
let repo = self.repository.clone();
|
|
self.executor
|
|
.spawn(async move {
|
|
fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
|
|
let head = repo.head()?.peel_to_tree()?;
|
|
// git2 unwraps internally on empty paths or `.`
|
|
if path.is_empty() {
|
|
return Err(anyhow!("empty path has no committed text"));
|
|
}
|
|
let Some(entry) = head.get_path(path.as_std_path()).ok() else {
|
|
return Ok(None);
|
|
};
|
|
if entry.filemode() == i32::from(git2::FileMode::Link) {
|
|
return Ok(None);
|
|
}
|
|
let content = repo.find_blob(entry.id())?.content().to_owned();
|
|
Ok(String::from_utf8(content).ok())
|
|
}
|
|
|
|
logic(&repo.lock(), &path)
|
|
.context("loading committed text")
|
|
.log_err()
|
|
.flatten()
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
|
|
let repo = self.repository.clone();
|
|
self.executor
|
|
.spawn(async move {
|
|
let repo = repo.lock();
|
|
let content = repo.find_blob(oid.0)?.content().to_owned();
|
|
Ok(String::from_utf8(content)?)
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>> {
|
|
let working_directory_and_git_binary = self.working_directory().map(|working_directory| {
|
|
(
|
|
working_directory.clone(),
|
|
GitBinary::new(
|
|
self.any_git_binary_path.clone(),
|
|
working_directory,
|
|
self.path(),
|
|
self.executor.clone(),
|
|
self.is_trusted(),
|
|
),
|
|
)
|
|
});
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
let (working_directory, git_binary) = working_directory_and_git_binary?;
|
|
|
|
let output = git_binary
|
|
.build_command(&["config", "--get", "commit.template"])
|
|
.output()
|
|
.await
|
|
.context("failed to run git config --get commit.template")?;
|
|
|
|
let raw_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !output.status.success() || raw_path.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let path = PathBuf::from(&raw_path);
|
|
let path = if let Some(path) = raw_path.strip_prefix("~/") {
|
|
paths::home_dir().join(path)
|
|
} else if path.is_relative() {
|
|
working_directory.join(path)
|
|
} else {
|
|
path
|
|
};
|
|
|
|
let template = match std::fs::read_to_string(&path) {
|
|
Ok(s) if !s.trim().is_empty() => Some(s),
|
|
Err(err) => {
|
|
log::warn!("failed to read commit template {}: {}", path.display(), err);
|
|
None
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
Ok(template.map(|template| GitCommitTemplate { template }))
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn set_index_text(
|
|
&self,
|
|
path: RepoPath,
|
|
content: Option<String>,
|
|
env: Arc<HashMap<String, String>>,
|
|
is_executable: bool,
|
|
) -> BoxFuture<'_, anyhow::Result<()>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let mode = if is_executable { "100755" } else { "100644" };
|
|
|
|
if let Some(content) = content {
|
|
let mut child = git
|
|
.build_command(&["hash-object", "-w", "--stdin"])
|
|
.envs(env.iter())
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.spawn()?;
|
|
let mut stdin = child.stdin.take().unwrap();
|
|
stdin.write_all(content.as_bytes()).await?;
|
|
stdin.flush().await?;
|
|
drop(stdin);
|
|
let output = child.output().await?.stdout;
|
|
let sha = str::from_utf8(&output)?.trim();
|
|
|
|
log::debug!("indexing SHA: {sha}, path {path:?}");
|
|
|
|
let output = git
|
|
.build_command(&["update-index", "--add", "--cacheinfo", mode, sha])
|
|
.envs(env.iter())
|
|
.arg(path.as_unix_str())
|
|
.output()
|
|
.await?;
|
|
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to stage:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
} else {
|
|
log::debug!("removing path {path:?} from the index");
|
|
let output = git
|
|
.build_command(&["update-index", "--force-remove", "--"])
|
|
.envs(env.iter())
|
|
.arg(path.as_unix_str())
|
|
.output()
|
|
.await?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to unstage:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
|
|
let repo = self.repository.clone();
|
|
let name = name.to_owned();
|
|
self.executor
|
|
.spawn(async move {
|
|
let repo = repo.lock();
|
|
let remote = repo.find_remote(&name).ok()?;
|
|
remote.url().map(|url| url.to_string())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let mut process = git
|
|
.build_command(&["cat-file", "--batch-check=%(objectname)"])
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()?;
|
|
|
|
let stdin = process
|
|
.stdin
|
|
.take()
|
|
.context("no stdin for git cat-file subprocess")?;
|
|
let mut stdin = BufWriter::new(stdin);
|
|
for rev in &revs {
|
|
stdin.write_all(rev.as_bytes()).await?;
|
|
stdin.write_all(b"\n").await?;
|
|
}
|
|
stdin.flush().await?;
|
|
drop(stdin);
|
|
|
|
let output = process.output().await?;
|
|
let output = std::str::from_utf8(&output.stdout)?;
|
|
let shas = output
|
|
.lines()
|
|
.map(|line| {
|
|
if line.ends_with("missing") {
|
|
None
|
|
} else {
|
|
Some(line.to_string())
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
if shas.len() != revs.len() {
|
|
// In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
|
|
bail!("unexpected number of shas")
|
|
}
|
|
|
|
Ok(shas)
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
|
|
let path = self.path().join("MERGE_MSG");
|
|
self.executor
|
|
.spawn(async move { std::fs::read_to_string(&path).ok() })
|
|
.boxed()
|
|
}
|
|
|
|
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
|
|
let git = match self.git_binary_in_worktree() {
|
|
Ok(git) => git,
|
|
Err(e) => return Task::ready(Err(e)),
|
|
};
|
|
let args = git_status_args(path_prefixes);
|
|
log::debug!("Checking for git status in {path_prefixes:?}");
|
|
self.executor.spawn(async move {
|
|
let output = git.build_command(&args).output().await?;
|
|
if output.status.success() {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
stdout.parse()
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
anyhow::bail!("git status failed: {stderr}");
|
|
}
|
|
})
|
|
}
|
|
|
|
fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
|
|
let git = match self.git_binary_in_worktree() {
|
|
Ok(git) => git,
|
|
Err(e) => return Task::ready(Err(e)).boxed(),
|
|
};
|
|
|
|
let mut args = vec![
|
|
OsString::from("diff-tree"),
|
|
OsString::from("-r"),
|
|
OsString::from("-z"),
|
|
OsString::from("--no-renames"),
|
|
];
|
|
match request {
|
|
DiffTreeType::MergeBase { base, head } => {
|
|
args.push("--merge-base".into());
|
|
args.push(OsString::from(base.as_str()));
|
|
args.push(OsString::from(head.as_str()));
|
|
}
|
|
DiffTreeType::Since { base, head } => {
|
|
args.push(OsString::from(base.as_str()));
|
|
args.push(OsString::from(head.as_str()));
|
|
}
|
|
}
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
let output = git.build_command(&args).output().await?;
|
|
if output.status.success() {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
stdout.parse()
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
anyhow::bail!("git status failed: {stderr}");
|
|
}
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
let output = git
|
|
.build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
|
|
.output()
|
|
.await?;
|
|
if output.status.success() {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
stdout.parse()
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
anyhow::bail!("git status failed: {stderr}");
|
|
}
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let fields = [
|
|
"%(HEAD)",
|
|
"%(objectname)",
|
|
"%(parent)",
|
|
"%(refname)",
|
|
"%(upstream)",
|
|
"%(upstream:track)",
|
|
"%(committerdate:unix)",
|
|
"%(authorname)",
|
|
"%(contents:subject)",
|
|
]
|
|
.join("%00");
|
|
let args = vec![
|
|
"for-each-ref",
|
|
"refs/heads/**/*",
|
|
"refs/remotes/**/*",
|
|
"--format",
|
|
&fields,
|
|
];
|
|
let output = git.build_command(&args).output().await?;
|
|
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to git git branches:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let input = String::from_utf8_lossy(&output.stdout);
|
|
|
|
let mut branches = parse_branch_input(&input)?;
|
|
if branches.is_empty() {
|
|
let args = vec!["symbolic-ref", "--quiet", "HEAD"];
|
|
|
|
let output = git.build_command(&args).output().await?;
|
|
|
|
// git symbolic-ref returns a non-0 exit code if HEAD points
|
|
// to something other than a branch
|
|
if output.status.success() {
|
|
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
|
|
branches.push(Branch {
|
|
ref_name: name.into(),
|
|
is_head: true,
|
|
upstream: None,
|
|
most_recent_commit: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(branches)
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
|
|
let git = self.git_binary();
|
|
let main_worktree_path = {
|
|
let repo = self.repository.lock();
|
|
let common_dir = repo.commondir().to_path_buf();
|
|
original_repo_path_from_common_dir(&common_dir)
|
|
};
|
|
self.executor
|
|
.spawn(async move {
|
|
let output = git
|
|
.build_command(&["worktree", "list", "--porcelain"])
|
|
.output()
|
|
.await?;
|
|
if output.status.success() {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
Ok(parse_worktrees_from_str(
|
|
&stdout,
|
|
main_worktree_path.as_deref(),
|
|
))
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
anyhow::bail!("git worktree list failed: {stderr}");
|
|
}
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn create_worktree(
|
|
&self,
|
|
target: CreateWorktreeTarget,
|
|
path: PathBuf,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git = self.git_binary();
|
|
let mut args = vec![OsString::from("worktree"), OsString::from("add")];
|
|
|
|
match &target {
|
|
CreateWorktreeTarget::ExistingBranch { branch_name } => {
|
|
args.push(OsString::from("--"));
|
|
args.push(OsString::from(path.as_os_str()));
|
|
args.push(OsString::from(branch_name));
|
|
}
|
|
CreateWorktreeTarget::NewBranch {
|
|
branch_name,
|
|
base_sha: start_point,
|
|
} => {
|
|
args.push(OsString::from("-b"));
|
|
args.push(OsString::from(branch_name));
|
|
args.push(OsString::from("--"));
|
|
args.push(OsString::from(path.as_os_str()));
|
|
args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
|
|
}
|
|
CreateWorktreeTarget::Detached {
|
|
base_sha: start_point,
|
|
} => {
|
|
args.push(OsString::from("--detach"));
|
|
args.push(OsString::from("--"));
|
|
args.push(OsString::from(path.as_os_str()));
|
|
args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
|
|
}
|
|
}
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
std::fs::create_dir_all(path.parent().unwrap_or(&path))?;
|
|
let output = git.build_command(&args).output().await?;
|
|
if output.status.success() {
|
|
Ok(())
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
anyhow::bail!("git worktree add failed: {stderr}");
|
|
}
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> {
|
|
let git = self.git_binary();
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
let mut args: Vec<OsString> = vec!["worktree".into(), "remove".into()];
|
|
if force {
|
|
args.push("--force".into());
|
|
}
|
|
args.push("--".into());
|
|
args.push(path.as_os_str().into());
|
|
git.run(&args).await?;
|
|
anyhow::Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> {
|
|
let git = self.git_binary();
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
let args: Vec<OsString> = vec![
|
|
"worktree".into(),
|
|
"move".into(),
|
|
"--".into(),
|
|
old_path.as_os_str().into(),
|
|
new_path.as_os_str().into(),
|
|
];
|
|
git.run(&args).await?;
|
|
anyhow::Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn checkout_branch_in_worktree(
|
|
&self,
|
|
branch_name: String,
|
|
worktree_path: PathBuf,
|
|
create: bool,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = GitBinary::new(
|
|
self.any_git_binary_path.clone(),
|
|
worktree_path,
|
|
self.path(),
|
|
self.executor.clone(),
|
|
self.is_trusted(),
|
|
);
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
if create {
|
|
git_binary.run(&["checkout", "-b", &branch_name]).await?;
|
|
} else {
|
|
git_binary.run(&["checkout", &branch_name]).await?;
|
|
}
|
|
anyhow::Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
|
|
let repo = self.repository.clone();
|
|
let git_binary = self.git_binary_in_worktree();
|
|
let branch = self.executor.spawn(async move {
|
|
let repo = repo.lock();
|
|
let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
|
|
branch
|
|
} else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
|
|
let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
|
|
|
|
let revision = revision.get();
|
|
let branch_commit = revision.peel_to_commit()?;
|
|
let mut branch = match repo.branch(&branch_name, &branch_commit, false) {
|
|
Ok(branch) => branch,
|
|
Err(err) if err.code() == ErrorCode::Exists => {
|
|
repo.find_branch(&branch_name, BranchType::Local)?
|
|
}
|
|
Err(err) => {
|
|
return Err(err.into());
|
|
}
|
|
};
|
|
|
|
branch.set_upstream(Some(&name))?;
|
|
branch
|
|
} else {
|
|
anyhow::bail!("Branch '{}' not found", name);
|
|
};
|
|
|
|
Ok(branch
|
|
.name()?
|
|
.context("cannot checkout anonymous branch")?
|
|
.to_string())
|
|
});
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
let branch = branch.await?;
|
|
git_binary?.run(&["checkout", &branch]).await?;
|
|
anyhow::Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn create_branch(
|
|
&self,
|
|
name: String,
|
|
base_branch: Option<String>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
let mut args = vec!["switch", "-c", &name];
|
|
let base_branch_str;
|
|
if let Some(ref base) = base_branch {
|
|
base_branch_str = base.clone();
|
|
args.push(&base_branch_str);
|
|
}
|
|
|
|
git_binary?.run(&args).await?;
|
|
anyhow::Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
git_binary?
|
|
.run(&["branch", "-m", &branch, &new_name])
|
|
.await?;
|
|
anyhow::Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn delete_branch(
|
|
&self,
|
|
is_remote: bool,
|
|
name: String,
|
|
force: bool,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
let flag = delete_branch_flag(is_remote, force);
|
|
git_binary?.run(&["branch", flag, &name]).await?;
|
|
anyhow::Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn blame(
|
|
&self,
|
|
path: RepoPath,
|
|
content: Rope,
|
|
line_ending: LineEnding,
|
|
) -> BoxFuture<'_, Result<crate::blame::Blame>> {
|
|
let git = self.git_binary_in_worktree();
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
crate::blame::Blame::for_path(&git?, &path, &content, line_ending).await
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
let output = match diff {
|
|
DiffType::HeadToIndex => {
|
|
git.build_command(&["diff", "--staged"]).output().await?
|
|
}
|
|
DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?,
|
|
DiffType::MergeBase { base_ref } => {
|
|
git.build_command(&["diff", "--merge-base", base_ref.as_ref()])
|
|
.output()
|
|
.await?
|
|
}
|
|
};
|
|
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to run git diff:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn diff_stat(
|
|
&self,
|
|
path_prefixes: &[RepoPath],
|
|
) -> BoxFuture<'_, Result<crate::status::GitDiffStat>> {
|
|
let path_prefixes = path_prefixes.to_vec();
|
|
let git_binary = self.git_binary_in_worktree();
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
let git_binary = git_binary?;
|
|
let mut args: Vec<String> = vec![
|
|
"diff".into(),
|
|
"--numstat".into(),
|
|
"--no-renames".into(),
|
|
"HEAD".into(),
|
|
];
|
|
if !path_prefixes.is_empty() {
|
|
args.push("--".into());
|
|
args.extend(
|
|
path_prefixes
|
|
.iter()
|
|
.map(|p| p.as_std_path().to_string_lossy().into_owned()),
|
|
);
|
|
}
|
|
let output = git_binary.run(&args).await?;
|
|
Ok(crate::status::parse_numstat(&output))
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn stage_paths(
|
|
&self,
|
|
paths: Vec<RepoPath>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
if !paths.is_empty() {
|
|
let git = git_binary?;
|
|
let output = git
|
|
.build_command(&["update-index", "--add", "--remove", "--"])
|
|
.envs(env.iter())
|
|
.args(paths.iter().map(|p| p.as_unix_str()))
|
|
.output()
|
|
.await?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to stage paths:\n{}",
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
}
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn unstage_paths(
|
|
&self,
|
|
paths: Vec<RepoPath>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
|
|
self.executor
|
|
.spawn(async move {
|
|
if !paths.is_empty() {
|
|
let git = git_binary?;
|
|
let output = git
|
|
.build_command(&["reset", "--quiet", "--"])
|
|
.envs(env.iter())
|
|
.args(paths.iter().map(|p| p.as_std_path()))
|
|
.output()
|
|
.await?;
|
|
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to unstage:\n{}",
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
}
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn stash_paths(
|
|
&self,
|
|
paths: Vec<RepoPath>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
let output = git
|
|
.build_command(&["stash", "push", "--quiet", "--include-untracked", "--"])
|
|
.envs(env.iter())
|
|
.args(paths.iter().map(|p| p.as_unix_str()))
|
|
.output()
|
|
.await?;
|
|
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to stash:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn stash_pop(
|
|
&self,
|
|
index: Option<usize>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
let mut args = vec!["stash".to_string(), "pop".to_string()];
|
|
if let Some(index) = index {
|
|
args.push(format!("stash@{{{}}}", index));
|
|
}
|
|
let output = git.build_command(&args).envs(env.iter()).output().await?;
|
|
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to stash pop:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn stash_apply(
|
|
&self,
|
|
index: Option<usize>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
let mut args = vec!["stash".to_string(), "apply".to_string()];
|
|
if let Some(index) = index {
|
|
args.push(format!("stash@{{{}}}", index));
|
|
}
|
|
let output = git.build_command(&args).envs(env.iter()).output().await?;
|
|
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to apply stash:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn stash_drop(
|
|
&self,
|
|
index: Option<usize>,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
let mut args = vec!["stash".to_string(), "drop".to_string()];
|
|
if let Some(index) = index {
|
|
args.push(format!("stash@{{{}}}", index));
|
|
}
|
|
let output = git.build_command(&args).envs(env.iter()).output().await?;
|
|
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to stash drop:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn commit(
|
|
&self,
|
|
message: SharedString,
|
|
name_and_email: Option<(SharedString, SharedString)>,
|
|
options: CommitOptions,
|
|
ask_pass: AskPassDelegate,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
let executor = self.executor.clone();
|
|
// Note: Do not spawn this command on the background thread, it might pop open the credential helper
|
|
// which we want to block on.
|
|
async move {
|
|
let git = git_binary?;
|
|
let mut cmd = git.build_command(&["commit", "--quiet", "-m"]);
|
|
cmd.envs(env.iter())
|
|
.arg(&message.to_string())
|
|
.arg("--cleanup=strip")
|
|
.arg("--no-verify")
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
if options.amend {
|
|
cmd.arg("--amend");
|
|
}
|
|
|
|
if options.signoff {
|
|
cmd.arg("--signoff");
|
|
}
|
|
|
|
if options.allow_empty {
|
|
cmd.arg("--allow-empty");
|
|
}
|
|
|
|
if let Some((name, email)) = name_and_email {
|
|
cmd.arg("--author").arg(&format!("{name} <{email}>"));
|
|
}
|
|
|
|
run_git_command(env, ask_pass, cmd, executor).await?;
|
|
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
|
|
self.edit_ref(RefEdit::Update { ref_name, commit })
|
|
}
|
|
|
|
fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
|
|
self.edit_ref(RefEdit::Delete { ref_name })
|
|
}
|
|
|
|
fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let args: Vec<OsString> = vec!["worktree".into(), "repair".into()];
|
|
git.run(&args).await?;
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn push(
|
|
&self,
|
|
branch_name: String,
|
|
remote_branch_name: String,
|
|
remote_name: String,
|
|
options: Option<PushOptions>,
|
|
ask_pass: AskPassDelegate,
|
|
env: Arc<HashMap<String, String>>,
|
|
cx: AsyncApp,
|
|
) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
|
|
let working_directory = self.working_directory();
|
|
let git_directory = self.path();
|
|
let executor = cx.background_executor().clone();
|
|
let git_binary_path = self.system_git_binary_path.clone();
|
|
let is_trusted = self.is_trusted();
|
|
// Note: Do not spawn this command on the background thread, it might pop open the credential helper
|
|
// which we want to block on.
|
|
async move {
|
|
let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
|
|
let working_directory = working_directory?;
|
|
let git = GitBinary::new(
|
|
git_binary_path,
|
|
working_directory,
|
|
git_directory,
|
|
executor.clone(),
|
|
is_trusted,
|
|
);
|
|
let mut command = git.build_command(&["push"]);
|
|
command
|
|
.envs(env.iter())
|
|
.args(options.map(|option| match option {
|
|
PushOptions::SetUpstream => "--set-upstream",
|
|
PushOptions::Force => "--force-with-lease",
|
|
}))
|
|
.arg(remote_name)
|
|
.arg(format!("{}:{}", branch_name, remote_branch_name))
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
run_git_command(env, ask_pass, command, executor).await
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn pull(
|
|
&self,
|
|
branch_name: Option<String>,
|
|
remote_name: String,
|
|
rebase: bool,
|
|
ask_pass: AskPassDelegate,
|
|
env: Arc<HashMap<String, String>>,
|
|
cx: AsyncApp,
|
|
) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
|
|
let working_directory = self.working_directory();
|
|
let git_directory = self.path();
|
|
let executor = cx.background_executor().clone();
|
|
let git_binary_path = self.system_git_binary_path.clone();
|
|
let is_trusted = self.is_trusted();
|
|
// Note: Do not spawn this command on the background thread, it might pop open the credential helper
|
|
// which we want to block on.
|
|
async move {
|
|
let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
|
|
let working_directory = working_directory?;
|
|
let git = GitBinary::new(
|
|
git_binary_path,
|
|
working_directory,
|
|
git_directory,
|
|
executor.clone(),
|
|
is_trusted,
|
|
);
|
|
let mut command = git.build_command(&["pull"]);
|
|
command.envs(env.iter());
|
|
|
|
if rebase {
|
|
command.arg("--rebase");
|
|
}
|
|
|
|
command
|
|
.arg(remote_name)
|
|
.args(branch_name)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
run_git_command(env, ask_pass, command, executor).await
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn fetch(
|
|
&self,
|
|
fetch_options: FetchOptions,
|
|
ask_pass: AskPassDelegate,
|
|
env: Arc<HashMap<String, String>>,
|
|
cx: AsyncApp,
|
|
) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
|
|
let working_directory = self.working_directory().unwrap_or(self.path());
|
|
let git_directory = self.path();
|
|
let remote_name = format!("{}", fetch_options);
|
|
let git_binary_path = self.system_git_binary_path.clone();
|
|
let executor = cx.background_executor().clone();
|
|
let is_trusted = self.is_trusted();
|
|
// Note: Do not spawn this command on the background thread, it might pop open the credential helper
|
|
// which we want to block on.
|
|
async move {
|
|
let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
|
|
let git = GitBinary::new(
|
|
git_binary_path,
|
|
working_directory,
|
|
git_directory,
|
|
executor.clone(),
|
|
is_trusted,
|
|
);
|
|
let mut command = git.build_command(&["fetch", &remote_name]);
|
|
command
|
|
.envs(env.iter())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
run_git_command(env, ask_pass, command, executor).await
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let output = git
|
|
.build_command(&["rev-parse", "--abbrev-ref"])
|
|
.arg(format!("{branch}@{{push}}"))
|
|
.output()
|
|
.await?;
|
|
if !output.status.success() {
|
|
return Ok(None);
|
|
}
|
|
let remote_name = String::from_utf8_lossy(&output.stdout)
|
|
.split('/')
|
|
.next()
|
|
.map(|name| Remote {
|
|
name: name.trim().to_string().into(),
|
|
});
|
|
|
|
Ok(remote_name)
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let output = git
|
|
.build_command(&["config", "--get"])
|
|
.arg(format!("branch.{branch}.remote"))
|
|
.output()
|
|
.await?;
|
|
if !output.status.success() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let remote_name = String::from_utf8_lossy(&output.stdout);
|
|
return Ok(Some(Remote {
|
|
name: remote_name.trim().to_string().into(),
|
|
}));
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let output = git.build_command(&["remote", "-v"]).output().await?;
|
|
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"Failed to get all remotes:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let remote_names: HashSet<Remote> = String::from_utf8_lossy(&output.stdout)
|
|
.lines()
|
|
.filter(|line| !line.is_empty())
|
|
.filter_map(|line| {
|
|
let mut split_line = line.split_whitespace();
|
|
let remote_name = split_line.next()?;
|
|
|
|
Some(Remote {
|
|
name: remote_name.trim().to_string().into(),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(remote_names.into_iter().collect())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
|
|
let repo = self.repository.clone();
|
|
self.executor
|
|
.spawn(async move {
|
|
let repo = repo.lock();
|
|
repo.remote_delete(&name)?;
|
|
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
|
|
let repo = self.repository.clone();
|
|
self.executor
|
|
.spawn(async move {
|
|
let repo = repo.lock();
|
|
repo.remote(&name, url.as_ref())?;
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
let git_cmd = async |args: &[&str]| -> Result<String> {
|
|
let output = git.build_command(args).output().await?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
String::from_utf8_lossy(&output.stderr).to_string()
|
|
);
|
|
Ok(String::from_utf8(output.stdout)?)
|
|
};
|
|
|
|
let head = git_cmd(&["rev-parse", "HEAD"])
|
|
.await
|
|
.context("Failed to get HEAD")?
|
|
.trim()
|
|
.to_owned();
|
|
|
|
let mut remote_branches = vec![];
|
|
let mut add_if_matching = async |remote_head: &str| {
|
|
if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
|
|
&& merge_base.trim() == head
|
|
&& let Some(s) = remote_head.strip_prefix("refs/remotes/")
|
|
{
|
|
remote_branches.push(s.to_owned().into());
|
|
}
|
|
};
|
|
|
|
// check the main branch of each remote
|
|
let remotes = git_cmd(&["remote"])
|
|
.await
|
|
.context("Failed to get remotes")?;
|
|
for remote in remotes.lines() {
|
|
if let Ok(remote_head) =
|
|
git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
|
|
{
|
|
add_if_matching(remote_head.trim()).await;
|
|
}
|
|
}
|
|
|
|
// ... and the remote branch that the checked-out one is tracking
|
|
if let Ok(remote_head) =
|
|
git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
|
|
{
|
|
add_if_matching(remote_head.trim()).await;
|
|
}
|
|
|
|
Ok(remote_branches)
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let mut git = git_binary?.envs(checkpoint_author_envs());
|
|
git.with_temp_index(async |git| {
|
|
let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
|
|
let mut excludes = exclude_files(git).await?;
|
|
|
|
git.run(&["add", "--all"]).await?;
|
|
let tree = git.run(&["write-tree"]).await?;
|
|
let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
|
|
git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
|
|
.await?
|
|
} else {
|
|
git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
|
|
};
|
|
|
|
excludes.restore_original().await?;
|
|
|
|
Ok(GitRepositoryCheckpoint {
|
|
commit_sha: checkpoint_sha.parse()?,
|
|
})
|
|
})
|
|
.await
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
git.run(&[
|
|
"restore",
|
|
"--source",
|
|
&checkpoint.commit_sha.to_string(),
|
|
"--worktree",
|
|
".",
|
|
])
|
|
.await?;
|
|
|
|
// TODO: We don't track binary and large files anymore,
|
|
// so the following call would delete them.
|
|
// Implement an alternative way to track files added by agent.
|
|
//
|
|
// git.with_temp_index(async move |git| {
|
|
// git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
|
|
// .await?;
|
|
// git.run(&["clean", "-d", "--force"]).await
|
|
// })
|
|
// .await?;
|
|
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let mut git = git_binary?.envs(checkpoint_author_envs());
|
|
|
|
let head_sha = git
|
|
.run(&["rev-parse", "HEAD"])
|
|
.await
|
|
.context("failed to read HEAD")?;
|
|
|
|
// Capture the staged state: write-tree reads the current index
|
|
let staged_tree = git
|
|
.run(&["write-tree"])
|
|
.await
|
|
.context("failed to write staged tree")?;
|
|
let staged_sha = git
|
|
.run(&[
|
|
"commit-tree",
|
|
&staged_tree,
|
|
"-p",
|
|
&head_sha,
|
|
"-m",
|
|
"WIP staged",
|
|
])
|
|
.await
|
|
.context("failed to create staged commit")?;
|
|
|
|
// Capture the full state (staged + unstaged + untracked) using
|
|
// a temporary index so we don't disturb the real one.
|
|
let unstaged_sha = git
|
|
.with_temp_index(async |git| {
|
|
git.run(&["add", "--all"]).await?;
|
|
let full_tree = git.run(&["write-tree"]).await?;
|
|
let sha = git
|
|
.run(&[
|
|
"commit-tree",
|
|
&full_tree,
|
|
"-p",
|
|
&staged_sha,
|
|
"-m",
|
|
"WIP unstaged",
|
|
])
|
|
.await?;
|
|
Ok(sha)
|
|
})
|
|
.await
|
|
.context("failed to create unstaged commit")?;
|
|
|
|
Ok((staged_sha, unstaged_sha))
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn restore_archive_checkpoint(
|
|
&self,
|
|
staged_sha: String,
|
|
unstaged_sha: String,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
|
|
// First, set the index AND working tree to match the unstaged
|
|
// tree. --reset -u computes a tree-level diff between the
|
|
// current index and unstaged_sha's tree and applies additions,
|
|
// modifications, and deletions to the working directory.
|
|
git.run(&["read-tree", "--reset", "-u", &unstaged_sha])
|
|
.await
|
|
.context("failed to restore working directory from unstaged commit")?;
|
|
|
|
// Then replace just the index with the staged tree. Without -u
|
|
// this doesn't touch the working directory, so the result is:
|
|
// working tree = unstaged state, index = staged state.
|
|
git.run(&["read-tree", &staged_sha])
|
|
.await
|
|
.context("failed to restore index from staged commit")?;
|
|
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn compare_checkpoints(
|
|
&self,
|
|
left: GitRepositoryCheckpoint,
|
|
right: GitRepositoryCheckpoint,
|
|
) -> BoxFuture<'_, Result<bool>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
let result = git
|
|
.run(&[
|
|
"diff-tree",
|
|
"--quiet",
|
|
&left.commit_sha.to_string(),
|
|
&right.commit_sha.to_string(),
|
|
])
|
|
.await;
|
|
match result {
|
|
Ok(_) => Ok(true),
|
|
Err(error) => {
|
|
if let Some(GitBinaryCommandError { status, .. }) =
|
|
error.downcast_ref::<GitBinaryCommandError>()
|
|
&& status.code() == Some(1)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
Err(error)
|
|
}
|
|
}
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn diff_checkpoints(
|
|
&self,
|
|
base_checkpoint: GitRepositoryCheckpoint,
|
|
target_checkpoint: GitRepositoryCheckpoint,
|
|
) -> BoxFuture<'_, Result<String>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git = git_binary?;
|
|
git.run(&[
|
|
"diff",
|
|
"--find-renames",
|
|
"--patch",
|
|
&base_checkpoint.commit_sha.to_string(),
|
|
&target_checkpoint.commit_sha.to_string(),
|
|
])
|
|
.await
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn default_branch(
|
|
&self,
|
|
include_remote_name: bool,
|
|
) -> BoxFuture<'_, Result<Option<SharedString>>> {
|
|
let git = self.git_binary();
|
|
self.executor
|
|
.spawn(async move {
|
|
let strip_prefix = if include_remote_name {
|
|
"refs/remotes/"
|
|
} else {
|
|
"refs/remotes/upstream/"
|
|
};
|
|
|
|
if let Ok(output) = git
|
|
.run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
|
|
.await
|
|
{
|
|
let output = output
|
|
.strip_prefix(strip_prefix)
|
|
.map(|s| SharedString::from(s.to_owned()));
|
|
return Ok(output);
|
|
}
|
|
|
|
let strip_prefix = if include_remote_name {
|
|
"refs/remotes/"
|
|
} else {
|
|
"refs/remotes/origin/"
|
|
};
|
|
|
|
if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
|
|
return Ok(output
|
|
.strip_prefix(strip_prefix)
|
|
.map(|s| SharedString::from(s.to_owned())));
|
|
}
|
|
|
|
if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
|
|
if git.run(&["rev-parse", &default_branch]).await.is_ok() {
|
|
return Ok(Some(default_branch.into()));
|
|
}
|
|
}
|
|
|
|
if git.run(&["rev-parse", "master"]).await.is_ok() {
|
|
return Ok(Some("master".into()));
|
|
}
|
|
|
|
Ok(None)
|
|
})
|
|
.boxed()
|
|
}
|
|
|
|
fn run_hook(
|
|
&self,
|
|
hook: RunHook,
|
|
env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git_binary = self.git_binary_in_worktree();
|
|
let repository = self.repository.clone();
|
|
let help_output = self.any_git_binary_help_output();
|
|
|
|
// Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
|
|
async move {
|
|
let git_binary = git_binary?;
|
|
|
|
let working_directory = git_binary.working_directory.clone();
|
|
if !help_output
|
|
.await
|
|
.lines()
|
|
.any(|line| line.trim().starts_with("hook "))
|
|
{
|
|
let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str());
|
|
if hook_abs_path.is_file() && git_binary.is_trusted {
|
|
#[allow(clippy::disallowed_methods)]
|
|
let output = new_command(&hook_abs_path)
|
|
.envs(env.iter())
|
|
.current_dir(&working_directory)
|
|
.output()
|
|
.await?;
|
|
|
|
if !output.status.success() {
|
|
return Err(GitBinaryCommandError {
|
|
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
|
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
|
status: output.status,
|
|
}
|
|
.into());
|
|
}
|
|
}
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
if git_binary.is_trusted {
|
|
let git_binary = git_binary.envs(HashMap::clone(&env));
|
|
git_binary
|
|
.run(&["hook", "run", "--ignore-missing", hook.as_str()])
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn initial_graph_data(
|
|
&self,
|
|
log_source: LogSource,
|
|
log_order: LogOrder,
|
|
request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git = self.git_binary();
|
|
|
|
async move {
|
|
let mut git_log_command = vec![
|
|
"log",
|
|
GRAPH_COMMIT_FORMAT,
|
|
log_order.as_arg(),
|
|
log_source.get_arg()?,
|
|
];
|
|
|
|
if let LogSource::Path(path) = &log_source {
|
|
git_log_command.extend(["--", path.as_unix_str()]);
|
|
}
|
|
|
|
let mut command = git.build_command(&git_log_command);
|
|
command.stdout(Stdio::piped());
|
|
command.stderr(Stdio::piped());
|
|
|
|
let mut child = command.spawn()?;
|
|
let stdout = child.stdout.take().context("failed to get stdout")?;
|
|
let stderr = child.stderr.take().context("failed to get stderr")?;
|
|
let mut reader = BufReader::new(stdout);
|
|
|
|
let mut line_buffer = String::new();
|
|
let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
|
|
|
|
loop {
|
|
line_buffer.clear();
|
|
let bytes_read = reader.read_line(&mut line_buffer).await?;
|
|
|
|
if bytes_read == 0 {
|
|
if !lines.is_empty() {
|
|
let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
|
|
if request_tx.send(commits).await.is_err() {
|
|
log::warn!(
|
|
"initial_graph_data: receiver dropped while sending commits"
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
let line = line_buffer.trim_end_matches('\n').to_string();
|
|
lines.push(line);
|
|
|
|
if lines.len() >= GRAPH_CHUNK_SIZE {
|
|
let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
|
|
if request_tx.send(commits).await.is_err() {
|
|
log::warn!("initial_graph_data: receiver dropped while streaming commits");
|
|
break;
|
|
}
|
|
lines.clear();
|
|
}
|
|
}
|
|
|
|
let status = child.status().await?;
|
|
if !status.success() {
|
|
let mut stderr_output = String::new();
|
|
BufReader::new(stderr)
|
|
.read_to_string(&mut stderr_output)
|
|
.await
|
|
.log_err();
|
|
|
|
if stderr_output.is_empty() {
|
|
anyhow::bail!("git log command failed with {}", status);
|
|
} else {
|
|
anyhow::bail!("git log command failed with {}: {}", status, stderr_output);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn search_commits(
|
|
&self,
|
|
log_source: LogSource,
|
|
search_args: SearchCommitArgs,
|
|
request_tx: Sender<Oid>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
let git = self.git_binary();
|
|
|
|
async move {
|
|
let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?];
|
|
|
|
args.push("--fixed-strings");
|
|
|
|
if !search_args.case_sensitive {
|
|
args.push("--regexp-ignore-case");
|
|
}
|
|
|
|
args.push("--grep");
|
|
args.push(search_args.query.as_str());
|
|
|
|
if let LogSource::Path(path) = &log_source {
|
|
args.extend(["--", path.as_unix_str()]);
|
|
}
|
|
|
|
let mut command = git.build_command(&args);
|
|
command.stdout(Stdio::piped());
|
|
command.stderr(Stdio::null());
|
|
|
|
let mut child = command.spawn()?;
|
|
let stdout = child.stdout.take().context("failed to get stdout")?;
|
|
let mut reader = BufReader::new(stdout);
|
|
|
|
let mut line_buffer = String::new();
|
|
|
|
loop {
|
|
line_buffer.clear();
|
|
let bytes_read = reader.read_line(&mut line_buffer).await?;
|
|
|
|
if bytes_read == 0 {
|
|
break;
|
|
}
|
|
|
|
let sha = line_buffer.trim_end_matches('\n');
|
|
|
|
if let Ok(oid) = Oid::from_str(sha)
|
|
&& request_tx.send(oid).await.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
child.status().await?;
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn commit_data_reader(&self) -> Result<CommitDataReader> {
|
|
let git_binary = self.git_binary();
|
|
|
|
let (request_tx, request_rx) = async_channel::bounded::<CommitDataRequest>(64);
|
|
|
|
let task = self.executor.spawn(async move {
|
|
if let Err(error) = run_commit_data_reader(git_binary, request_rx).await {
|
|
log::error!("commit data reader failed: {error:?}");
|
|
}
|
|
});
|
|
|
|
Ok(CommitDataReader {
|
|
request_tx,
|
|
_task: task,
|
|
})
|
|
}
|
|
|
|
fn set_trusted(&self, trusted: bool) {
|
|
self.is_trusted
|
|
.store(trusted, std::sync::atomic::Ordering::Release);
|
|
}
|
|
|
|
fn is_trusted(&self) -> bool {
|
|
self.is_trusted.load(std::sync::atomic::Ordering::Acquire)
|
|
}
|
|
}
|
|
|
|
async fn run_commit_data_reader(
|
|
git: GitBinary,
|
|
request_rx: async_channel::Receiver<CommitDataRequest>,
|
|
) -> Result<()> {
|
|
let mut process = git
|
|
.build_command(&["cat-file", "--batch"])
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.context("starting git cat-file --batch process")?;
|
|
|
|
let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
|
|
let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
|
|
|
|
const MAX_BATCH_SIZE: usize = 64;
|
|
|
|
while let Ok(first_request) = request_rx.recv().await {
|
|
let mut pending_requests = vec![first_request];
|
|
|
|
while pending_requests.len() < MAX_BATCH_SIZE {
|
|
match request_rx.try_recv() {
|
|
Ok(request) => pending_requests.push(request),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
|
|
for request in &pending_requests {
|
|
stdin.write_all(request.sha.to_string().as_bytes()).await?;
|
|
stdin.write_all(b"\n").await?;
|
|
}
|
|
stdin.flush().await?;
|
|
|
|
for request in pending_requests {
|
|
let result = read_single_commit_response(&mut stdout, &request.sha).await;
|
|
request.response_tx.send(result).ok();
|
|
}
|
|
}
|
|
|
|
drop(stdin);
|
|
process.kill().ok();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_single_commit_response<R: smol::io::AsyncBufRead + Unpin>(
|
|
stdout: &mut R,
|
|
sha: &Oid,
|
|
) -> Result<CommitData> {
|
|
let mut header_bytes = Vec::new();
|
|
stdout.read_until(b'\n', &mut header_bytes).await?;
|
|
let header_line = String::from_utf8_lossy(&header_bytes);
|
|
|
|
let parts: Vec<&str> = header_line.trim().split(' ').collect();
|
|
if parts.len() < 3 {
|
|
bail!("invalid cat-file header: {header_line}");
|
|
}
|
|
|
|
let object_type = parts[1];
|
|
if object_type == "missing" {
|
|
bail!("object not found: {}", sha);
|
|
}
|
|
|
|
if object_type != "commit" {
|
|
bail!("expected commit object, got {object_type}");
|
|
}
|
|
|
|
let size: usize = parts[2]
|
|
.parse()
|
|
.with_context(|| format!("invalid object size: {}", parts[2]))?;
|
|
|
|
let mut content = vec![0u8; size];
|
|
stdout.read_exact(&mut content).await?;
|
|
|
|
let mut newline = [0u8; 1];
|
|
stdout.read_exact(&mut newline).await?;
|
|
|
|
let content_str = String::from_utf8_lossy(&content);
|
|
parse_cat_file_commit(*sha, &content_str)
|
|
.ok_or_else(|| anyhow!("failed to parse commit {}", sha))
|
|
}
|
|
|
|
fn parse_initial_graph_output<'a>(
|
|
lines: impl Iterator<Item = &'a str>,
|
|
) -> Vec<Arc<InitialGraphCommitData>> {
|
|
lines
|
|
.filter(|line| !line.is_empty())
|
|
.filter_map(|line| {
|
|
// Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
|
|
let mut parts = line.split('\x00');
|
|
|
|
let sha = Oid::from_str(parts.next()?).ok()?;
|
|
let parents_str = parts.next()?;
|
|
let parents = parents_str
|
|
.split_whitespace()
|
|
.filter_map(|p| Oid::from_str(p).ok())
|
|
.collect();
|
|
|
|
let ref_names_str = parts.next().unwrap_or("");
|
|
let ref_names = if ref_names_str.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
ref_names_str
|
|
.split(", ")
|
|
.map(|s| SharedString::from(s.to_string()))
|
|
.collect()
|
|
};
|
|
|
|
Some(Arc::new(InitialGraphCommitData {
|
|
sha,
|
|
parents,
|
|
ref_names,
|
|
}))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
|
|
let mut args = vec![
|
|
OsString::from("status"),
|
|
OsString::from("--porcelain=v1"),
|
|
OsString::from("--untracked-files=all"),
|
|
OsString::from("--no-renames"),
|
|
OsString::from("-z"),
|
|
OsString::from("--"),
|
|
];
|
|
args.extend(path_prefixes.iter().map(|path_prefix| {
|
|
if path_prefix.is_empty() {
|
|
Path::new(".").into()
|
|
} else {
|
|
path_prefix.as_std_path().into()
|
|
}
|
|
}));
|
|
args
|
|
}
|
|
|
|
/// Temporarily git-ignore commonly ignored files and files over 2MB
|
|
async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
|
|
const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
|
|
let mut excludes = git.with_exclude_overrides().await?;
|
|
excludes
|
|
.add_excludes(include_str!("./checkpoint.gitignore"))
|
|
.await?;
|
|
|
|
let working_directory = git.working_directory.clone();
|
|
let untracked_files = git.list_untracked_files().await?;
|
|
let excluded_paths = untracked_files.into_iter().map(|path| {
|
|
let working_directory = working_directory.clone();
|
|
smol::spawn(async move {
|
|
let full_path = working_directory.join(path.clone());
|
|
match smol::fs::metadata(&full_path).await {
|
|
Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
|
|
Some(PathBuf::from("/").join(path.clone()))
|
|
}
|
|
_ => None,
|
|
}
|
|
})
|
|
});
|
|
|
|
let excluded_paths = futures::future::join_all(excluded_paths).await;
|
|
let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
|
|
|
|
if !excluded_paths.is_empty() {
|
|
let exclude_patterns = excluded_paths
|
|
.into_iter()
|
|
.map(|path| path.to_string_lossy().into_owned())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
excludes.add_excludes(&exclude_patterns).await?;
|
|
}
|
|
|
|
Ok(excludes)
|
|
}
|
|
|
|
pub(crate) struct GitBinary {
|
|
git_binary_path: PathBuf,
|
|
working_directory: PathBuf,
|
|
git_directory: PathBuf,
|
|
executor: BackgroundExecutor,
|
|
index_file_path: Option<PathBuf>,
|
|
envs: HashMap<String, String>,
|
|
is_trusted: bool,
|
|
}
|
|
|
|
impl GitBinary {
|
|
pub(crate) fn new(
|
|
git_binary_path: PathBuf,
|
|
working_directory: PathBuf,
|
|
git_directory: PathBuf,
|
|
executor: BackgroundExecutor,
|
|
is_trusted: bool,
|
|
) -> Self {
|
|
Self {
|
|
git_binary_path,
|
|
working_directory,
|
|
git_directory,
|
|
executor,
|
|
index_file_path: None,
|
|
envs: HashMap::default(),
|
|
is_trusted,
|
|
}
|
|
}
|
|
|
|
async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
|
|
let status_output = self
|
|
.run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
|
|
.await?;
|
|
|
|
let paths = status_output
|
|
.split('\0')
|
|
.filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
|
|
.map(|entry| PathBuf::from(&entry[3..]))
|
|
.collect::<Vec<_>>();
|
|
Ok(paths)
|
|
}
|
|
|
|
fn envs(mut self, envs: HashMap<String, String>) -> Self {
|
|
self.envs = envs;
|
|
self
|
|
}
|
|
|
|
pub async fn with_temp_index<R>(
|
|
&mut self,
|
|
f: impl AsyncFnOnce(&Self) -> Result<R>,
|
|
) -> Result<R> {
|
|
let index_file_path = self.path_for_index_id(Uuid::new_v4());
|
|
|
|
let delete_temp_index = util::defer({
|
|
let index_file_path = index_file_path.clone();
|
|
let executor = self.executor.clone();
|
|
move || {
|
|
executor
|
|
.spawn(async move {
|
|
smol::fs::remove_file(index_file_path).await.log_err();
|
|
})
|
|
.detach();
|
|
}
|
|
});
|
|
|
|
// Copy the default index file so that Git doesn't have to rebuild the
|
|
// whole index from scratch. This might fail if this is an empty repository.
|
|
smol::fs::copy(self.git_directory.join("index"), &index_file_path)
|
|
.await
|
|
.ok();
|
|
|
|
self.index_file_path = Some(index_file_path.clone());
|
|
let result = f(self).await;
|
|
self.index_file_path = None;
|
|
let result = result?;
|
|
|
|
smol::fs::remove_file(index_file_path).await.ok();
|
|
delete_temp_index.abort();
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
|
|
let path = self.git_directory.join("info").join("exclude");
|
|
|
|
GitExcludeOverride::new(path).await
|
|
}
|
|
|
|
fn path_for_index_id(&self, id: Uuid) -> PathBuf {
|
|
self.git_directory.join(format!("index-{}.tmp", id))
|
|
}
|
|
|
|
pub async fn run<S>(&self, args: &[S]) -> Result<String>
|
|
where
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let mut stdout = self.run_raw(args).await?;
|
|
if stdout.chars().last() == Some('\n') {
|
|
stdout.pop();
|
|
}
|
|
Ok(stdout)
|
|
}
|
|
|
|
/// Returns the result of the command without trimming the trailing newline.
|
|
pub async fn run_raw<S>(&self, args: &[S]) -> Result<String>
|
|
where
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let mut command = self.build_command(args);
|
|
let output = command.output().await?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
GitBinaryCommandError {
|
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
|
status: output.status,
|
|
}
|
|
);
|
|
Ok(String::from_utf8(output.stdout)?)
|
|
}
|
|
|
|
#[allow(clippy::disallowed_methods)]
|
|
pub(crate) fn build_command<S>(&self, args: &[S]) -> util::command::Command
|
|
where
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let mut command = new_command(&self.git_binary_path);
|
|
command.current_dir(&self.working_directory);
|
|
command.args(["-c", "core.fsmonitor=false"]);
|
|
command.arg("--no-optional-locks");
|
|
command.arg("--no-pager");
|
|
|
|
if !self.is_trusted {
|
|
command.args(["-c", "core.hooksPath=/dev/null"]);
|
|
command.args(["-c", "core.sshCommand=ssh"]);
|
|
command.args(["-c", "credential.helper="]);
|
|
command.args(["-c", "protocol.ext.allow=never"]);
|
|
command.args(["-c", "diff.external="]);
|
|
}
|
|
command.args(args);
|
|
|
|
// If the `diff` command is being used, we'll want to add the
|
|
// `--no-ext-diff` flag when working on an untrusted repository,
|
|
// preventing any external diff programs from being invoked.
|
|
if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
|
|
command.arg("--no-ext-diff");
|
|
}
|
|
|
|
if let Some(index_file_path) = self.index_file_path.as_ref() {
|
|
command.env("GIT_INDEX_FILE", index_file_path);
|
|
}
|
|
command.envs(&self.envs);
|
|
command
|
|
}
|
|
}
|
|
|
|
#[derive(Error, Debug)]
|
|
#[error("Git command failed:\n{stdout}{stderr}\n")]
|
|
struct GitBinaryCommandError {
|
|
stdout: String,
|
|
stderr: String,
|
|
status: ExitStatus,
|
|
}
|
|
|
|
async fn run_git_command(
|
|
env: Arc<HashMap<String, String>>,
|
|
ask_pass: AskPassDelegate,
|
|
mut command: util::command::Command,
|
|
executor: BackgroundExecutor,
|
|
) -> Result<RemoteCommandOutput> {
|
|
if env.contains_key("GIT_ASKPASS") {
|
|
let git_process = command.spawn()?;
|
|
let output = git_process.output().await?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(RemoteCommandOutput {
|
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
|
})
|
|
} else {
|
|
let ask_pass = AskPassSession::new(executor, ask_pass).await?;
|
|
command
|
|
.env("GIT_ASKPASS", ask_pass.script_path())
|
|
.env("SSH_ASKPASS", ask_pass.script_path())
|
|
.env("SSH_ASKPASS_REQUIRE", "force");
|
|
let git_process = command.spawn()?;
|
|
|
|
run_askpass_command(ask_pass, git_process).await
|
|
}
|
|
}
|
|
|
|
async fn run_askpass_command(
|
|
mut ask_pass: AskPassSession,
|
|
git_process: util::command::Child,
|
|
) -> anyhow::Result<RemoteCommandOutput> {
|
|
select_biased! {
|
|
result = ask_pass.run().fuse() => {
|
|
match result {
|
|
AskPassResult::CancelledByUser => {
|
|
Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
|
|
}
|
|
AskPassResult::Timedout => {
|
|
Err(anyhow!("Connecting to host timed out"))?
|
|
}
|
|
}
|
|
}
|
|
output = git_process.output().fuse() => {
|
|
let output = output?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(RemoteCommandOutput {
|
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
|
|
pub struct RepoPath(Arc<RelPath>);
|
|
|
|
impl std::fmt::Debug for RepoPath {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
self.0.fmt(f)
|
|
}
|
|
}
|
|
|
|
impl RepoPath {
|
|
pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
|
|
let rel_path = RelPath::unix(s.as_ref())?;
|
|
Ok(Self::from_rel_path(rel_path))
|
|
}
|
|
|
|
pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
|
|
let rel_path = RelPath::new(path, path_style)?;
|
|
Ok(Self::from_rel_path(&rel_path))
|
|
}
|
|
|
|
pub fn from_proto(proto: &str) -> Result<Self> {
|
|
let rel_path = RelPath::from_proto(proto)?;
|
|
Ok(Self(rel_path))
|
|
}
|
|
|
|
pub fn from_rel_path(path: &RelPath) -> RepoPath {
|
|
Self(Arc::from(path))
|
|
}
|
|
|
|
pub fn as_std_path(&self) -> &Path {
|
|
// git2 does not like empty paths and our RelPath infra turns `.` into ``
|
|
// so undo that here
|
|
if self.is_empty() {
|
|
Path::new(".")
|
|
} else {
|
|
self.0.as_std_path()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
|
|
RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
|
|
}
|
|
|
|
impl AsRef<Arc<RelPath>> for RepoPath {
|
|
fn as_ref(&self) -> &Arc<RelPath> {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::ops::Deref for RepoPath {
|
|
type Target = RelPath;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
|
|
|
|
impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
|
|
fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
|
|
if key.starts_with(self.0) {
|
|
Ordering::Greater
|
|
} else {
|
|
self.0.cmp(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
|
|
let mut branches = Vec::new();
|
|
for line in input.split('\n') {
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let mut fields = line.split('\x00');
|
|
let Some(head) = fields.next() else {
|
|
continue;
|
|
};
|
|
let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
|
|
continue;
|
|
};
|
|
let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
|
|
continue;
|
|
};
|
|
let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
|
|
continue;
|
|
};
|
|
let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
|
|
continue;
|
|
};
|
|
let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
|
|
continue;
|
|
};
|
|
let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
|
|
continue;
|
|
};
|
|
let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
|
|
continue;
|
|
};
|
|
|
|
branches.push(Branch {
|
|
is_head: head == "*",
|
|
ref_name,
|
|
most_recent_commit: Some(CommitSummary {
|
|
sha: head_sha,
|
|
subject,
|
|
commit_timestamp: commiterdate,
|
|
author_name: author_name,
|
|
has_parent: !parent_sha.is_empty(),
|
|
}),
|
|
upstream: if upstream_name.is_empty() {
|
|
None
|
|
} else {
|
|
Some(Upstream {
|
|
ref_name: upstream_name.into(),
|
|
tracking: upstream_tracking,
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
Ok(branches)
|
|
}
|
|
|
|
fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
|
|
if upstream_track.is_empty() {
|
|
return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
|
|
ahead: 0,
|
|
behind: 0,
|
|
}));
|
|
}
|
|
|
|
let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
|
|
let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
|
|
let mut ahead: u32 = 0;
|
|
let mut behind: u32 = 0;
|
|
for component in upstream_track.split(", ") {
|
|
if component == "gone" {
|
|
return Ok(UpstreamTracking::Gone);
|
|
}
|
|
if let Some(ahead_num) = component.strip_prefix("ahead ") {
|
|
ahead = ahead_num.parse::<u32>()?;
|
|
}
|
|
if let Some(behind_num) = component.strip_prefix("behind ") {
|
|
behind = behind_num.parse::<u32>()?;
|
|
}
|
|
}
|
|
Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
|
|
ahead,
|
|
behind,
|
|
}))
|
|
}
|
|
|
|
fn checkpoint_author_envs() -> HashMap<String, String> {
|
|
HashMap::from_iter([
|
|
("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
|
|
("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
|
|
("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
|
|
("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
|
|
])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::fs;
|
|
|
|
use super::*;
|
|
use gpui::TestAppContext;
|
|
|
|
fn disable_git_global_config() {
|
|
unsafe {
|
|
std::env::set_var("GIT_CONFIG_GLOBAL", "");
|
|
std::env::set_var("GIT_CONFIG_SYSTEM", "");
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
|
|
cx.executor().allow_parking();
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let git = GitBinary::new(
|
|
PathBuf::from("git"),
|
|
dir.path().to_path_buf(),
|
|
dir.path().join(".git"),
|
|
cx.executor(),
|
|
false,
|
|
);
|
|
let output = git
|
|
.build_command(&["version"])
|
|
.output()
|
|
.await
|
|
.expect("git version should succeed");
|
|
assert!(output.status.success());
|
|
|
|
let git = GitBinary::new(
|
|
PathBuf::from("git"),
|
|
dir.path().to_path_buf(),
|
|
dir.path().join(".git"),
|
|
cx.executor(),
|
|
false,
|
|
);
|
|
let output = git
|
|
.build_command(&["config", "--get", "core.fsmonitor"])
|
|
.output()
|
|
.await
|
|
.expect("git config should run");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert_eq!(
|
|
stdout.trim(),
|
|
"false",
|
|
"fsmonitor should be disabled for untrusted repos"
|
|
);
|
|
|
|
git2::Repository::init(dir.path()).unwrap();
|
|
let git = GitBinary::new(
|
|
PathBuf::from("git"),
|
|
dir.path().to_path_buf(),
|
|
dir.path().join(".git"),
|
|
cx.executor(),
|
|
false,
|
|
);
|
|
let output = git
|
|
.build_command(&["config", "--get", "core.hooksPath"])
|
|
.output()
|
|
.await
|
|
.expect("git config should run");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert_eq!(
|
|
stdout.trim(),
|
|
"/dev/null",
|
|
"hooksPath should be /dev/null for untrusted repos"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) {
|
|
cx.executor().allow_parking();
|
|
let dir = tempfile::tempdir().unwrap();
|
|
git2::Repository::init(dir.path()).unwrap();
|
|
|
|
let git = GitBinary::new(
|
|
PathBuf::from("git"),
|
|
dir.path().to_path_buf(),
|
|
dir.path().join(".git"),
|
|
cx.executor(),
|
|
true,
|
|
);
|
|
let output = git
|
|
.build_command(&["config", "--get", "core.fsmonitor"])
|
|
.output()
|
|
.await
|
|
.expect("git config should run");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert_eq!(
|
|
stdout.trim(),
|
|
"false",
|
|
"fsmonitor should be disabled even for trusted repos"
|
|
);
|
|
|
|
let git = GitBinary::new(
|
|
PathBuf::from("git"),
|
|
dir.path().to_path_buf(),
|
|
dir.path().join(".git"),
|
|
cx.executor(),
|
|
true,
|
|
);
|
|
let output = git
|
|
.build_command(&["config", "--get", "core.hooksPath"])
|
|
.output()
|
|
.await
|
|
.expect("git config should run");
|
|
assert!(
|
|
!output.status.success(),
|
|
"hooksPath should NOT be overridden for trusted repos"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_path_for_index_id_uses_real_git_directory(cx: &mut TestAppContext) {
|
|
cx.executor().allow_parking();
|
|
let working_directory = PathBuf::from("/code/worktree");
|
|
let git_directory = PathBuf::from("/code/repo/.git/modules/worktree");
|
|
let git = GitBinary::new(
|
|
PathBuf::from("git"),
|
|
working_directory,
|
|
git_directory.clone(),
|
|
cx.executor(),
|
|
false,
|
|
);
|
|
|
|
let path = git.path_for_index_id(Uuid::nil());
|
|
|
|
assert_eq!(
|
|
path,
|
|
git_directory.join(format!("index-{}.tmp", Uuid::nil()))
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_checkpoint_basic(cx: &mut TestAppContext) {
|
|
disable_git_global_config();
|
|
|
|
cx.executor().allow_parking();
|
|
|
|
let repo_dir = tempfile::tempdir().unwrap();
|
|
|
|
git2::Repository::init(repo_dir.path()).unwrap();
|
|
let file_path = repo_dir.path().join("file");
|
|
smol::fs::write(&file_path, "initial").await.unwrap();
|
|
|
|
let repo = RealGitRepository::new(
|
|
&repo_dir.path().join(".git"),
|
|
None,
|
|
Some("git".into()),
|
|
cx.executor(),
|
|
)
|
|
.unwrap();
|
|
|
|
repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
|
|
.await
|
|
.unwrap();
|
|
repo.commit(
|
|
"Initial commit".into(),
|
|
None,
|
|
CommitOptions::default(),
|
|
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
|
Arc::new(checkpoint_author_envs()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
smol::fs::write(&file_path, "modified before checkpoint")
|
|
.await
|
|
.unwrap();
|
|
smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
|
|
.await
|
|
.unwrap();
|
|
let checkpoint = repo.checkpoint().await.unwrap();
|
|
|
|
// Ensure the user can't see any branches after creating a checkpoint.
|
|
assert_eq!(repo.branches().await.unwrap().len(), 1);
|
|
|
|
smol::fs::write(&file_path, "modified after checkpoint")
|
|
.await
|
|
.unwrap();
|
|
repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
|
|
.await
|
|
.unwrap();
|
|
repo.commit(
|
|
"Commit after checkpoint".into(),
|
|
None,
|
|
CommitOptions::default(),
|
|
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
|
Arc::new(checkpoint_author_envs()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
|
|
.await
|
|
.unwrap();
|
|
smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
|
|
.await
|
|
.unwrap();
|
|
|
|
// Ensure checkpoint stays alive even after a Git GC.
|
|
repo.gc().await.unwrap();
|
|
repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
|
|
|
|
assert_eq!(
|
|
smol::fs::read_to_string(&file_path).await.unwrap(),
|
|
"modified before checkpoint"
|
|
);
|
|
assert_eq!(
|
|
smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
|
|
.await
|
|
.unwrap(),
|
|
"1"
|
|
);
|
|
// See TODO above
|
|
// assert_eq!(
|
|
// smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
|
|
// .await
|
|
// .ok(),
|
|
// None
|
|
// );
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
|
|
disable_git_global_config();
|
|
|
|
cx.executor().allow_parking();
|
|
|
|
let repo_dir = tempfile::tempdir().unwrap();
|
|
git2::Repository::init(repo_dir.path()).unwrap();
|
|
let repo = RealGitRepository::new(
|
|
&repo_dir.path().join(".git"),
|
|
None,
|
|
Some("git".into()),
|
|
cx.executor(),
|
|
)
|
|
.unwrap();
|
|
|
|
smol::fs::write(repo_dir.path().join("foo"), "foo")
|
|
.await
|
|
.unwrap();
|
|
let checkpoint_sha = repo.checkpoint().await.unwrap();
|
|
|
|
// Ensure the user can't see any branches after creating a checkpoint.
|
|
assert_eq!(repo.branches().await.unwrap().len(), 1);
|
|
|
|
smol::fs::write(repo_dir.path().join("foo"), "bar")
|
|
.await
|
|
.unwrap();
|
|
smol::fs::write(repo_dir.path().join("baz"), "qux")
|
|
.await
|
|
.unwrap();
|
|
repo.restore_checkpoint(checkpoint_sha).await.unwrap();
|
|
assert_eq!(
|
|
smol::fs::read_to_string(repo_dir.path().join("foo"))
|
|
.await
|
|
.unwrap(),
|
|
"foo"
|
|
);
|
|
// See TODOs above
|
|
// assert_eq!(
|
|
// smol::fs::read_to_string(repo_dir.path().join("baz"))
|
|
// .await
|
|
// .ok(),
|
|
// None
|
|
// );
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_compare_checkpoints(cx: &mut TestAppContext) {
|
|
disable_git_global_config();
|
|
|
|
cx.executor().allow_parking();
|
|
|
|
let repo_dir = tempfile::tempdir().unwrap();
|
|
git2::Repository::init(repo_dir.path()).unwrap();
|
|
let repo = RealGitRepository::new(
|
|
&repo_dir.path().join(".git"),
|
|
None,
|
|
Some("git".into()),
|
|
cx.executor(),
|
|
)
|
|
.unwrap();
|
|
|
|
smol::fs::write(repo_dir.path().join("file1"), "content1")
|
|
.await
|
|
.unwrap();
|
|
let checkpoint1 = repo.checkpoint().await.unwrap();
|
|
|
|
smol::fs::write(repo_dir.path().join("file2"), "content2")
|
|
.await
|
|
.unwrap();
|
|
let checkpoint2 = repo.checkpoint().await.unwrap();
|
|
|
|
assert!(
|
|
!repo
|
|
.compare_checkpoints(checkpoint1, checkpoint2.clone())
|
|
.await
|
|
.unwrap()
|
|
);
|
|
|
|
let checkpoint3 = repo.checkpoint().await.unwrap();
|
|
assert!(
|
|
repo.compare_checkpoints(checkpoint2, checkpoint3)
|
|
.await
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
|
|
disable_git_global_config();
|
|
|
|
cx.executor().allow_parking();
|
|
|
|
let repo_dir = tempfile::tempdir().unwrap();
|
|
let text_path = repo_dir.path().join("main.rs");
|
|
let bin_path = repo_dir.path().join("binary.o");
|
|
|
|
git2::Repository::init(repo_dir.path()).unwrap();
|
|
|
|
smol::fs::write(&text_path, "fn main() {}").await.unwrap();
|
|
|
|
smol::fs::write(&bin_path, "some binary file here")
|
|
.await
|
|
.unwrap();
|
|
|
|
let repo = RealGitRepository::new(
|
|
&repo_dir.path().join(".git"),
|
|
None,
|
|
Some("git".into()),
|
|
cx.executor(),
|
|
)
|
|
.unwrap();
|
|
|
|
// initial commit
|
|
repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
|
|
.await
|
|
.unwrap();
|
|
repo.commit(
|
|
"Initial commit".into(),
|
|
None,
|
|
CommitOptions::default(),
|
|
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
|
Arc::new(checkpoint_author_envs()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let checkpoint = repo.checkpoint().await.unwrap();
|
|
|
|
smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
|
|
.await
|
|
.unwrap();
|
|
smol::fs::write(&bin_path, "Modified binary file")
|
|
.await
|
|
.unwrap();
|
|
|
|
repo.restore_checkpoint(checkpoint).await.unwrap();
|
|
|
|
// Text files should be restored to checkpoint state,
|
|
// but binaries should not (they aren't tracked)
|
|
assert_eq!(
|
|
smol::fs::read_to_string(&text_path).await.unwrap(),
|
|
"fn main() {}"
|
|
);
|
|
|
|
assert_eq!(
|
|
smol::fs::read_to_string(&bin_path).await.unwrap(),
|
|
"Modified binary file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_branches_parsing() {
|
|
// suppress "help: octal escapes are not supported, `\0` is always null"
|
|
#[allow(clippy::octal_escapes)]
|
|
let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
|
|
assert_eq!(
|
|
parse_branch_input(input).unwrap(),
|
|
vec![Branch {
|
|
is_head: true,
|
|
ref_name: "refs/heads/zed-patches".into(),
|
|
upstream: Some(Upstream {
|
|
ref_name: "refs/remotes/origin/zed-patches".into(),
|
|
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
|
|
ahead: 0,
|
|
behind: 0
|
|
})
|
|
}),
|
|
most_recent_commit: Some(CommitSummary {
|
|
sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
|
|
subject: "generated protobuf".into(),
|
|
commit_timestamp: 1733187470,
|
|
author_name: SharedString::new_static("John Doe"),
|
|
has_parent: false,
|
|
})
|
|
}]
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_branches_parsing_containing_refs_with_missing_fields() {
|
|
#[allow(clippy::octal_escapes)]
|
|
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";
|
|
|
|
let branches = parse_branch_input(input).unwrap();
|
|
assert_eq!(branches.len(), 2);
|
|
assert_eq!(
|
|
branches,
|
|
vec![
|
|
Branch {
|
|
is_head: false,
|
|
ref_name: "refs/heads/dev".into(),
|
|
upstream: None,
|
|
most_recent_commit: Some(CommitSummary {
|
|
sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
|
|
subject: "Add feature".into(),
|
|
commit_timestamp: 1762948725,
|
|
author_name: SharedString::new_static("Zed"),
|
|
has_parent: true,
|
|
})
|
|
},
|
|
Branch {
|
|
is_head: true,
|
|
ref_name: "refs/heads/main".into(),
|
|
upstream: None,
|
|
most_recent_commit: Some(CommitSummary {
|
|
sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
|
|
subject: "Initial commit".into(),
|
|
commit_timestamp: 1762948695,
|
|
author_name: SharedString::new_static("Zed"),
|
|
has_parent: false,
|
|
})
|
|
}
|
|
]
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_upstream_branch_name() {
|
|
let upstream = Upstream {
|
|
ref_name: "refs/remotes/origin/feature/branch".into(),
|
|
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
|
|
ahead: 0,
|
|
behind: 0,
|
|
}),
|
|
};
|
|
assert_eq!(upstream.branch_name(), Some("feature/branch"));
|
|
|
|
let upstream = Upstream {
|
|
ref_name: "refs/remotes/upstream/main".into(),
|
|
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
|
|
ahead: 0,
|
|
behind: 0,
|
|
}),
|
|
};
|
|
assert_eq!(upstream.branch_name(), Some("main"));
|
|
|
|
let upstream = Upstream {
|
|
ref_name: "refs/heads/local".into(),
|
|
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
|
|
ahead: 0,
|
|
behind: 0,
|
|
}),
|
|
};
|
|
assert_eq!(upstream.branch_name(), None);
|
|
|
|
// Test case where upstream branch name differs from what might be the local branch name
|
|
let upstream = Upstream {
|
|
ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
|
|
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
|
|
ahead: 0,
|
|
behind: 0,
|
|
}),
|
|
};
|
|
assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_worktrees_from_str() {
|
|
// Empty input
|
|
let result = parse_worktrees_from_str("", None);
|
|
assert!(result.is_empty());
|
|
|
|
// Single worktree (main)
|
|
let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n";
|
|
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
|
|
assert_eq!(result[0].sha.as_ref(), "abc123def");
|
|
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
|
|
assert!(result[0].is_main);
|
|
assert!(!result[0].is_bare);
|
|
|
|
// Multiple worktrees
|
|
let input = "worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n\
|
|
worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n";
|
|
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
|
|
assert_eq!(result.len(), 2);
|
|
assert_eq!(result[0].path, PathBuf::from("/home/user/project-wt"));
|
|
assert_eq!(result[0].ref_name, Some("refs/heads/feature".into()));
|
|
assert!(!result[0].is_main);
|
|
assert!(!result[0].is_bare);
|
|
assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
|
|
assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
|
|
assert!(result[1].is_main);
|
|
assert!(!result[1].is_bare);
|
|
|
|
// Detached HEAD entry (included with ref_name: None)
|
|
let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
|
|
worktree /home/user/detached\nHEAD def456\ndetached\n\n";
|
|
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
|
|
assert_eq!(result.len(), 2);
|
|
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
|
|
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
|
|
assert!(result[0].is_main);
|
|
assert_eq!(result[1].path, PathBuf::from("/home/user/detached"));
|
|
assert_eq!(result[1].ref_name, None);
|
|
assert_eq!(result[1].sha.as_ref(), "def456");
|
|
assert!(!result[1].is_main);
|
|
assert!(!result[1].is_bare);
|
|
|
|
// Bare repo entry with no main worktree.
|
|
let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
|
|
worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
|
|
let result = parse_worktrees_from_str(input, None);
|
|
assert_eq!(result.len(), 2);
|
|
assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
|
|
assert_eq!(result[0].ref_name, None);
|
|
assert!(!result[0].is_main);
|
|
assert!(result[0].is_bare);
|
|
assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
|
|
assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
|
|
assert!(!result[1].is_main);
|
|
assert!(!result[1].is_bare);
|
|
|
|
// Extra porcelain lines (locked, prunable) should be ignored
|
|
let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
|
|
worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\
|
|
worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n";
|
|
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
|
|
assert_eq!(result.len(), 3);
|
|
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
|
|
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
|
|
assert!(result[0].is_main);
|
|
assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
|
|
assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into()));
|
|
assert!(!result[1].is_main);
|
|
assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
|
|
assert_eq!(
|
|
result[2].ref_name,
|
|
Some("refs/heads/prunable-branch".into())
|
|
);
|
|
assert!(!result[2].is_main);
|
|
|
|
// Leading/trailing whitespace on lines should be tolerated
|
|
let input =
|
|
" worktree /home/user/project \n HEAD abc123 \n branch refs/heads/main \n\n";
|
|
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
|
|
assert_eq!(result[0].sha.as_ref(), "abc123");
|
|
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
|
|
assert!(result[0].is_main);
|
|
|
|
// Windows-style line endings should be handled
|
|
let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n";
|
|
let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
|
|
assert_eq!(result[0].sha.as_ref(), "abc123");
|
|
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
|
|
assert!(result[0].is_main);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
|
|
disable_git_global_config();
|
|
cx.executor().allow_parking();
|
|
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let repo_dir = temp_dir.path().join("repo");
|
|
let worktrees_dir = temp_dir.path().join("worktrees");
|
|
|
|
fs::create_dir_all(&repo_dir).unwrap();
|
|
fs::create_dir_all(&worktrees_dir).unwrap();
|
|
|
|
git2::Repository::init(&repo_dir).unwrap();
|
|
|
|
let repo = RealGitRepository::new(
|
|
&repo_dir.join(".git"),
|
|
None,
|
|
Some("git".into()),
|
|
cx.executor(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Create an initial commit (required for worktrees)
|
|
smol::fs::write(repo_dir.join("file.txt"), "content")
|
|
.await
|
|
.unwrap();
|
|
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
|
.await
|
|
.unwrap();
|
|
repo.commit(
|
|
"Initial commit".into(),
|
|
None,
|
|
CommitOptions::default(),
|
|
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
|
Arc::new(checkpoint_author_envs()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// List worktrees — should have just the main one
|
|
let worktrees = repo.worktrees().await.unwrap();
|
|
assert_eq!(worktrees.len(), 1);
|
|
assert_eq!(
|
|
worktrees[0].path.canonicalize().unwrap(),
|
|
repo_dir.canonicalize().unwrap()
|
|
);
|
|
|
|
let worktree_path = worktrees_dir.join("some-worktree");
|
|
|
|
// Create a new worktree
|
|
repo.create_worktree(
|
|
CreateWorktreeTarget::NewBranch {
|
|
branch_name: "test-branch".to_string(),
|
|
base_sha: Some("HEAD".to_string()),
|
|
},
|
|
worktree_path.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// List worktrees — should have two
|
|
let worktrees = repo.worktrees().await.unwrap();
|
|
assert_eq!(worktrees.len(), 2);
|
|
|
|
let new_worktree = worktrees
|
|
.iter()
|
|
.find(|w| w.display_name() == "test-branch")
|
|
.expect("should find worktree with test-branch");
|
|
assert_eq!(
|
|
new_worktree.path.canonicalize().unwrap(),
|
|
worktree_path.canonicalize().unwrap(),
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_remove_worktree(cx: &mut TestAppContext) {
|
|
disable_git_global_config();
|
|
cx.executor().allow_parking();
|
|
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let repo_dir = temp_dir.path().join("repo");
|
|
let worktrees_dir = temp_dir.path().join("worktrees");
|
|
git2::Repository::init(&repo_dir).unwrap();
|
|
|
|
let repo = RealGitRepository::new(
|
|
&repo_dir.join(".git"),
|
|
None,
|
|
Some("git".into()),
|
|
cx.executor(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Create an initial commit
|
|
smol::fs::write(repo_dir.join("file.txt"), "content")
|
|
.await
|
|
.unwrap();
|
|
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
|
.await
|
|
.unwrap();
|
|
repo.commit(
|
|
"Initial commit".into(),
|
|
None,
|
|
CommitOptions::default(),
|
|
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
|
Arc::new(checkpoint_author_envs()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Create a worktree
|
|
let worktree_path = worktrees_dir.join("worktree-to-remove");
|
|
repo.create_worktree(
|
|
CreateWorktreeTarget::NewBranch {
|
|
branch_name: "to-remove".to_string(),
|
|
base_sha: Some("HEAD".to_string()),
|
|
},
|
|
worktree_path.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Remove the worktree
|
|
repo.remove_worktree(worktree_path.clone(), false)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Verify the directory is removed
|
|
let worktrees = repo.worktrees().await.unwrap();
|
|
assert_eq!(worktrees.len(), 1);
|
|
assert!(
|
|
worktrees.iter().all(|w| w.display_name() != "to-remove"),
|
|
"removed worktree should not appear in list"
|
|
);
|
|
assert!(!worktree_path.exists());
|
|
|
|
// Create a worktree
|
|
let worktree_path = worktrees_dir.join("dirty-wt");
|
|
repo.create_worktree(
|
|
CreateWorktreeTarget::NewBranch {
|
|
branch_name: "dirty-wt".to_string(),
|
|
base_sha: Some("HEAD".to_string()),
|
|
},
|
|
worktree_path.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(worktree_path.exists());
|
|
|
|
// Add uncommitted changes in the worktree
|
|
smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
|
|
.await
|
|
.unwrap();
|
|
|
|
// Non-force removal should fail with dirty worktree
|
|
let result = repo.remove_worktree(worktree_path.clone(), false).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"non-force removal of dirty worktree should fail"
|
|
);
|
|
|
|
// Force removal should succeed
|
|
repo.remove_worktree(worktree_path.clone(), true)
|
|
.await
|
|
.unwrap();
|
|
|
|
let worktrees = repo.worktrees().await.unwrap();
|
|
assert_eq!(worktrees.len(), 1);
|
|
assert!(!worktree_path.exists());
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_rename_worktree(cx: &mut TestAppContext) {
|
|
disable_git_global_config();
|
|
cx.executor().allow_parking();
|
|
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let repo_dir = temp_dir.path().join("repo");
|
|
let worktrees_dir = temp_dir.path().join("worktrees");
|
|
|
|
git2::Repository::init(&repo_dir).unwrap();
|
|
|
|
let repo = RealGitRepository::new(
|
|
&repo_dir.join(".git"),
|
|
None,
|
|
Some("git".into()),
|
|
cx.executor(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Create an initial commit
|
|
smol::fs::write(repo_dir.join("file.txt"), "content")
|
|
.await
|
|
.unwrap();
|
|
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
|
.await
|
|
.unwrap();
|
|
repo.commit(
|
|
"Initial commit".into(),
|
|
None,
|
|
CommitOptions::default(),
|
|
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
|
Arc::new(checkpoint_author_envs()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Create a worktree
|
|
let old_path = worktrees_dir.join("old-worktree-name");
|
|
repo.create_worktree(
|
|
CreateWorktreeTarget::NewBranch {
|
|
branch_name: "old-name".to_string(),
|
|
base_sha: Some("HEAD".to_string()),
|
|
},
|
|
old_path.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(old_path.exists());
|
|
|
|
// Move the worktree to a new path
|
|
let new_path = worktrees_dir.join("new-worktree-name");
|
|
repo.rename_worktree(old_path.clone(), new_path.clone())
|
|
.await
|
|
.unwrap();
|
|
|
|
// Verify the old path is gone and new path exists
|
|
assert!(!old_path.exists());
|
|
assert!(new_path.exists());
|
|
|
|
// Verify it shows up in worktree list at the new path
|
|
let worktrees = repo.worktrees().await.unwrap();
|
|
assert_eq!(worktrees.len(), 2);
|
|
let moved_worktree = worktrees
|
|
.iter()
|
|
.find(|w| w.display_name() == "old-name")
|
|
.expect("should find worktree by branch name");
|
|
assert_eq!(
|
|
moved_worktree.path.canonicalize().unwrap(),
|
|
new_path.canonicalize().unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_original_repo_path_from_common_dir() {
|
|
// Normal repo: common_dir is <work_dir>/.git
|
|
assert_eq!(
|
|
original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
|
|
Some(PathBuf::from("/code/zed5"))
|
|
);
|
|
|
|
// Worktree: common_dir is the main repo's .git
|
|
// (same result — that's the point, it always traces back to the original)
|
|
assert_eq!(
|
|
original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
|
|
Some(PathBuf::from("/code/zed5"))
|
|
);
|
|
|
|
// Bare repo: no .git suffix, returns None (no working-tree root)
|
|
assert_eq!(
|
|
original_repo_path_from_common_dir(Path::new("/code/zed5.git")),
|
|
None
|
|
);
|
|
|
|
// Root-level .git directory
|
|
assert_eq!(
|
|
original_repo_path_from_common_dir(Path::new("/.git")),
|
|
Some(PathBuf::from("/"))
|
|
);
|
|
}
|
|
|
|
impl RealGitRepository {
|
|
/// Force a Git garbage collection on the repository.
|
|
fn gc(&self) -> BoxFuture<'_, Result<()>> {
|
|
let working_directory = self.working_directory();
|
|
let git_directory = self.path();
|
|
let git_binary_path = self.any_git_binary_path.clone();
|
|
let executor = self.executor.clone();
|
|
self.executor
|
|
.spawn(async move {
|
|
let git_binary_path = git_binary_path.clone();
|
|
let working_directory = working_directory?;
|
|
let git = GitBinary::new(
|
|
git_binary_path,
|
|
working_directory,
|
|
git_directory,
|
|
executor,
|
|
true,
|
|
);
|
|
git.run(&["gc", "--prune"]).await?;
|
|
Ok(())
|
|
})
|
|
.boxed()
|
|
}
|
|
}
|
|
}
|