Show "Restore Checkpoint" only when there were changes (#27243)

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
Antonio Scandurra 2025-03-21 15:10:43 +01:00 committed by GitHub
parent 9d965bc98a
commit e14ebcf267
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 350 additions and 112 deletions

View file

@ -32,6 +32,7 @@ serde.workspace = true
smol.workspace = true
sum_tree.workspace = true
text.workspace = true
thiserror.workspace = true
time.workspace = true
url.workspace = true
util.workspace = true

View file

@ -5,14 +5,15 @@ use collections::HashMap;
use futures::future::BoxFuture;
use futures::{select_biased, AsyncWriteExt, FutureExt as _};
use git2::BranchType;
use gpui::{AppContext, AsyncApp, SharedString};
use gpui::{AppContext, AsyncApp, BackgroundExecutor, SharedString};
use parking_lot::Mutex;
use rope::Rope;
use schemars::JsonSchema;
use serde::Deserialize;
use std::borrow::Borrow;
use std::future;
use std::path::Component;
use std::process::Stdio;
use std::process::{ExitStatus, Stdio};
use std::sync::LazyLock;
use std::{
cmp::Ordering,
@ -20,6 +21,7 @@ use std::{
sync::Arc,
};
use sum_tree::MapSeekTarget;
use thiserror::Error;
use util::command::new_smol_command;
use util::ResultExt;
use uuid::Uuid;
@ -298,6 +300,14 @@ pub trait GitRepository: Send + Sync {
checkpoint: GitRepositoryCheckpoint,
cx: AsyncApp,
) -> BoxFuture<Result<()>>;
/// Compares two checkpoints, returning true if they are equal
fn compare_checkpoints(
&self,
left: GitRepositoryCheckpoint,
right: GitRepositoryCheckpoint,
cx: AsyncApp,
) -> BoxFuture<Result<bool>>;
}
pub enum DiffType {
@ -1049,62 +1059,36 @@ impl GitRepository for RealGitRepository {
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
let working_directory = working_directory?;
let index_file_path = working_directory.join(".git/index.tmp");
let delete_temp_index = util::defer({
let index_file_path = index_file_path.clone();
|| {
executor
.spawn(async move {
smol::fs::remove_file(index_file_path).await.log_err();
})
.detach();
}
});
let run_git_command = async |args: &[&str]| {
let output = new_smol_command(&git_binary_path)
.current_dir(&working_directory)
.env("GIT_INDEX_FILE", &index_file_path)
.envs(checkpoint_author_envs())
.args(args)
.output()
.await?;
if output.status.success() {
anyhow::Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
let mut git = GitBinary::new(git_binary_path, working_directory, executor)
.envs(checkpoint_author_envs());
git.with_temp_index(async |git| {
let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
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 {
let error = String::from_utf8_lossy(&output.stderr);
Err(anyhow!("Git command failed: {:?}", error))
}
};
git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
};
let ref_name = Uuid::new_v4().to_string();
git.run(&[
"update-ref",
&format!("refs/zed/{ref_name}"),
&checkpoint_sha,
])
.await?;
let head_sha = run_git_command(&["rev-parse", "HEAD"]).await.ok();
run_git_command(&["add", "--all"]).await?;
let tree = run_git_command(&["write-tree"]).await?;
let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
run_git_command(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"]).await?
} else {
run_git_command(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
};
let ref_name = Uuid::new_v4().to_string();
run_git_command(&[
"update-ref",
&format!("refs/zed/{ref_name}"),
&checkpoint_sha,
])
.await?;
smol::fs::remove_file(index_file_path).await.ok();
delete_temp_index.abort();
Ok(GitRepositoryCheckpoint {
head_sha: if let Some(head_sha) = head_sha {
Some(head_sha.parse()?)
} else {
None
},
sha: checkpoint_sha.parse()?,
Ok(GitRepositoryCheckpoint {
head_sha: if let Some(head_sha) = head_sha {
Some(head_sha.parse()?)
} else {
None
},
sha: checkpoint_sha.parse()?,
})
})
.await
})
.boxed()
}
@ -1116,50 +1100,165 @@ impl GitRepository for RealGitRepository {
) -> BoxFuture<Result<()>> {
let working_directory = self.working_directory();
let git_binary_path = self.git_binary_path.clone();
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
let working_directory = working_directory?;
let index_file_path = working_directory.join(".git/index.tmp");
let run_git_command = async |args: &[&str], use_temp_index: bool| {
let mut command = new_smol_command(&git_binary_path);
command.current_dir(&working_directory);
command.args(args);
if use_temp_index {
command.env("GIT_INDEX_FILE", &index_file_path);
}
let output = command.output().await?;
if output.status.success() {
anyhow::Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
} else {
let error = String::from_utf8_lossy(&output.stderr);
Err(anyhow!("Git command failed: {:?}", error))
}
};
run_git_command(
&[
"restore",
"--source",
&checkpoint.sha.to_string(),
"--worktree",
".",
],
false,
)
let mut git = GitBinary::new(git_binary_path, working_directory, executor);
git.run(&[
"restore",
"--source",
&checkpoint.sha.to_string(),
"--worktree",
".",
])
.await?;
git.with_temp_index(async move |git| {
git.run(&["read-tree", &checkpoint.sha.to_string()]).await?;
git.run(&["clean", "-d", "--force"]).await
})
.await?;
run_git_command(&["read-tree", &checkpoint.sha.to_string()], true).await?;
run_git_command(&["clean", "-d", "--force"], true).await?;
if let Some(head_sha) = checkpoint.head_sha {
run_git_command(&["reset", "--mixed", &head_sha.to_string()], false).await?;
git.run(&["reset", "--mixed", &head_sha.to_string()])
.await?;
} else {
run_git_command(&["update-ref", "-d", "HEAD"], false).await?;
git.run(&["update-ref", "-d", "HEAD"]).await?;
}
Ok(())
})
.boxed()
}
fn compare_checkpoints(
&self,
left: GitRepositoryCheckpoint,
right: GitRepositoryCheckpoint,
cx: AsyncApp,
) -> BoxFuture<Result<bool>> {
if left.head_sha != right.head_sha {
return future::ready(Ok(false)).boxed();
}
let working_directory = self.working_directory();
let git_binary_path = self.git_binary_path.clone();
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
let working_directory = working_directory?;
let git = GitBinary::new(git_binary_path, working_directory, executor);
let result = git
.run(&[
"diff-tree",
"--quiet",
&left.sha.to_string(),
&right.sha.to_string(),
])
.await;
match result {
Ok(_) => Ok(true),
Err(error) => {
if let Some(GitBinaryCommandError { status, .. }) =
error.downcast_ref::<GitBinaryCommandError>()
{
if status.code() == Some(1) {
return Ok(false);
}
}
Err(error)
}
}
})
.boxed()
}
}
struct GitBinary {
git_binary_path: PathBuf,
working_directory: PathBuf,
executor: BackgroundExecutor,
index_file_path: Option<PathBuf>,
envs: HashMap<String, String>,
}
impl GitBinary {
fn new(
git_binary_path: PathBuf,
working_directory: PathBuf,
executor: BackgroundExecutor,
) -> Self {
Self {
git_binary_path,
working_directory,
executor,
index_file_path: None,
envs: HashMap::default(),
}
}
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.working_directory.join(".git/index.tmp");
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();
}
});
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 run(&self, args: &[&str]) -> Result<String> {
let mut command = new_smol_command(&self.git_binary_path);
command.current_dir(&self.working_directory);
command.args(args);
if let Some(index_file_path) = self.index_file_path.as_ref() {
command.env("GIT_INDEX_FILE", index_file_path);
}
command.envs(&self.envs);
let output = command.output().await?;
if output.status.success() {
anyhow::Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
} else {
Err(anyhow!(GitBinaryCommandError {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
status: output.status,
}))
}
}
}
#[derive(Error, Debug)]
#[error("Git command failed: {stdout}")]
struct GitBinaryCommandError {
stdout: String,
status: ExitStatus,
}
async fn run_remote_command(
@ -1619,6 +1718,36 @@ mod tests {
);
}
#[gpui::test]
async fn test_compare_checkpoints(cx: &mut TestAppContext) {
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).unwrap();
smol::fs::write(repo_dir.path().join("file1"), "content1")
.await
.unwrap();
let checkpoint1 = repo.checkpoint(cx.to_async()).await.unwrap();
smol::fs::write(repo_dir.path().join("file2"), "content2")
.await
.unwrap();
let checkpoint2 = repo.checkpoint(cx.to_async()).await.unwrap();
assert!(!repo
.compare_checkpoints(checkpoint1, checkpoint2, cx.to_async())
.await
.unwrap());
let checkpoint3 = repo.checkpoint(cx.to_async()).await.unwrap();
assert!(repo
.compare_checkpoints(checkpoint2, checkpoint3, cx.to_async())
.await
.unwrap());
}
#[test]
fn test_branches_parsing() {
// suppress "help: octal escapes are not supported, `\0` is always null"