Files
openidea/crates/ide-vcs/src/preserve.rs
l0ng-ai 8c381a07c4 fix(vcs): keep local changes and staging intact across Git operations; roll back partial multi-repository operations (#1701)
* fix(vcs): keep local changes and staging intact across commit, checkout and history edits

- Commit: changes left out of a commit keep their staged state. The commit is
  made from a copy of the index (included files staged there, excluded ones
  reset to HEAD), so the user's index and other repositories are untouched.
- Smart Checkout restores staged changes as staged; untracked files a checkout
  would overwrite are listed instead of a raw git error.
- Edit Commit Message rewrites the commit and its descendants without a rebase,
  so the working tree and local changes stay as they are.
- Edit Commit Message, Drop and Squash offer Undo in their notification while
  the branch hasn't moved since.
- Interactive rebases (Drop, Squash, Interactively Rebase) put local changes
  aside themselves and bring them back with their staged state once the rebase
  is over, also after a stop for conflicts.
- Undo Commit asks for the target changelist (a new one named after the
  commit) and puts the commit message back into the commit message field.
- Cherry-pick and revert skip commits whose changes are already applied instead
  of stopping with an empty commit; commits cherry-picked from a protected
  branch get "(cherry picked from commit …)".
- Commit tool window: Commit File…, Accept Yours / Accept Theirs on conflicted
  files and Copy as Patch to Clipboard.

* fix(vcs): restore staged changes after merge, rebase and update; roll back partial multi-repository operations

- Merge, rebase, pull / Update Project and the rebase of a diverged checkout put
  local changes aside themselves and bring them back with --index, so staged
  changes stay staged (git's --autostash restored them unstaged). An operation
  stopped on conflicts keeps them aside until it is continued, skipped, aborted
  or concluded by a commit.
- Checkout, New Branch, Merge, Rebase and Delete over several repositories:
  when one repository fails after others succeeded, ask whether to roll those
  back (check out the previous branch, delete the new branch, reset to the
  commit before, restore the branch); not when it stopped on conflicts.
- Fixup… / Squash Into…: while the prepared message is in the field, the second
  commit button is Commit and Rebase, which commits and autosquashes the new
  commit into its target.
- Undo on a history rewrite replaces its notification, so the link is gone once
  used.
2026-11-06 12:13:59 +08:00

69 lines
3.0 KiB
Rust

//! Local changes put aside around merge, rebase and pull, and brought back once the
//! operation is over: staged changes as staged (git's `--autostash` brings everything
//! back unstaged). An operation that stops on conflicts keeps them aside until it is
//! continued, skipped, aborted or concluded by a commit.
use crate::Repo;
use crate::cli::{self, run};
use crate::write::unmerged_paths;
use ide_core::{IdeError, IdeResult};
/// `.git/<MARKER>` holds the id of the stash the changes went to.
const MARKER: &str = "openidea-saved-changes";
/// Stashes the local changes of tracked files, if any.
pub(crate) fn save(r: &Repo, message: &str) -> IdeResult<()> {
let marker = r.git_dir.join(MARKER);
if marker.exists() {
// Changes saved by an operation still in progress stay where they are; those of
// one concluded outside the IDE come back first.
if r.branch_info()?.operation.is_some() {
return Ok(());
}
restore(r)?;
}
if run(r.root(), &["status", "--porcelain", "--untracked-files=no"], None)?.stdout_str().trim().is_empty() {
return Ok(());
}
let before = run(r.root(), &["rev-parse", "-q", "--verify", "refs/stash"], None).map(|o| o.stdout_str()).ok();
run(r.root(), &["stash", "push", "-q", "-m", message], None)?;
let id = run(r.root(), &["rev-parse", "refs/stash"], None)?.stdout_str();
if before.as_deref().map(str::trim) == Some(id.trim()) {
return Err(IdeError::invalid("could not save the local changes"));
}
std::fs::write(marker, id.trim())?;
Ok(())
}
/// Brings the saved changes back once no operation is in progress. Fails when they come
/// back with conflicts (they then also stay in the stash).
pub(crate) fn restore(r: &Repo) -> IdeResult<()> {
let marker = r.git_dir.join(MARKER);
let Ok(saved) = std::fs::read_to_string(&marker) else { return Ok(()) };
if r.branch_info()?.operation.is_some() {
return Ok(());
}
let _ = std::fs::remove_file(&marker);
let saved = saved.trim();
let stashes = run(r.root(), &["stash", "list", "--format=%H"], None)?.stdout_str();
let Some(index) = stashes.lines().position(|id| id.trim() == saved) else { return Ok(()) };
let entry = format!("stash@{{{index}}}");
if cli::run_raw(r.root(), &["stash", "pop", "-q", "--index", &entry], None)?.code == Some(0) {
return Ok(());
}
// The staged part no longer applies as it was: bring everything back unstaged.
if unmerged_paths(r)?.is_empty() && cli::run_raw(r.root(), &["stash", "pop", "-q", &entry], None)?.code == Some(0) {
return Ok(());
}
Err(IdeError::invalid("local changes were restored with conflicts; resolve them (a copy stays in the stash)"))
}
/// Runs `op` with the local changes put aside, and brings them back if it finished.
pub(crate) fn around<T>(r: &Repo, message: &str, op: impl FnOnce() -> IdeResult<T>) -> IdeResult<T> {
save(r, message)?;
let result = op();
let restored = restore(r);
let value = result?;
restored.map(|_| value)
}