Add an undo button to the git panel (#24593)
Also prep infrastructure for pushing a commit Release Notes: - N/A --------- Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com> Co-authored-by: Conrad <conrad@zed.dev> Co-authored-by: Nate Butler <iamnbutler@gmail.com>
This commit is contained in:
parent
df8adc8b11
commit
b014afa938
41 changed files with 1437 additions and 738 deletions
|
@ -132,8 +132,8 @@ pub struct BlameEntry {
|
|||
pub author_time: Option<i64>,
|
||||
pub author_tz: Option<String>,
|
||||
|
||||
pub committer: Option<String>,
|
||||
pub committer_mail: Option<String>,
|
||||
pub committer_name: Option<String>,
|
||||
pub committer_email: Option<String>,
|
||||
pub committer_time: Option<i64>,
|
||||
pub committer_tz: Option<String>,
|
||||
|
||||
|
@ -255,10 +255,12 @@ fn parse_git_blame(output: &str) -> Result<Vec<BlameEntry>> {
|
|||
.clone_from(&existing_entry.author_mail);
|
||||
new_entry.author_time = existing_entry.author_time;
|
||||
new_entry.author_tz.clone_from(&existing_entry.author_tz);
|
||||
new_entry.committer.clone_from(&existing_entry.committer);
|
||||
new_entry
|
||||
.committer_mail
|
||||
.clone_from(&existing_entry.committer_mail);
|
||||
.committer_name
|
||||
.clone_from(&existing_entry.committer_name);
|
||||
new_entry
|
||||
.committer_email
|
||||
.clone_from(&existing_entry.committer_email);
|
||||
new_entry.committer_time = existing_entry.committer_time;
|
||||
new_entry
|
||||
.committer_tz
|
||||
|
@ -288,8 +290,8 @@ fn parse_git_blame(output: &str) -> Result<Vec<BlameEntry>> {
|
|||
}
|
||||
"author-tz" if is_committed => entry.author_tz = Some(value.into()),
|
||||
|
||||
"committer" if is_committed => entry.committer = Some(value.into()),
|
||||
"committer-mail" if is_committed => entry.committer_mail = Some(value.into()),
|
||||
"committer" if is_committed => entry.committer_name = Some(value.into()),
|
||||
"committer-mail" if is_committed => entry.committer_email = Some(value.into()),
|
||||
"committer-time" if is_committed => {
|
||||
entry.committer_time = Some(value.parse::<i64>()?)
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ actions!(
|
|||
StageAll,
|
||||
UnstageAll,
|
||||
RevertAll,
|
||||
Uncommit,
|
||||
Commit,
|
||||
ClearCommitMessage
|
||||
]
|
||||
|
|
|
@ -4,13 +4,11 @@ use anyhow::Result;
|
|||
use async_trait::async_trait;
|
||||
use collections::BTreeMap;
|
||||
use derive_more::{Deref, DerefMut};
|
||||
use gpui::{App, Global};
|
||||
use gpui::{App, Global, SharedString};
|
||||
use http_client::HttpClient;
|
||||
use parking_lot::RwLock;
|
||||
use url::Url;
|
||||
|
||||
use crate::Oid;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct PullRequest {
|
||||
pub number: u32,
|
||||
|
@ -83,7 +81,7 @@ pub trait GitHostingProvider {
|
|||
&self,
|
||||
_repo_owner: &str,
|
||||
_repo: &str,
|
||||
_commit: Oid,
|
||||
_commit: SharedString,
|
||||
_http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<Url>> {
|
||||
Ok(None)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use crate::status::FileStatus;
|
||||
use crate::GitHostingProviderRegistry;
|
||||
use crate::{blame::Blame, status::GitStatus};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use collections::{HashMap, HashSet};
|
||||
use git2::BranchType;
|
||||
use gpui::SharedString;
|
||||
|
@ -20,12 +20,63 @@ use sum_tree::MapSeekTarget;
|
|||
use util::command::new_std_command;
|
||||
use util::ResultExt;
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq)]
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub struct Branch {
|
||||
pub is_head: bool,
|
||||
pub name: SharedString,
|
||||
/// Timestamp of most recent commit, normalized to Unix Epoch format.
|
||||
pub unix_timestamp: Option<i64>,
|
||||
pub upstream: Option<Upstream>,
|
||||
pub most_recent_commit: Option<CommitSummary>,
|
||||
}
|
||||
|
||||
impl Branch {
|
||||
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 Upstream {
|
||||
pub ref_name: SharedString,
|
||||
pub tracking: Option<UpstreamTracking>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub struct UpstreamTracking {
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub struct CommitDetails {
|
||||
pub sha: SharedString,
|
||||
pub message: SharedString,
|
||||
pub commit_timestamp: i64,
|
||||
pub committer_email: SharedString,
|
||||
pub committer_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,
|
||||
}
|
||||
|
||||
pub trait GitRepository: Send + Sync {
|
||||
|
@ -45,7 +96,6 @@ pub trait GitRepository: Send + Sync {
|
|||
|
||||
/// Returns the URL of the remote with the given name.
|
||||
fn remote_url(&self, name: &str) -> Option<String>;
|
||||
fn branch_name(&self) -> Option<String>;
|
||||
|
||||
/// Returns the SHA of the current HEAD.
|
||||
fn head_sha(&self) -> Option<String>;
|
||||
|
@ -60,6 +110,10 @@ pub trait GitRepository: Send + Sync {
|
|||
fn create_branch(&self, _: &str) -> Result<()>;
|
||||
fn branch_exits(&self, _: &str) -> Result<bool>;
|
||||
|
||||
fn reset(&self, commit: &str, mode: ResetMode) -> Result<()>;
|
||||
|
||||
fn show(&self, commit: &str) -> Result<CommitDetails>;
|
||||
|
||||
fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
|
||||
|
||||
/// Returns the absolute path to the repository. For worktrees, this will be the path to the
|
||||
|
@ -132,6 +186,53 @@ impl GitRepository for RealGitRepository {
|
|||
repo.commondir().into()
|
||||
}
|
||||
|
||||
fn show(&self, commit: &str) -> Result<CommitDetails> {
|
||||
let repo = self.repository.lock();
|
||||
let Ok(commit) = repo.revparse_single(commit)?.into_commit() else {
|
||||
anyhow::bail!("{} is not a commit", commit);
|
||||
};
|
||||
let details = CommitDetails {
|
||||
sha: commit.id().to_string().into(),
|
||||
message: String::from_utf8_lossy(commit.message_raw_bytes())
|
||||
.to_string()
|
||||
.into(),
|
||||
commit_timestamp: commit.time().seconds(),
|
||||
committer_email: String::from_utf8_lossy(commit.committer().email_bytes())
|
||||
.to_string()
|
||||
.into(),
|
||||
committer_name: String::from_utf8_lossy(commit.committer().name_bytes())
|
||||
.to_string()
|
||||
.into(),
|
||||
};
|
||||
Ok(details)
|
||||
}
|
||||
|
||||
fn reset(&self, commit: &str, mode: ResetMode) -> Result<()> {
|
||||
let working_directory = self
|
||||
.repository
|
||||
.lock()
|
||||
.workdir()
|
||||
.context("failed to read git work directory")?
|
||||
.to_path_buf();
|
||||
|
||||
let mode_flag = match mode {
|
||||
ResetMode::Mixed => "--mixed",
|
||||
ResetMode::Soft => "--soft",
|
||||
};
|
||||
|
||||
let output = new_std_command(&self.git_binary_path)
|
||||
.current_dir(&working_directory)
|
||||
.args(["reset", mode_flag, commit])
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to reset:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_index_text(&self, path: &RepoPath) -> Option<String> {
|
||||
fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
|
||||
const STAGE_NORMAL: i32 = 0;
|
||||
|
@ -215,13 +316,6 @@ impl GitRepository for RealGitRepository {
|
|||
remote.url().map(|url| url.to_string())
|
||||
}
|
||||
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
let repo = self.repository.lock();
|
||||
let head = repo.head().log_err()?;
|
||||
let branch = String::from_utf8_lossy(head.shorthand_bytes());
|
||||
Some(branch.to_string())
|
||||
}
|
||||
|
||||
fn head_sha(&self) -> Option<String> {
|
||||
Some(self.repository.lock().head().ok()?.target()?.to_string())
|
||||
}
|
||||
|
@ -261,33 +355,62 @@ impl GitRepository for RealGitRepository {
|
|||
}
|
||||
|
||||
fn branches(&self) -> Result<Vec<Branch>> {
|
||||
let repo = self.repository.lock();
|
||||
let local_branches = repo.branches(Some(BranchType::Local))?;
|
||||
let valid_branches = local_branches
|
||||
.filter_map(|branch| {
|
||||
branch.ok().and_then(|(branch, _)| {
|
||||
let is_head = branch.is_head();
|
||||
let name = branch
|
||||
.name()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|name| name.to_string().into())?;
|
||||
let timestamp = branch.get().peel_to_commit().ok()?.time();
|
||||
let unix_timestamp = timestamp.seconds();
|
||||
let timezone_offset = timestamp.offset_minutes();
|
||||
let utc_offset =
|
||||
time::UtcOffset::from_whole_seconds(timezone_offset * 60).ok()?;
|
||||
let unix_timestamp =
|
||||
time::OffsetDateTime::from_unix_timestamp(unix_timestamp).ok()?;
|
||||
Some(Branch {
|
||||
is_head,
|
||||
name,
|
||||
unix_timestamp: Some(unix_timestamp.to_offset(utc_offset).unix_timestamp()),
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(valid_branches)
|
||||
let working_directory = self
|
||||
.repository
|
||||
.lock()
|
||||
.workdir()
|
||||
.context("failed to read git work directory")?
|
||||
.to_path_buf();
|
||||
let fields = [
|
||||
"%(HEAD)",
|
||||
"%(objectname)",
|
||||
"%(refname)",
|
||||
"%(upstream)",
|
||||
"%(upstream:track)",
|
||||
"%(committerdate:unix)",
|
||||
"%(contents:subject)",
|
||||
]
|
||||
.join("%00");
|
||||
let args = vec!["for-each-ref", "refs/heads/*", "--format", &fields];
|
||||
|
||||
let output = new_std_command(&self.git_binary_path)
|
||||
.current_dir(&working_directory)
|
||||
.args(args)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"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", "--short", "HEAD"];
|
||||
|
||||
let output = new_std_command(&self.git_binary_path)
|
||||
.current_dir(&working_directory)
|
||||
.args(args)
|
||||
.output()?;
|
||||
|
||||
// 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 {
|
||||
name: name.into(),
|
||||
is_head: true,
|
||||
upstream: None,
|
||||
most_recent_commit: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
fn change_branch(&self, name: &str) -> Result<()> {
|
||||
|
@ -478,11 +601,6 @@ impl GitRepository for FakeGitRepository {
|
|||
None
|
||||
}
|
||||
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
let state = self.state.lock();
|
||||
state.current_branch_name.clone()
|
||||
}
|
||||
|
||||
fn head_sha(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
@ -491,6 +609,14 @@ impl GitRepository for FakeGitRepository {
|
|||
vec![]
|
||||
}
|
||||
|
||||
fn show(&self, _: &str) -> Result<CommitDetails> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn path(&self) -> PathBuf {
|
||||
let state = self.state.lock();
|
||||
state.path.clone()
|
||||
|
@ -533,7 +659,8 @@ impl GitRepository for FakeGitRepository {
|
|||
.map(|branch_name| Branch {
|
||||
is_head: Some(branch_name) == current_branch.as_ref(),
|
||||
name: branch_name.into(),
|
||||
unix_timestamp: None,
|
||||
most_recent_commit: None,
|
||||
upstream: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
@ -703,3 +830,106 @@ impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 is_current_branch = fields.next().context("no HEAD")? == "*";
|
||||
let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
|
||||
let ref_name: SharedString = fields
|
||||
.next()
|
||||
.context("no refname")?
|
||||
.strip_prefix("refs/heads/")
|
||||
.context("unexpected format for refname")?
|
||||
.to_string()
|
||||
.into();
|
||||
let upstream_name = fields.next().context("no upstream")?.to_string();
|
||||
let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
|
||||
let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
|
||||
let subject: SharedString = fields
|
||||
.next()
|
||||
.context("no contents:subject")?
|
||||
.to_string()
|
||||
.into();
|
||||
|
||||
branches.push(Branch {
|
||||
is_head: is_current_branch,
|
||||
name: ref_name,
|
||||
most_recent_commit: Some(CommitSummary {
|
||||
sha: head_sha,
|
||||
subject,
|
||||
commit_timestamp: commiterdate,
|
||||
}),
|
||||
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<Option<UpstreamTracking>> {
|
||||
if upstream_track == "" {
|
||||
return Ok(Some(UpstreamTracking {
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
let upstream_track = upstream_track
|
||||
.strip_prefix("[")
|
||||
.ok_or_else(|| anyhow!("missing ["))?;
|
||||
let upstream_track = upstream_track
|
||||
.strip_suffix("]")
|
||||
.ok_or_else(|| anyhow!("missing ["))?;
|
||||
let mut ahead: u32 = 0;
|
||||
let mut behind: u32 = 0;
|
||||
for component in upstream_track.split(", ") {
|
||||
if component == "gone" {
|
||||
return Ok(None);
|
||||
}
|
||||
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(Some(UpstreamTracking { ahead, behind }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_branches_parsing() {
|
||||
// suppress "help: octal escapes are not supported, `\0` is always null"
|
||||
#[allow(clippy::octal_escapes)]
|
||||
let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
|
||||
assert_eq!(
|
||||
parse_branch_input(&input).unwrap(),
|
||||
vec![Branch {
|
||||
is_head: true,
|
||||
name: "zed-patches".into(),
|
||||
upstream: Some(Upstream {
|
||||
ref_name: "refs/remotes/origin/zed-patches".into(),
|
||||
tracking: Some(UpstreamTracking {
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
}),
|
||||
most_recent_commit: Some(CommitSummary {
|
||||
sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
|
||||
subject: "generated protobuf".into(),
|
||||
commit_timestamp: 1733187470,
|
||||
})
|
||||
}]
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue