Implement staging and unstaging hunks (#24606)

- [x] Staging hunks
- [x] Unstaging hunks
- [x] Write a randomized test
- [x] Get test passing
- [x] Fix existing bug in diff_base_byte_range computation
- [x] Remote project support
- [ ] ~~Improve performance of
buffer_range_to_unchanged_diff_base_range~~
- [ ] ~~Bug: project diff editor scrolls to top when staging/unstaging
hunk~~ existing issue
- [ ] ~~UI~~ deferred
- [x] Tricky cases
  - [x] Correctly handle acting on multiple hunks for a single file
- [x] Remove path from index when unstaging the last staged hunk, if
it's absent from HEAD, or staging the only hunk, if it's deleted in the
working copy

Release Notes:

- Add `ToggleStagedSelectedDiffHunks` action for staging and unstaging
individual diff hunks
This commit is contained in:
Cole Miller 2025-02-12 14:46:42 -05:00 committed by GitHub
parent ea8da43c6b
commit eea6b526dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 768 additions and 70 deletions

View file

@ -8,6 +8,8 @@ use gpui::SharedString;
use parking_lot::Mutex;
use rope::Rope;
use std::borrow::Borrow;
use std::io::Write as _;
use std::process::Stdio;
use std::sync::LazyLock;
use std::{
cmp::Ordering,
@ -39,6 +41,8 @@ pub trait GitRepository: Send + Sync {
/// Note that for symlink entries, this will return the contents of the symlink, not the target.
fn load_committed_text(&self, path: &RepoPath) -> Option<String>;
fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()>;
/// Returns the URL of the remote with the given name.
fn remote_url(&self, name: &str) -> Option<String>;
fn branch_name(&self) -> Option<String>;
@ -161,6 +165,50 @@ impl GitRepository for RealGitRepository {
Some(content)
}
fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
let working_directory = self
.repository
.lock()
.workdir()
.context("failed to read git work directory")?
.to_path_buf();
if let Some(content) = content {
let mut child = new_std_command(&self.git_binary_path)
.current_dir(&working_directory)
.args(["hash-object", "-w", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
child.stdin.take().unwrap().write_all(content.as_bytes())?;
let output = child.wait_with_output()?.stdout;
let sha = String::from_utf8(output)?;
log::debug!("indexing SHA: {sha}, path {path:?}");
let status = new_std_command(&self.git_binary_path)
.current_dir(&working_directory)
.args(["update-index", "--add", "--cacheinfo", "100644", &sha])
.arg(path.as_ref())
.status()?;
if !status.success() {
return Err(anyhow!("Failed to add to index: {status:?}"));
}
} else {
let status = new_std_command(&self.git_binary_path)
.current_dir(&working_directory)
.args(["update-index", "--force-remove"])
.arg(path.as_ref())
.status()?;
if !status.success() {
return Err(anyhow!("Failed to remove from index: {status:?}"));
}
}
Ok(())
}
fn remote_url(&self, name: &str) -> Option<String> {
let repo = self.repository.lock();
let remote = repo.find_remote(name).ok()?;
@ -412,6 +460,20 @@ impl GitRepository for FakeGitRepository {
state.head_contents.get(path.as_ref()).cloned()
}
fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
let mut state = self.state.lock();
if let Some(content) = content {
state.index_contents.insert(path.clone(), content);
} else {
state.index_contents.remove(path);
}
state
.event_emitter
.try_send(state.path.clone())
.expect("Dropped repo change event");
Ok(())
}
fn remote_url(&self, _name: &str) -> Option<String> {
None
}