From 8c381a07c4d056ad55d37ec1936af64b1bc4e253 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:13:59 +0800 Subject: [PATCH] fix(vcs): keep local changes and staging intact across Git operations; roll back partial multi-repository operations (#1701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- crates/ide-host/src/ops/vcs/commands.rs | 41 +++- crates/ide-vcs/src/lib.rs | 50 ++++- crates/ide-vcs/src/manage.rs | 30 ++- crates/ide-vcs/src/ops.rs | 82 ++++++-- crates/ide-vcs/src/preserve.rs | 68 +++++++ crates/ide-vcs/src/write.rs | 180 ++++++++++++++++-- crates/ide-vcs/tests/git_workflows.rs | 97 ++++++++++ crates/ide-vcs/tests/history_ops.rs | 42 ++++ crates/ide-vcs/tests/repo.rs | 170 +++++++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/vcs.rs | 27 +++ src/bindings.ts | 23 ++- src/features/vcs/CommitView.tsx | 71 +++++-- src/features/vcs/__tests__/changes.test.ts | 4 +- .../vcs/__tests__/commitInclusion.test.ts | 17 +- src/features/vcs/__tests__/gitOps.test.ts | 4 +- .../vcs/__tests__/multiRepoRollback.test.ts | 83 ++++++++ src/features/vcs/branchOps.ts | 125 +++++++++--- src/features/vcs/changeOps.tsx | 16 +- src/features/vcs/changelistsUi.tsx | 89 +++++++-- src/features/vcs/changes.ts | 14 +- src/features/vcs/commitStore.ts | 12 ++ src/features/vcs/conflictsUi.tsx | 3 +- src/features/vcs/operations.tsx | 16 +- src/features/vcs/ops.ts | 5 +- src/features/vcs/patches.tsx | 15 ++ src/features/vcs/rebase.tsx | 92 +++++++-- src/platform/locales/text/es.json | 2 +- src/platform/locales/text/pl.json | 2 +- src/platform/locales/text/pt.json | 2 +- src/platform/locales/text/zh.json | 23 ++- 31 files changed, 1264 insertions(+), 143 deletions(-) create mode 100644 crates/ide-vcs/src/preserve.rs create mode 100644 src/features/vcs/__tests__/multiRepoRollback.test.ts diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 82505d7e8..d076c717d 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -157,9 +157,11 @@ pub enum GitOperation { Continue, Abort, Skip, - /// Commits oldest first. + /// Commits oldest first; `record_origin` adds "(cherry picked from commit …)". CherryPick { commits: Vec, + #[serde(default, rename = "recordOrigin")] + record_origin: bool, }, /// Commits newest first. Revert { @@ -572,6 +574,33 @@ pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option, c blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await } +/// Edit Commit Message: rewrites the commit and its descendants without touching the +/// working tree. +pub async fn vcs_reword( + host: &Host, + window: &str, + repo: Option, + commit: String, + message: String, +) -> IdeResult { + host.require_trust(window)?; + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.reword(&commit, &message))).await +} + +/// Undo of a history rewrite (Edit Commit Message, Drop, Squash). +pub async fn vcs_restore_head( + host: &Host, + window: &str, + repo: Option, + before: String, + after: String, +) -> IdeResult<()> { + host.require_trust(window)?; + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_head(&before, &after))).await +} + pub async fn vcs_delete_branch( host: &Host, window: &str, @@ -836,7 +865,9 @@ pub async fn vcs_operation( GitOperation::Continue => r.continue_operation(&cancel, &out), GitOperation::Abort => r.abort_operation(&cancel, &out), GitOperation::Skip => r.skip_operation(&cancel, &out), - GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out), + GitOperation::CherryPick { commits, record_origin } => { + r.cherry_pick_with(&commits, record_origin, &cancel, &out) + } GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out), GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out), GitOperation::InteractiveRebase { base, entries } => { @@ -1247,6 +1278,12 @@ pub(crate) fn register(d: &mut Dispatcher) { "vcs_undo_commit", |c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await }, ); + d.add("vcs_reword", |c| async move { + vcs_reword(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?, c.arg("message")?).await + }); + d.add("vcs_restore_head", |c| async move { + vcs_restore_head(&c.host, &c.window, c.arg("repo")?, c.arg("before")?, c.arg("after")?).await + }); d.add("vcs_delete_branch", |c| async move { vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await }); diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 7ebcaaa6a..f5ef2206d 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -273,6 +273,12 @@ pub struct CommitOptions { /// entries of the index change; afterwards their index entries match the new commit. #[serde(default)] pub partial: Vec, + /// Files committed with their working-tree content, staged or not. + #[serde(default)] + pub stage: Vec, + /// Staged files left out of the commit; they stay staged afterwards. + #[serde(default)] + pub exclude: Vec, } /// A file of a partial commit: which of its changes against HEAD to commit. @@ -480,6 +486,10 @@ pub enum CheckoutOutcome { LocalChanges { files: Vec, }, + /// These untracked files (repository-relative paths) would be overwritten. + UntrackedFiles { + files: Vec, + }, /// Local branch `local` has commits that remote branch `remote` does not. LocalBranchAhead { local: String, @@ -492,6 +502,14 @@ pub enum CheckoutOutcome { }, } +/// HEAD before and after a history rewrite, for its Undo. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HeadChange { + pub before: String, + pub after: String, +} + /// A deleted local branch, enough to restore it (IDEA's "Restore" notification action). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -575,6 +593,7 @@ mod log_index; mod manage; mod ops; mod patch; +mod preserve; mod refs; mod remotes; mod repo; @@ -1006,7 +1025,7 @@ impl Repo { /// `amend` keeps the previous message. pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() }; + let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, ..Default::default() }; write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8.into())) } /// Commits with the Commit Options popup's choices. @@ -1048,6 +1067,17 @@ impl Repo { write::undo_commit(self, commit) } /// Deletes a local branch (`git branch -d`, `-D` when `force`). + /// Edit Commit Message without touching the index or the working tree; returns HEAD + /// before and after. + pub fn reword(&self, commit: &str, message: &str) -> IdeResult { + write::reword(self, commit, message).map(|(before, after)| HeadChange { before, after }) + } + + /// Undoes a history rewrite: HEAD goes from `after` back to `before`, local changes kept. + pub fn restore_head(&self, before: &str, after: &str) -> IdeResult<()> { + write::restore_head(self, before, after) + } + pub fn delete_branch(&self, branch: &str, force: bool) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::delete_branch(self, branch, force) @@ -1124,9 +1154,11 @@ impl Repo { pub fn pull(&self, rebase: bool, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let mode: &[&str] = if rebase { &["--rebase"] } else { &["--no-rebase", "--ff"] }; - let mut args = vec!["pull", "--progress", "--autostash"]; + let mut args = vec!["pull", "--progress"]; args.extend_from_slice(mode); - cli::run_streaming(&self.root, &args, cancel, on_output) + preserve::around(self, "Local changes saved before update", || { + cli::run_streaming(&self.root, &args, cancel, on_output) + }) } /// Pushes the current branch. `set_upstream` pushes to the branch's remote (or /// `origin`) under the same name and records it as upstream. @@ -1213,9 +1245,19 @@ impl Repo { commits: &[String], cancel: &CancellationToken, on_output: &dyn Fn(String), + ) -> IdeResult<()> { + self.cherry_pick_with(commits, false, cancel, on_output) + } + /// [`Repo::cherry_pick`]; `record_origin` adds "(cherry picked from commit …)" (`-x`). + pub fn cherry_pick_with( + &self, + commits: &[String], + record_origin: bool, + cancel: &CancellationToken, + on_output: &dyn Fn(String), ) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - ops::cherry_pick(self, commits, cancel, on_output) + ops::cherry_pick(self, commits, record_origin, cancel, on_output) } /// Reverts `commits` in order (newest first) with git's default messages. pub fn revert(&self, commits: &[String], cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { diff --git a/crates/ide-vcs/src/manage.rs b/crates/ide-vcs/src/manage.rs index 7e5dccd90..870da58f4 100644 --- a/crates/ide-vcs/src/manage.rs +++ b/crates/ide-vcs/src/manage.rs @@ -106,7 +106,7 @@ pub(crate) fn merge_args(branches: &[String], o: &MergeOptions) -> IdeResult = vec!["merge".into(), "--autostash".into()]; + let mut args: Vec = vec!["merge".into()]; args.extend(merge_flags(o)?.into_iter().map(String::from)); match o.message.as_deref().map(str::trim).filter(|m| !m.is_empty()) { Some(m) => args.extend(["-m".into(), m.to_string()]), @@ -120,7 +120,7 @@ pub(crate) fn merge_args(branches: &[String], o: &MergeOptions) -> IdeResult IdeResult> { - let mut args: Vec = vec!["rebase".into(), "--autostash".into()]; + let mut args: Vec = vec!["rebase".into()]; if o.rebase_merges { args.push("--rebase-merges".into()); } @@ -137,7 +137,7 @@ pub(crate) fn rebase_args(upstream: &str, o: &RebaseOptions) -> IdeResult IdeResult> { - let mut args: Vec = vec!["pull".into(), "--progress".into(), "--autostash".into()]; + let mut args: Vec = vec!["pull".into(), "--progress".into()]; if rebase { if o.no_ff || o.ff_only || o.squash || o.no_commit { return Err(IdeError::invalid("--rebase cannot be combined with merge options")); @@ -213,7 +213,7 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let args = merge_args(branches, options)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); - run(self.root(), &args, None).map(|_| ()) + crate::preserve::around(self, "Local changes saved before merge", || run(self.root(), &args, None).map(|_| ())) } /// Rebases the current branch onto `upstream` (or `upstream..HEAD` onto `options.onto`). @@ -221,7 +221,7 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let args = rebase_args(upstream, options)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); - run(self.root(), &args, None).map(|_| ()) + crate::preserve::around(self, "Local changes saved before rebase", || run(self.root(), &args, None).map(|_| ())) } /// Git › Pull…: pulls `branches` of `remote` into the current branch. @@ -237,7 +237,9 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let args = pull_args(remote, branches, rebase, options)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); - cli::run_streaming(self.root(), &args, cancel, on_output) + crate::preserve::around(self, "Local changes saved before update", || { + cli::run_streaming(self.root(), &args, cancel, on_output) + }) } /// Manage Remotes: adds, removes or edits a remote. @@ -305,13 +307,10 @@ mod tests { #[test] fn merge_args_follow_the_options() { let o = MergeOptions { no_ff: true, message: Some("Merge it".into()), ..Default::default() }; - assert_eq!( - merge_args(&["dev".into()], &o).unwrap(), - ["merge", "--autostash", "--no-ff", "-m", "Merge it", "dev"] - ); + assert_eq!(merge_args(&["dev".into()], &o).unwrap(), ["merge", "--no-ff", "-m", "Merge it", "dev"]); assert_eq!( merge_args(&["a".into(), "b".into()], &MergeOptions::default()).unwrap(), - ["merge", "--autostash", "--no-edit", "a", "b"] + ["merge", "--no-edit", "a", "b"] ); let bad = MergeOptions { ff_only: true, squash: true, ..Default::default() }; assert!(merge_args(&["dev".into()], &bad).is_err()); @@ -322,18 +321,15 @@ mod tests { #[test] fn rebase_and_pull_args() { let o = RebaseOptions { onto: Some("main".into()), rebase_merges: true, keep_empty: false }; - assert_eq!( - rebase_args("feature~3", &o).unwrap(), - ["rebase", "--autostash", "--rebase-merges", "--onto", "main", "feature~3"] - ); + assert_eq!(rebase_args("feature~3", &o).unwrap(), ["rebase", "--rebase-merges", "--onto", "main", "feature~3"]); assert_eq!( pull_args("origin", &["main".into()], false, &MergeOptions { ff_only: true, ..Default::default() }) .unwrap(), - ["pull", "--progress", "--autostash", "--no-rebase", "--ff-only", "origin", "main"] + ["pull", "--progress", "--no-rebase", "--ff-only", "origin", "main"] ); assert_eq!( pull_args("origin", &[], true, &MergeOptions::default()).unwrap(), - ["pull", "--progress", "--autostash", "--rebase", "origin"] + ["pull", "--progress", "--rebase", "origin"] ); assert!(pull_args("origin", &[], true, &MergeOptions { squash: true, ..Default::default() }).is_err()); } diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index af172d06d..844a95902 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -57,7 +57,8 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult { Some("cherry-pick") => sides.commit = read("CHERRY_PICK_HEAD").map(short), Some("revert") => sides.commit = read("REVERT_HEAD").map(short), // Conflicts outside an operation come from restoring stashed changes (Smart - // Checkout, update with autostash): stage 2 is the new HEAD, stage 3 the user's. + // Checkout, local changes put aside for a merge or update): stage 2 is the new HEAD, + // stage 3 the user's. None if !crate::write::unmerged_paths(r)?.is_empty() => { sides.operation = Some("unstash".into()); sides.reversed = true; @@ -128,12 +129,16 @@ pub(crate) fn continue_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(Str "revert" => &["revert", "--continue"], _ => &["am", "--continue"], }; - cli::run_streaming(r.root(), args, cancel, out) + let result = cli::run_streaming(r.root(), args, cancel, out); + let finished = finish_operation(r); + result.and(finished) } pub(crate) fn abort_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> { let op = sequencer(r)?; - cli::run_streaming(r.root(), &[op, "--abort"], cancel, out) + let result = cli::run_streaming(r.root(), &[op, "--abort"], cancel, out); + let finished = finish_operation(r); + result.and(finished) } pub(crate) fn skip_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> { @@ -141,7 +146,9 @@ pub(crate) fn skip_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String) if op == "merge" { return Err(IdeError::invalid("a merge cannot be skipped; abort it instead")); } - cli::run_streaming(r.root(), &[op, "--skip"], cancel, out) + let result = cli::run_streaming(r.root(), &[op, "--skip"], cancel, out); + let finished = finish_operation(r); + result.and(finished) } /// Merge commits need a mainline (`-m`), which IDEA doesn't offer for cherry-pick / revert. @@ -157,18 +164,23 @@ fn reject_merges(r: &Repo, commits: &[String], verb: &str) -> IdeResult<()> { Ok(()) } -/// Applies `commits` on top of HEAD, in the given order (pass oldest first). +/// Applies `commits` on top of HEAD, in the given order (pass oldest first); +/// `record_origin` adds "(cherry picked from commit …)" to their messages. pub(crate) fn cherry_pick( r: &Repo, commits: &[String], + record_origin: bool, cancel: &CancellationToken, out: &dyn Fn(String), ) -> IdeResult<()> { check_commits(commits)?; reject_merges(r, commits, "cherry-pick")?; let mut args = vec!["cherry-pick"]; + if record_origin { + args.push("-x"); + } args.extend(commits.iter().map(String::as_str)); - cli::run_streaming(r.root(), &args, cancel, out) + apply_skipping_empty(r, "cherry-pick", &args, cancel, out) } /// Creates commits undoing `commits`, in the given order (pass newest first). @@ -177,7 +189,42 @@ pub(crate) fn revert(r: &Repo, commits: &[String], cancel: &CancellationToken, o reject_merges(r, commits, "revert")?; let mut args = vec!["revert", "--no-edit"]; args.extend(commits.iter().map(String::as_str)); - cli::run_streaming(r.root(), &args, cancel, out) + apply_skipping_empty(r, "revert", &args, cancel, out) +} + +/// Runs a cherry-pick / revert sequence. A commit whose changes are already there leaves +/// nothing to commit: it is skipped and the rest of the sequence goes on. +fn apply_skipping_empty( + r: &Repo, + verb: &str, + args: &[&str], + cancel: &CancellationToken, + out: &dyn Fn(String), +) -> IdeResult<()> { + let head_file = if verb == "revert" { "REVERT_HEAD" } else { "CHERRY_PICK_HEAD" }; + let said_nothing = std::cell::Cell::new(false); + let watch = |line: String| { + // git runs with LC_ALL=C; a single commit with nothing left to apply stops here. + said_nothing.set(said_nothing.get() || line.contains("nothing to commit")); + out(line) + }; + let mut result = cli::run_streaming(r.root(), args, cancel, &watch); + if result.is_err() && said_nothing.get() && r.branch_info()?.operation.is_none() { + out("Skipped: its changes are already applied".into()); + return Ok(()); + } + while result.is_err() && !cancel.is_cancelled() { + let Ok(stopped) = std::fs::read_to_string(r.git_dir.join(head_file)) else { break }; + let empty = unmerged_paths(r)?.is_empty() + && cli::run_raw(r.root(), &["diff", "--cached", "--quiet", "HEAD", "--"], None)?.code == Some(0); + if !empty { + break; + } + let id = stopped.trim(); + out(format!("{} skipped: its changes are already applied", &id[..id.len().min(8)])); + result = cli::run_streaming(r.root(), &[verb, "--skip"], cancel, out); + } + result } pub(crate) fn reset( @@ -289,15 +336,26 @@ pub(crate) fn interactive_rebase( let todo_file = work.join("todo"); std::fs::write(&todo_file, todo)?; let editor = format!("cp {}", quote(&todo_file)); - let mut args = vec!["rebase", "--interactive", "--autostash", "--no-autosquash"]; + // Local changes are put aside and restored when the rebase is over, staged ones as + // staged (git's --autostash would bring them all back unstaged). + crate::preserve::save(r, "Local changes saved before rebase")?; + let mut args = vec!["rebase", "--interactive", "--no-autosquash"]; args.push(base.unwrap_or("--root")); let env = [("GIT_SEQUENCE_EDITOR", std::ffi::OsStr::new(&editor))]; - cli::run_streaming_env(r.root(), &args, &env, cancel, out)?; + let result = cli::run_streaming_env(r.root(), &args, &env, cancel, out); // A stopped rebase still needs the message files for its remaining `exec` lines. - if r.branch_info()?.operation.is_none() { - let _ = std::fs::remove_dir_all(&work); + let finished = finish_operation(r); + result.and(finished) +} + +/// Once a rebase / merge / cherry-pick / revert is over, brings back the local changes +/// put aside for it; the plan files of [`interactive_rebase`] go once it is done. +pub(crate) fn finish_operation(r: &Repo) -> IdeResult<()> { + if r.branch_info()?.operation.is_some() { + return Ok(()); } - Ok(()) + let _ = std::fs::remove_dir_all(r.git_dir.join("openidea-rebase")); + crate::preserve::restore(r) } /// Creates a tag on `commit`: annotated when `message` is non-empty, else lightweight. diff --git a/crates/ide-vcs/src/preserve.rs b/crates/ide-vcs/src/preserve.rs new file mode 100644 index 000000000..ad5717e04 --- /dev/null +++ b/crates/ide-vcs/src/preserve.rs @@ -0,0 +1,68 @@ +//! 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/` 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(r: &Repo, message: &str, op: impl FnOnce() -> IdeResult) -> IdeResult { + save(r, message)?; + let result = op(); + let restored = restore(r); + let value = result?; + restored.map(|_| value) +} diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 4e4f92832..1c6ab0d09 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -372,11 +372,13 @@ pub(crate) fn commit( args.extend(["-F", "-"]); Some(message.as_bytes()) }; - if options.partial.is_empty() { + if options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty() { run(r.root(), &args, stdin)?; } else { - commit_partial(r, &args, stdin, &options.partial, encoding)?; + commit_selected(r, &args, stdin, options, encoding)?; } + // A commit concluding a merge brings back the local changes put aside for it. + let _ = crate::preserve::restore(r); let repo = r.gix()?; Ok(repo.head_id().map_err(gx)?.detach().to_string()) } @@ -391,15 +393,16 @@ impl Drop for TempIndex { } } -/// Commits the index with only the chosen hunks of `partial` files, through a copy of the -/// index (`GIT_INDEX_FILE`), as IDEA's partial commit does: the working tree and the -/// user's index are left alone except that the partial files' entries end up matching -/// the new commit. -fn commit_partial( +/// Commits a selection of the changes through a copy of the index (`GIT_INDEX_FILE`): +/// `exclude` goes back to HEAD in the copy, `stage` takes its working-tree content and +/// `partial` only the chosen hunks. The working tree and the user's index are left alone +/// except that the committed files' entries end up matching the new commit, so changes +/// left out keep their staged state. +fn commit_selected( r: &Repo, commit_args: &[&str], stdin: Option<&[u8]>, - partial: &[crate::PartialCommitFile], + options: &CommitOptions, encoding: &dyn Fn(&Path) -> IdeResult, ) -> IdeResult<()> { let tmp = TempIndex(r.git_dir.join("openidea-partial-index")); @@ -408,8 +411,22 @@ fn commit_partial( std::fs::copy(&index, &tmp.0)?; } let env = [("GIT_INDEX_FILE", tmp.0.as_os_str())]; - let mut rels = Vec::new(); - for file in partial { + let rel_paths = |paths: &[ide_core::IpcPath]| -> IdeResult> { + let paths: Vec = paths.iter().map(|p| p.0.clone()).collect(); + r.rel_paths(&paths) + }; + let exclude = rel_paths(&options.exclude)?; + if !exclude.is_empty() { + let args: &[&str] = if r.has_head()? { + &["--literal-pathspecs", "reset", "-q", "HEAD", "--"] + } else { + &["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--"] + }; + cli::run_paths_env(r.root(), args, &exclude, None, &env)?; + } + let mut rels = rel_paths(&options.stage)?; + cli::run_paths_env(r.root(), &["--literal-pathspecs", "add", "-A", "--"], &rels, None, &env)?; + for file in &options.partial { let rel = r.rel_path(&file.path.0)?; let head = { let repo = r.gix()?; @@ -525,8 +542,16 @@ fn switch_plan(r: &Repo, branch: &str, create: bool, diverged: Option Option> { - let mut lines = - stderr.lines().skip_while(|l| !l.contains("local changes to the following files would be overwritten")); + listed_files(stderr, "local changes to the following files would be overwritten") +} + +/// Untracked files git refuses to overwrite, from its error message. +fn overwritten_untracked(stderr: &str) -> Option> { + listed_files(stderr, "untracked working tree files would be") +} + +fn listed_files(stderr: &str, header: &str) -> Option> { + let mut lines = stderr.lines().skip_while(|l| !l.contains(header)); lines.next()?; Some(lines.take_while(|l| l.starts_with('\t')).map(|l| l.trim().to_string()).collect()) } @@ -547,10 +572,13 @@ pub(crate) fn checkout_with( let mut args = match switch_plan(r, branch, create, options.diverged)? { SwitchPlan::Ahead { local, remote } => return Ok(CheckoutOutcome::LocalBranchAhead { local, remote }), SwitchPlan::Rebase { local, remote } => { - let out = cli::run_raw(r.root(), &["rebase", "--autostash", &remote, &local], None)?; + crate::preserve::save(r, "Local changes saved before checkout")?; + let out = cli::run_raw(r.root(), &["rebase", &remote, &local], None)?; if out.code == Some(0) { + crate::preserve::restore(r)?; return Ok(CheckoutOutcome::Done); } + crate::preserve::restore(r)?; let files = unmerged_paths(r)?; if files.is_empty() { return Err(cli::failure(&["rebase"], &out.stderr, &out.stdout, out.code)); @@ -567,7 +595,11 @@ pub(crate) fn checkout_with( if out.code == Some(0) { return Ok(CheckoutOutcome::Done); } - let Some(files) = overwritten_files(&String::from_utf8_lossy(&out.stderr)) else { + let stderr = String::from_utf8_lossy(&out.stderr); + if let Some(files) = overwritten_untracked(&stderr) { + return Ok(CheckoutOutcome::UntrackedFiles { files }); + } + let Some(files) = overwritten_files(&stderr) else { return Err(cli::failure(&argv, &out.stderr, &out.stdout, out.code)); }; if options.mode != CheckoutMode::Smart { @@ -583,7 +615,17 @@ pub(crate) fn checkout_with( run(r.root(), &["stash", "pop", "-q", "--index"], None)?; return Err(e); } - let pop = cli::run_raw(r.root(), &["stash", "pop", "-q"], None)?; + // Staged changes come back staged; when the index can't be restored as it was (it + // conflicts with the new branch), the changes come back unstaged. + let pop = cli::run_raw(r.root(), &["stash", "pop", "-q", "--index"], None)?; + if pop.code == Some(0) { + return Ok(CheckoutOutcome::Done); + } + let pop = if unmerged_paths(r)?.is_empty() && stash_count(r)? > before { + cli::run_raw(r.root(), &["stash", "pop", "-q"], None)? + } else { + pop + }; if pop.code == Some(0) { return Ok(CheckoutOutcome::Done); } @@ -623,6 +665,104 @@ pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout: /// Undo Commit: moves the current branch back to the parent of `commit` (which must be /// HEAD), keeping its changes staged. Returns the undone commit's message. +/// Edit Commit Message: writes `commit` again with `message`, and its descendants on top +/// of it with their own trees, then moves HEAD to the new tip. Trees don't change, so the +/// index and the working tree stay as they are (local changes included). Returns HEAD +/// before and after, for [`restore_head`]. +pub(crate) fn reword(r: &Repo, commit: &str, message: &str) -> IdeResult<(String, String)> { + if r.branch_info()?.operation.is_some() { + return Err(IdeError::invalid( + "cannot edit a commit message while a merge, rebase or cherry-pick is in progress", + )); + } + let message = message.trim(); + if message.is_empty() { + return Err(IdeError::invalid("commit message is empty")); + } + let (target, head) = { + let repo = r.gix()?; + let head = repo.head_id().map_err(|_| IdeError::invalid("there is no commit to edit"))?.detach(); + let target = repo.find_object(resolve(&repo, commit)?).map_err(gx)?.peel_to_commit().map_err(gx)?.id; + (target.to_string(), head.to_string()) + }; + if cli::run_raw(r.root(), &["merge-base", "--is-ancestor", &target, &head], None)?.code != Some(0) { + return Err(IdeError::invalid("the commit is not in the current branch")); + } + // target..HEAD must be a straight line on top of the target. + let range = format!("{target}..{head}"); + let listed = run(r.root(), &["rev-list", "--reverse", "--topo-order", "--parents", &range], None)?.stdout_str(); + let mut chain = vec![target.clone()]; + for line in listed.lines() { + let ids: Vec<&str> = line.split_whitespace().collect(); + if ids.len() != 2 || ids[1] != chain.last().unwrap() { + return Err(IdeError::invalid("the commits after this one are not a linear history")); + } + chain.push(ids[0].to_string()); + } + let mut parent: Option = None; + for (i, id) in chain.iter().enumerate() { + let raw = run(r.root(), &["cat-file", "commit", id], None)?.stdout; + let text = String::from_utf8_lossy(&raw); + let (headers, body) = text.split_once("\n\n").unwrap_or((&text, "")); + let mut tree = ""; + let mut parents = Vec::new(); + let mut author = ""; + for line in headers.lines() { + if let Some(v) = line.strip_prefix("tree ") { + tree = v; + } else if let Some(v) = line.strip_prefix("parent ") { + parents.push(v.to_string()); + } else if let Some(v) = line.strip_prefix("author ") { + author = v; + } + } + if parents.len() > 1 { + return Err(IdeError::invalid("merge commits cannot be reworded")); + } + if let Some(p) = &parent { + parents = vec![p.clone()]; + } + let (name, rest) = author.split_once(" <").unwrap_or((author, "")); + let (email, date) = rest.split_once("> ").unwrap_or((rest, "")); + let env = [ + ("GIT_AUTHOR_NAME", std::ffi::OsStr::new(name)), + ("GIT_AUTHOR_EMAIL", std::ffi::OsStr::new(email)), + ("GIT_AUTHOR_DATE", std::ffi::OsStr::new(date)), + ]; + let mut args = vec!["commit-tree", tree]; + for p in &parents { + args.extend(["-p", p.as_str()]); + } + let new_message = if i == 0 { format!("{message}\n") } else { body.to_string() }; + let created = cli::run_env(r.root(), &args, Some(new_message.as_bytes()), &env)?.stdout_str(); + parent = Some(created.trim().to_string()); + } + let new_head = parent.expect("the chain holds the target"); + let reason = format!("reword: {}", message.lines().next().unwrap_or_default()); + run(r.root(), &["update-ref", "-m", &reason, "HEAD", &new_head, &head], None)?; + Ok((head, new_head)) +} + +/// Undo of a history rewrite: moves HEAD from `after` back to `before`, keeping local +/// changes (`git reset --keep`). Refused when HEAD has moved since. +pub(crate) fn restore_head(r: &Repo, before: &str, after: &str) -> IdeResult<()> { + if r.branch_info()?.operation.is_some() { + return Err(IdeError::invalid("cannot undo while a merge, rebase or cherry-pick is in progress")); + } + let head = run(r.root(), &["rev-parse", "--verify", "HEAD"], None)?.stdout_str(); + if head.trim() != after { + return Err(IdeError::invalid("the branch has changed since; undo is no longer possible")); + } + let before = check_rev(before)?; + let tree = |rev: &str| run(r.root(), &["rev-parse", &format!("{rev}^{{tree}}")], None).map(|o| o.stdout_str()); + if tree(before)? == tree(after)? { + run(r.root(), &["update-ref", "-m", "undo", "HEAD", before, after], None)?; + } else { + run(r.root(), &["reset", "-q", "--keep", before, "--"], None)?; + } + Ok(()) +} + pub(crate) fn undo_commit(r: &Repo, commit: &str) -> IdeResult { if r.branch_info()?.operation.is_some() { return Err(IdeError::invalid("cannot undo a commit while a merge, rebase or cherry-pick is in progress")); @@ -711,11 +851,17 @@ fn check_rev(rev: &str) -> IdeResult<&str> { /// Local changes are stashed and restored around the merge (IDEA's "save local changes"). pub(crate) fn merge(r: &Repo, branch: &str) -> IdeResult<()> { - run(r.root(), &["merge", "--no-edit", "--autostash", check_rev(branch)?], None).map(|_| ()) + let branch = check_rev(branch)?; + crate::preserve::around(r, "Local changes saved before merge", || { + run(r.root(), &["merge", "--no-edit", branch], None).map(|_| ()) + }) } pub(crate) fn rebase(r: &Repo, onto: &str) -> IdeResult<()> { - run(r.root(), &["rebase", "--autostash", check_rev(onto)?], None).map(|_| ()) + let onto = check_rev(onto)?; + crate::preserve::around(r, "Local changes saved before rebase", || { + run(r.root(), &["rebase", onto], None).map(|_| ()) + }) } pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> { diff --git a/crates/ide-vcs/tests/git_workflows.rs b/crates/ide-vcs/tests/git_workflows.rs index 00552a0bc..be30861b0 100644 --- a/crates/ide-vcs/tests/git_workflows.rs +++ b/crates/ide-vcs/tests/git_workflows.rs @@ -863,3 +863,100 @@ fn remote_progress_is_streamed() { fx.repo().push(false, false, &CancellationToken::new(), &move |l| sink.lock().unwrap().push(l)).unwrap(); assert!(!lines.lock().unwrap().is_empty()); } + +#[test] +fn interactive_rebase_restores_local_changes_staged_as_they_were() { + let (fx, ids) = linear(); + let repo = fx.repo(); + let (cancel, out) = quiet(); + fx.write("f1.txt", "staged\n"); + fx.git(&["add", "f1.txt"]); + fx.write("f2.txt", "unstaged\n"); + repo.interactive_rebase( + Some(&ids[1]), + &[entry(&ids[2], RebaseAction::Drop, None), entry(&ids[3], RebaseAction::Pick, None)], + &cancel, + &out, + ) + .unwrap(); + assert_eq!(fx.subjects(), vec!["c4", "c2", "c1"]); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); + assert_eq!(fx.git(&["diff", "--name-only"]), "f2.txt"); + assert!(fx.git(&["stash", "list"]).is_empty()); + assert!(!fx.path(".git/openidea-rebase").exists()); +} + +#[test] +fn local_changes_come_back_once_a_stopped_rebase_is_continued() { + let (fx, ids) = linear(); + let repo = fx.repo(); + let (cancel, out) = quiet(); + // c4 rewrites f3.txt so that dropping c3 conflicts. + fx.write("f3.txt", "3\nmore\n"); + fx.commit_all("c5"); + let c5 = fx.git(&["rev-parse", "HEAD"]); + fx.write("f1.txt", "staged\n"); + fx.git(&["add", "f1.txt"]); + let result = repo.interactive_rebase( + Some(&ids[1]), + &[ + entry(&ids[2], RebaseAction::Drop, None), + entry(&ids[3], RebaseAction::Pick, None), + entry(&c5, RebaseAction::Pick, None), + ], + &cancel, + &out, + ); + assert!(result.is_err()); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); + assert_eq!(fx.read("f1.txt"), "1\n", "put aside while the rebase is stopped"); + fx.write("f3.txt", "3\nmore\n"); + repo.stage(&[fx.path("f3.txt")]).unwrap(); + repo.continue_operation(&cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.read("f1.txt"), "staged\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); + assert!(fx.git(&["stash", "list"]).is_empty()); +} + +#[test] +fn merge_brings_staged_changes_back_staged() { + let (fx, _ids) = linear(); + let repo = fx.repo(); + fx.git(&["checkout", "-q", "-b", "side", "HEAD~1"]); + fx.write("side.txt", "side\n"); + fx.git(&["add", "side.txt"]); + fx.git(&["commit", "-q", "-m", "side"]); + fx.git(&["checkout", "-q", "main"]); + fx.write("f1.txt", "staged\n"); + fx.git(&["add", "f1.txt"]); + fx.write("f2.txt", "unstaged\n"); + repo.merge_with(&["side".to_string()], &MergeOptions::default()).unwrap(); + assert!(fx.exists("side.txt")); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); + assert_eq!(fx.git(&["diff", "--name-only"]), "f2.txt"); + assert!(fx.git(&["stash", "list"]).is_empty()); +} + +#[test] +fn local_changes_come_back_after_a_conflicting_merge_is_committed() { + let (fx, _ids) = linear(); + let repo = fx.repo(); + fx.git(&["checkout", "-q", "-b", "side", "HEAD~1"]); + fx.write("f4.txt", "side\n"); + fx.git(&["add", "f4.txt"]); + fx.git(&["commit", "-q", "-m", "side f4"]); + fx.git(&["checkout", "-q", "main"]); + fx.write("f1.txt", "staged\n"); + fx.git(&["add", "f1.txt"]); + assert!(repo.merge_with(&["side".to_string()], &MergeOptions::default()).is_err()); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("merge")); + assert_eq!(fx.read("f1.txt"), "1\n", "put aside while the merge is in progress"); + fx.write("f4.txt", "both\n"); + repo.stage(&[fx.path("f4.txt")]).unwrap(); + repo.commit("Merge side", false, false).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.read("f1.txt"), "staged\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt"); + assert!(fx.git(&["stash", "list"]).is_empty()); +} diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index 2d86ebc49..79f428492 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -662,3 +662,45 @@ fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() { shelf::delete_path(&shelf, "plain", Some("one.txt")).unwrap(); assert_eq!(shelf::list(&shelf).unwrap().iter().find(|s| s.id == "plain").unwrap().files[0].path, "two.txt"); } + +#[test] +fn cherry_pick_skips_commits_already_applied() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + fx.commit_all("base"); + fx.git(&["checkout", "-q", "-b", "side"]); + fx.write("a.txt", "2\n"); + let same = fx.commit_all("same change"); + fx.write("b.txt", "b\n"); + let other = fx.commit_all("other change"); + fx.git(&["checkout", "-q", "main"]); + // main already has the first change. + fx.write("a.txt", "2\n"); + fx.commit_all("same change, made on main"); + let repo = fx.repo(); + let cancel = CancellationToken::new(); + let lines = std::sync::Mutex::new(Vec::new()); + let out = |l: String| lines.lock().unwrap().push(l); + + repo.cherry_pick_with(&[same.clone(), other], true, &cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None, "nothing left in progress"); + assert_eq!(fx.git(&["log", "--format=%s", "-2"]), "other change\nsame change, made on main"); + assert!(fx.git(&["log", "-1", "--format=%B"]).contains("(cherry picked from commit")); + assert!(lines.lock().unwrap().iter().any(|l| l.starts_with(&same[..8]) && l.contains("skipped"))); +} + +#[test] +fn revert_of_a_commit_already_reverted_is_skipped() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + fx.commit_all("one"); + fx.write("a.txt", "2\n"); + let two = fx.commit_all("two"); + fx.write("a.txt", "1\n"); + fx.commit_all("undo two by hand"); + let repo = fx.repo(); + let (cancel, out) = quiet(); + repo.revert(&[two], &cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.git(&["rev-list", "--count", "HEAD"]), "3", "no empty revert commit"); +} diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index c2db88f85..ba3fe7fa8 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -1687,3 +1687,173 @@ fn restricted_conflict_preview_uses_host_owned_temporary_files() { assert!(versions.merged.unwrap().contains("<<<<<<<")); assert_eq!(std::fs::read_to_string(marker).unwrap(), "untouched"); } + +#[test] +fn commit_leaves_excluded_staged_changes_staged() { + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + fx.write("b.txt", "b\n"); + fx.write("old.txt", "renamed\n"); + fx.commit_all("base"); + let repo = fx.repo(); + + // a.txt: staged and edited again; b.txt: staged; new.txt: staged addition; old.txt: staged rename. + fx.write("a.txt", "a staged\n"); + fx.write("b.txt", "b staged\n"); + fx.write("new.txt", "new\n"); + fx.git(&["add", "a.txt", "b.txt", "new.txt"]); + fx.git(&["mv", "old.txt", "moved.txt"]); + fx.write("a.txt", "a on disk\n"); + + // Commit a.txt as it is on disk; leave out b.txt, new.txt and the rename. + let options = CommitOptions { + stage: vec![fx.path("a.txt").into()], + exclude: vec![ + fx.path("b.txt").into(), + fx.path("new.txt").into(), + fx.path("moved.txt").into(), + fx.path("old.txt").into(), + ], + ..Default::default() + }; + repo.commit_with("only a", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap(); + + assert_eq!(fx.git(&["show", "--name-only", "--format=", "HEAD"]), "a.txt"); + assert_eq!(fx.git(&["show", "HEAD:a.txt"]), "a on disk"); + // What was left out is still staged, exactly as before. + let mut staged: Vec = + fx.git(&["diff", "--cached", "--name-status", "-M"]).lines().map(str::to_string).collect(); + staged.sort(); + assert_eq!(staged, ["A\tnew.txt", "M\tb.txt", "R100\told.txt\tmoved.txt"]); + assert_eq!(fx.git(&["show", ":b.txt"]), "b staged"); + assert_eq!(fx.git(&["diff", "--name-only"]), "", "nothing unstaged is left behind"); + assert!(!fx.dir.join(".git/openidea-partial-index").exists()); +} + +#[test] +fn commit_of_staged_version_keeps_the_unstaged_rest() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + fx.write("b.txt", "1\n"); + fx.commit_all("base"); + let repo = fx.repo(); + fx.write("a.txt", "2\n"); + fx.write("b.txt", "2\n"); + fx.git(&["add", "a.txt", "b.txt"]); + fx.write("a.txt", "3\n"); + + // a.txt committed as staged (its unstaged edit stays), b.txt left out. + let options = CommitOptions { exclude: vec![fx.path("b.txt").into()], ..Default::default() }; + repo.commit_with("staged a", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap(); + + assert_eq!(fx.git(&["show", "HEAD:a.txt"]), "2"); + assert_eq!(fx.git(&["show", "HEAD:b.txt"]), "1"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "b.txt"); + assert_eq!(fx.git(&["diff", "--name-only"]), "a.txt"); + assert_eq!(fx.read("a.txt"), "3\n"); +} + +#[test] +fn commit_with_exclusions_in_a_repository_without_commits() { + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + fx.write("b.txt", "b\n"); + fx.git(&["add", "-A"]); + let repo = fx.repo(); + let options = CommitOptions { exclude: vec![fx.path("b.txt").into()], ..Default::default() }; + repo.commit_with("first", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap(); + assert_eq!(fx.git(&["show", "--name-only", "--format=", "HEAD"]), "a.txt"); + assert_eq!(fx.git(&["diff", "--cached", "--name-status"]), "A\tb.txt"); +} + +#[test] +fn smart_checkout_keeps_staged_changes_staged() { + let fx = Fixture::new(); + fx.write("f.txt", "1\n2\n3\n4\n5\n"); + fx.write("s.txt", "s\n"); + fx.commit_all("base"); + fx.git(&["checkout", "-q", "-b", "other"]); + fx.write("f.txt", "1\nother\n3\n4\n5\n"); + fx.commit_all("other edit"); + fx.git(&["checkout", "-q", "main"]); + let repo = fx.repo(); + + fx.write("f.txt", "1\n2\n3\n4\nlocal\n"); + fx.write("s.txt", "staged\n"); + fx.git(&["add", "s.txt"]); + let opts = CheckoutOptions { mode: CheckoutMode::Smart, diverged: None }; + assert_eq!(repo.checkout_with("other", false, &opts).unwrap(), CheckoutOutcome::Done); + assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("other")); + assert_eq!(fx.read("f.txt"), "1\nother\n3\n4\nlocal\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "s.txt", "staged change stays staged"); + assert_eq!(fx.git(&["diff", "--name-only"]), "f.txt"); + assert!(repo.stashes().unwrap().is_empty()); +} + +#[test] +fn checkout_reports_untracked_files_it_would_overwrite() { + let fx = Fixture::new(); + fx.write("f.txt", "f\n"); + fx.commit_all("base"); + fx.git(&["checkout", "-q", "-b", "other"]); + fx.write("dir/new.txt", "theirs\n"); + fx.commit_all("add new"); + fx.git(&["checkout", "-q", "main"]); + fx.write("dir/new.txt", "mine\n"); + let repo = fx.repo(); + for mode in [CheckoutMode::Normal, CheckoutMode::Smart] { + let opts = CheckoutOptions { mode, diverged: None }; + let outcome = repo.checkout_with("other", false, &opts).unwrap(); + assert_eq!(outcome, CheckoutOutcome::UntrackedFiles { files: vec!["dir/new.txt".into()] }); + assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main")); + assert_eq!(fx.read("dir/new.txt"), "mine\n"); + } +} + +#[test] +fn reword_keeps_local_changes_and_can_be_undone() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + let first = fx.commit_all("first"); + fx.write("a.txt", "2\n"); + let second = fx.commit_all("second"); + fx.write("b.txt", "b\n"); + fx.commit_all("third"); + // Local changes, staged and not, must survive untouched. + fx.write("a.txt", "local\n"); + fx.write("staged.txt", "s\n"); + fx.git(&["add", "staged.txt"]); + let repo = fx.repo(); + + let change = repo.reword(&second, "second, reworded\n\nwith a body").unwrap(); + assert_eq!(fx.git(&["log", "--format=%s", "-3"]), "third\nsecond, reworded\nfirst"); + assert_eq!(fx.git(&["log", "-1", "--format=%B", "HEAD~1"]), "second, reworded\n\nwith a body"); + assert_eq!(fx.git(&["rev-parse", "HEAD~2"]), first, "commits before it keep their ids"); + assert_eq!(fx.git(&["rev-parse", "HEAD"]), change.after); + assert_eq!(fx.git(&["symbolic-ref", "--short", "HEAD"]), "main", "the branch moved, not a detached HEAD"); + assert_eq!(fx.read("a.txt"), "local\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "staged.txt"); + assert_eq!(fx.git(&["diff", "--name-only"]), "a.txt"); + + repo.restore_head(&change.before, &change.after).unwrap(); + assert_eq!(fx.git(&["log", "--format=%s", "-3"]), "third\nsecond\nfirst"); + assert_eq!(fx.read("a.txt"), "local\n"); + assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "staged.txt"); + + // Undo is refused once HEAD has moved. + let change = repo.reword("HEAD", "third, reworded").unwrap(); + fx.git(&["commit", "-q", "--allow-empty", "-m", "later"]); + assert!(repo.restore_head(&change.before, &change.after).is_err()); +} + +#[test] +fn reword_refuses_commits_outside_the_current_branch() { + let fx = Fixture::new(); + fx.write("a.txt", "1\n"); + fx.commit_all("first"); + fx.git(&["checkout", "-q", "-b", "side"]); + fx.write("a.txt", "side\n"); + let side = fx.commit_all("side"); + fx.git(&["checkout", "-q", "main"]); + assert!(fx.repo().reword(&side, "other").is_err()); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3e4b0d47c..62e190609 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -280,6 +280,8 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_restore_branch, vcs::vcs_create_branch, vcs::vcs_undo_commit, + vcs::vcs_reword, + vcs::vcs_restore_head, vcs::vcs_rename_branch, vcs::vcs_merge, vcs::vcs_rebase, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index e28096e2a..6ed81767d 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -285,6 +285,33 @@ pub async fn vcs_undo_commit( ide_host::ops::vcs::vcs_undo_commit(&state.host, window.label(), repo, commit).await } +/// Edit Commit Message: rewrites the commit and its descendants without touching the +/// working tree. +#[tauri::command] +#[specta::specta] +pub async fn vcs_reword( + window: Window, + state: State<'_, AppState>, + repo: Option, + commit: String, + message: String, +) -> IdeResult { + ide_host::ops::vcs::vcs_reword(&state.host, window.label(), repo, commit, message).await +} + +/// Undo of a history rewrite (Edit Commit Message, Drop, Squash). +#[tauri::command] +#[specta::specta] +pub async fn vcs_restore_head( + window: Window, + state: State<'_, AppState>, + repo: Option, + before: String, + after: String, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_restore_head(&state.host, window.label(), repo, before, after).await +} + #[tauri::command] #[specta::specta] pub async fn vcs_delete_branch( diff --git a/src/bindings.ts b/src/bindings.ts index 9ede3d710..5255bfb30 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -530,6 +530,13 @@ export const commands = { * Returns the undone commit's message. */ vcsUndoCommit: (repo: string | null, commit: string) => typedError(__TAURI_INVOKE("vcs_undo_commit", { repo, commit })), + /** + * Edit Commit Message: rewrites the commit and its descendants without touching the + * working tree. + */ + vcsReword: (repo: string | null, commit: string, message: string) => typedError(__TAURI_INVOKE("vcs_reword", { repo, commit, message })), + /** Undo of a history rewrite (Edit Commit Message, Drop, Squash). */ + vcsRestoreHead: (repo: string | null, before: string, after: string) => typedError(__TAURI_INVOKE("vcs_restore_head", { repo, before, after })), vcsRenameBranch: (repo: string | null, from: string, to: string) => typedError(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to })), vcsMerge: (repo: string | null, branch: string) => typedError(__TAURI_INVOKE("vcs_merge", { repo, branch })), vcsRebase: (repo: string | null, onto: string) => typedError(__TAURI_INVOKE("vcs_rebase", { repo, onto })), @@ -2081,6 +2088,8 @@ export type CheckoutOptions = { export type CheckoutOutcome = { kind: "done" } | /** These local changes (repository-relative paths) would be overwritten. */ { kind: "localChanges"; files: string[] } | +/** These untracked files (repository-relative paths) would be overwritten. */ +{ kind: "untrackedFiles"; files: string[] } | /** Local branch `local` has commits that remote branch `remote` does not. */ { kind: "localBranchAhead"; local: string; remote: string } | /** @@ -2225,6 +2234,10 @@ export type CommitOptions = { * entries of the index change; afterwards their index entries match the new commit. */ partial?: PartialCommitFile[], + /** Files committed with their working-tree content, staged or not. */ + stage?: IpcPath[], + /** Staged files left out of the commit; they stay staged afterwards. */ + exclude?: IpcPath[], }; /** Version Control › Commit: commit checks and commit-message inspections. */ @@ -3910,8 +3923,8 @@ export type GitExecutableInfo = { export type GitOperation = /** Continue the merge / rebase / cherry-pick / revert in progress. */ { op: "continue" } | { op: "abort" } | { op: "skip" } | -/** Commits oldest first. */ -{ op: "cherryPick"; commits: string[] } | +/** Commits oldest first; `record_origin` adds "(cherry picked from commit …)". */ +{ op: "cherryPick"; commits: string[]; recordOrigin?: boolean } | /** Commits newest first. */ { op: "revert"; commits: string[] } | { op: "reset"; commit: string; mode: ResetMode } | /** Rebases `base..HEAD` (`base: None` = from the root) following `entries`, oldest first. */ @@ -4062,6 +4075,12 @@ export type GradleTask = { description: string | null, }; +/** HEAD before and after a history rewrite, for its Undo. */ +export type HeadChange = { + before: string, + after: string, +}; + export type HierarchyChoice = { id: string, makeAbstract: boolean, diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index e29f8dfc3..1329403f2 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -41,6 +41,7 @@ import { checkOf, filesOf, groupFileNodes, + groupOf, ignoreTargets, isPartiallyStaged, planCommit, @@ -51,9 +52,12 @@ import { import { applyStash, commitChanges, deleteFiles, dropStash, showUnstashDialog, stashChanges } from "./changeOps"; import { CommitOptionsPopup } from "./CommitOptions"; import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists"; -import { moveToChangelist } from "./changelistsUi"; +import { commitFiles, moveToChangelist } from "./changelistsUi"; +import { acceptSide } from "./conflictsUi"; +import { copyChangesAsPatch } from "./patches"; import { messageHistory, + pendingFixup, setAmend, setCommitDiffPreview, setDiffPreview, @@ -63,6 +67,7 @@ import { type CommitTab, } from "./commitStore"; import { pushAfterCommit } from "./commitUi"; +import { rebaseAfterFixup } from "./rebase"; import { stashTabOf, stashTabs, type ContentTab } from "./gitTabs"; import { diffInput, openDiff, withChain, workingDiffData } from "./diffInput"; import { DiffEditor } from "./DiffEditor"; @@ -243,6 +248,26 @@ export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuIte const toIgnore = ignoreTargets(nodes, repos); const conflicted = files.filter((f) => f.unstaged === "conflicted"); const items: MenuItem[] = [ + ...(conflicted.length + ? [ + { + label: "Accept Yours", + run: () => void Promise.all(conflicted.map((f) => acceptSide(f, "ours"))).then(() => refreshNow()), + }, + { + label: "Accept Theirs", + run: () => void Promise.all(conflicted.map((f) => acceptSide(f, "theirs"))).then(() => refreshNow()), + }, + { kind: "separator" as const }, + ] + : []), + { + label: "Commit File…", + icon: "commit", + disabled: !files.some((f) => groupOf(f) === "changes"), + run: () => commitFiles(paths), + }, + { kind: "separator" }, { kind: "action", action: "vcs.showDiff", arg: first?.path }, { label: "Jump to Source", @@ -297,6 +322,7 @@ export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuIte : []), { kind: "action", action: "vcs.shelve", arg: paths }, { kind: "action", action: "vcs.createPatch", arg: paths }, + { label: "Copy as Patch to Clipboard", run: () => void copyChangesAsPatch(paths) }, { kind: "separator" }, { label: "Show History", @@ -762,7 +788,7 @@ export interface CommitRunner { clearEmptyError: () => void; /** Whether the Commit buttons are enabled. */ canCommit: boolean; - run: (push: boolean) => Promise; + run: (push: boolean, rebaseFixup?: boolean) => Promise; } /** @@ -774,7 +800,7 @@ export function useCommitRunner(included: number, onCommitted?: () => void): Com const merging = useVcs((s) => s.branch?.operation === "merge"); const [emptyError, setEmptyError] = useState(false); const [busy, setBusy] = useState(false); - const run = async (push: boolean) => { + const run = async (push: boolean, rebaseFixup = false) => { if (busy) return; // IDEA keeps Commit enabled and reports an empty message at the field. if (!useCommit.getState().message.trim() && !useCommit.getState().amend) { @@ -784,10 +810,12 @@ export function useCommitRunner(included: number, onCommitted?: () => void): Com } setBusy(true); try { + const fixup = rebaseFixup ? pendingFixup(useCommit.getState().message, useCommit.getState().fixupTarget) : null; const id = await commitChanges(); if (id === undefined) return; onCommitted?.(); - if (push) void pushAfterCommit(); + if (fixup) void rebaseAfterFixup(fixup); + else if (push) void pushAfterCommit(); } finally { setBusy(false); } @@ -950,6 +978,8 @@ function CommitPanel({ included }: { included: number }) { const [optionsOpen, setOptionsOpen] = useState(false); const runner = useCommitRunner(included); const { canCommit } = runner; + // After Fixup… / Squash Into…, the second button commits and autosquashes the target. + const fixup = useCommit((s) => pendingFixup(s.message, s.fixupTarget)); return (
@@ -958,16 +988,29 @@ function CommitPanel({ included }: { included: number }) { - + {fixup ? ( + + ) : ( + + )} { expect(initialCheck(status[5]!)).toBe("none"); }); - it("plans staging so the index holds exactly the checked changes", () => { + it("plans which changes the commit takes and which staged ones it leaves out", () => { const checks = new Map([ ["/r/README.md", "none"], ["/r/notes.txt", "all"], ]); const plan = planCommit(status, checks); expect(plan.stage.sort()).toEqual(["/r/notes.txt", "/r/src/main/kotlin/App.kt"]); - expect(plan.unstage).toEqual(["/r/README.md"]); + expect(plan.exclude).toEqual(["/r/README.md"]); expect(plan.included.map((f) => f.relPath).sort()).toEqual([ "notes.txt", "src/main/kotlin/App.kt", diff --git a/src/features/vcs/__tests__/commitInclusion.test.ts b/src/features/vcs/__tests__/commitInclusion.test.ts index 9b84e9777..f55053d6c 100644 --- a/src/features/vcs/__tests__/commitInclusion.test.ts +++ b/src/features/vcs/__tests__/commitInclusion.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const unstage = vi.hoisted(() => vi.fn()); const stage = vi.hoisted(() => vi.fn()); +const commit = vi.hoisted(() => vi.fn()); vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); @@ -14,7 +15,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { ...real.commands, vcsUnstage: unstage.mockImplementation(() => ok(null)), vcsStage: stage.mockImplementation(() => ok(null)), - vcsCommit: () => ok("commit1"), + vcsCommit: commit.mockImplementation(() => ok("commit1")), }, }; }); @@ -39,6 +40,7 @@ describe("changes left out of a commit (#1539)", () => { localStorage.clear(); unstage.mockClear(); stage.mockClear(); + commit.mockClear(); useVcs.setState({ available: false, root: "/p", @@ -59,8 +61,9 @@ describe("changes left out of a commit (#1539)", () => { it("keeps the 15 excluded files excluded instead of checking them again", async () => { expect(await commitChanges()).toBe("commit1"); - // Only the 5 checked files were staged for the commit. - expect(stage.mock.calls[0]?.[0]).toEqual(files.slice(0, 5).map((f) => f.path)); + // Only the 5 checked files go into the commit; the index itself is not touched. + expect(commit.mock.calls[0]?.[2].stage).toEqual(files.slice(0, 5).map((f) => f.path)); + expect(stage).not.toHaveBeenCalled(); expect(unstage).not.toHaveBeenCalled(); const { checks, excludedHunks } = useCommit.getState(); @@ -70,4 +73,12 @@ describe("changes left out of a commit (#1539)", () => { // Block exclusions of files still in the tree are kept too. expect(excludedHunks.get(files[5]!.path)).toEqual(new Set(["h1"])); }); + + it("leaves staged files out of the commit without unstaging them", async () => { + const staged = { ...files[7]!, staged: "modified" as const, unstaged: null }; + useVcs.setState({ status: [...files.slice(0, 7), staged] }); + expect(await commitChanges()).toBe("commit1"); + expect(commit.mock.calls[0]?.[2].exclude).toEqual([staged.path]); + expect(unstage).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/vcs/__tests__/gitOps.test.ts b/src/features/vcs/__tests__/gitOps.test.ts index 8dd435daf..7d9b7b37f 100644 --- a/src/features/vcs/__tests__/gitOps.test.ts +++ b/src/features/vcs/__tests__/gitOps.test.ts @@ -84,9 +84,9 @@ describe("commit plan and rollback with renames", () => { expect(renamedFromPath(fs("a.ts"))).toBeNull(); }); - it("unstages both sides of an excluded rename so the deletion is not committed alone", () => { + it("leaves both sides of an excluded rename out so the deletion is not committed alone", () => { const plan = planCommit([renamed], new Map([[renamed.path, "none"]])); - expect(plan.unstage).toEqual(["/p/src/new.ts", "/p/src/old.ts"]); + expect(plan.exclude).toEqual(["/p/src/new.ts", "/p/src/old.ts"]); expect(plan.included).toEqual([]); }); diff --git a/src/features/vcs/__tests__/multiRepoRollback.test.ts b/src/features/vcs/__tests__/multiRepoRollback.test.ts new file mode 100644 index 000000000..ddfb93434 --- /dev/null +++ b/src/features/vcs/__tests__/multiRepoRollback.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + ask: vi.fn(), + merge: vi.fn(), + restore: vi.fn(), + heads: {} as Record, + ops: {} as Record, +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + const ok = (data: unknown) => Promise.resolve({ status: "ok" as const, data }); + const fail = (message: string) => Promise.resolve({ status: "error" as const, error: { kind: "failed", message } }); + return { + ...real, + isTauri: () => false, + call: async (p: Promise<{ status: string; data?: unknown; error?: unknown }>) => { + const r = await p; + if (r.status === "error") throw r.error; + return r.data; + }, + commands: { + ...real.commands, + vcsBranchInfo: (root: string) => + ok({ head: "main", headCommit: mocks.heads[root], operation: mocks.ops[root] ?? null }), + vcsMerge: (root: string, branch: string) => { + mocks.merge(root, branch); + if (root === "/b") return fail("merge refused"); + mocks.heads[root] = `${root}-merged`; + return ok(null); + }, + vcsRestoreHead: (root: string, before: string, after: string) => { + mocks.restore(root, before, after); + return ok(null); + }, + }, + }; +}); +vi.mock("@/workbench/messages", () => ({ ask: mocks.ask, showError: vi.fn() })); + +import { mergeInRepos } from "../branchOps"; +import { pendingFixup } from "../commitStore"; +import type { Commit } from "@/bindings"; + +describe("an operation over several repositories that fails part way (IDEA's rollback proposal)", () => { + beforeEach(() => { + mocks.ask.mockReset(); + mocks.merge.mockReset(); + mocks.restore.mockReset(); + mocks.heads = { "/a": "a1", "/b": "b1", "/c": "c1" }; + mocks.ops = {}; + }); + + it("offers to reset the repositories it already merged, and does so on Rollback", async () => { + mocks.ask.mockResolvedValue({ button: "rollback" }); + await mergeInRepos("feature", ["/a", "/b", "/c"]); + expect(mocks.merge.mock.calls.map((c) => c[0])).toEqual(["/a", "/b"]); + expect(mocks.ask).toHaveBeenCalledTimes(1); + expect(mocks.restore).toHaveBeenCalledWith("/a", "a1", "/a-merged"); + }); + + it("leaves them merged on Don't Rollback", async () => { + mocks.ask.mockResolvedValue({ button: "keep" }); + await mergeInRepos("feature", ["/a", "/b"]); + expect(mocks.restore).not.toHaveBeenCalled(); + }); + + it("doesn't ask when the failed repository stopped on conflicts", async () => { + mocks.ops = { "/b": "merge" }; + await mergeInRepos("feature", ["/a", "/b"]); + expect(mocks.ask).not.toHaveBeenCalled(); + }); +}); + +describe("Commit and Rebase after Fixup…", () => { + const target = { id: "c1", summary: "Add parser" } as Commit; + it("is offered while the message is the one prepared for the target", () => { + expect(pendingFixup("fixup! Add parser", target)).toBe(target); + expect(pendingFixup("squash! Add parser\n\nmore", target)).toBe(target); + expect(pendingFixup("Something else", target)).toBeNull(); + expect(pendingFixup("fixup! Add parser", null)).toBeNull(); + }); +}); diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index c0de39f38..808856a98 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -1,6 +1,6 @@ /** Branch operations (checkout, create, merge, rebase, update, delete) with recents and favorites. */ import { create } from "zustand"; -import type { Branch, CheckoutOptions } from "@/bindings"; +import type { Branch, BranchInfo, CheckoutOptions } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; import { notifications } from "@/workbench/contributions"; @@ -98,6 +98,16 @@ async function performCheckout( }); await openConflictsInstead(); return true; + case "untrackedFiles": { + const files = outcome.files.join("\n"); + notifications().notify({ + group: "vcs", + level: "error", + title: "Untracked files prevent checkout", + body: tx`The following untracked working tree files would be overwritten by checkout:\n${files}\nPlease move or remove them before you switch branches.`, + }); + return false; + } case "localChanges": { const mode = await askSmartCheckout(outcome.files); if (!mode) return false; @@ -344,25 +354,86 @@ function inRepos(done: number, total: number): string { return done === total ? `in ${total} repositories` : `in ${done} of ${total} repositories`; } +interface Rollback { + /** What rolling back does, for the question: "check out the previous branch again". */ + undo: string; + run: (root: string, result: T, before: BranchInfo) => Promise; +} + +const branchInfo = (root: string) => call(commands.vcsBranchInfo(root)).catch(() => null); + /** * Runs `step` in each repository in turn, stopping at the first one that fails (its * error is reported by `step`), then reports how many repositories it was done in. + * When it failed after succeeding in some repositories, offers to roll those back so + * the repositories don't diverge (not when the failed one stopped on conflicts). */ -async function eachRepo(roots: readonly string[], step: (root: string) => Promise, title: string) { - let done = 0; +async function eachRepo( + roots: readonly string[], + step: (root: string) => Promise, + title: string, + rollback?: Rollback, +) { + const done: { root: string; result: T; before: BranchInfo }[] = []; + let failed: string | null = null; for (const root of roots) { - if (!(await step(root))) break; - done++; + const before = await branchInfo(root); + const result = await step(root); + if (result === undefined || !before) { + failed = root; + break; + } + done.push({ root, result, before }); } await refreshNow(); - if (done) notifications().notify({ group: "vcs", level: "info", title: `${title} ${inRepos(done, roots.length)}` }); + if (failed && done.length && rollback && !(await branchInfo(failed))?.operation) { + const succeeded = done.map((d) => repoName(d.root)).join(", "); + const failedName = repoName(failed); + const r = await ask({ + title: "Rollback", + body: tx`The operation failed in ${failedName}, but succeeded in ${succeeded}. Roll those back (${rollback.undo}) so the repositories don't diverge?`, + icon: "warning", + buttons: [ + { id: "rollback", label: "Rollback", primary: true }, + { id: "keep", label: "Don't Rollback" }, + ], + }); + if (r.button === "rollback") { + for (const d of done.reverse()) { + const repo = repoName(d.root); + await runOp(tx`Rollback (${repo})`, () => rollback.run(d.root, d.result, d.before), { refresh: false }); + } + await refreshNow(); + return; + } + } + if (done.length) + notifications().notify({ group: "vcs", level: "info", title: `${title} ${inRepos(done.length, roots.length)}` }); +} + +/** HEAD as a checkout target: the branch, or the commit on a detached HEAD. */ +const headRef = (b: BranchInfo) => b.head ?? b.headCommit ?? "HEAD"; + +async function checkoutBack(root: string, before: BranchInfo) { + await call(commands.vcsCheckout(root, headRef(before), false, { mode: "smart", diverged: null })); +} + +/** Moves HEAD back to where it was before a merge or rebase, keeping local changes. */ +async function resetBack(root: string, before: BranchInfo) { + const after = (await branchInfo(root))?.headCommit; + if (before.headCommit && after) await call(commands.vcsRestoreHead(root, before.headCommit, after)); } /** Checks out `name` (local, or remote: its local namesake) in every repository of `roots`. */ export async function checkoutInRepos(name: string, roots: readonly string[]) { const label = useVcs.getState().branches.some((b) => b.remote && b.name === name) ? shortRemoteName(name) : name; const previous = useVcs.getState().branch?.head; - await eachRepo(roots, (root) => performCheckout(name, () => "", root, true), `Checked out ${label}`); + await eachRepo( + roots, + async (root) => ((await performCheckout(name, () => "", root, true)) ? true : undefined), + `Checked out ${label}`, + { undo: tx("check out the previous branch again"), run: (root, _, before) => checkoutBack(root, before) }, + ); rememberBranch(previous); rememberBranch(useVcs.getState().branch?.head); } @@ -385,15 +456,18 @@ export async function newBranchInRepos(roots: readonly string[], from?: string) if (r.checked) await saveAll(); await eachRepo( roots, - async (root) => - (await runOp( - tx`Create branch ${name}`, - () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), - { - refresh: false, - }, - )) !== undefined, + (root) => + runOp(tx`Create branch ${name}`, () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), { + refresh: false, + }).then((done) => (done === undefined ? undefined : true)), r.checked ? `Created and checked out ${name}` : `Created branch ${name}`, + { + undo: tx`delete branch ${name}`, + run: async (root, _, before) => { + if (r.checked) await checkoutBack(root, before); + await call(commands.vcsDeleteBranch(root, name)); + }, + }, ); } @@ -402,13 +476,14 @@ export async function mergeInRepos(branch: string, roots: readonly string[]) { await saveAll(); await eachRepo( roots, - async (root) => - (await runOp(tx`Merge ${branch} (${repoName(root)})`, () => call(commands.vcsMerge(root, branch)), { + (root) => + runOp(tx`Merge ${branch} (${repoName(root)})`, () => call(commands.vcsMerge(root, branch)), { refresh: false, errorActions: conflictActions, onError: openConflictsInstead, - })) !== undefined, + }).then((done) => (done === undefined ? undefined : true)), `Merged ${branch}`, + { undo: tx("reset to the commit before the merge"), run: (root, _, before) => resetBack(root, before) }, ); } @@ -417,13 +492,14 @@ export async function rebaseInRepos(branch: string, roots: readonly string[]) { await saveAll(); await eachRepo( roots, - async (root) => - (await runOp(tx`Rebase onto ${branch} (${repoName(root)})`, () => call(commands.vcsRebase(root, branch)), { + (root) => + runOp(tx`Rebase onto ${branch} (${repoName(root)})`, () => call(commands.vcsRebase(root, branch)), { refresh: false, errorActions: conflictActions, onError: openConflictsInstead, - })) !== undefined, + }).then((done) => (done === undefined ? undefined : true)), `Rebased onto ${branch}`, + { undo: tx("reset to the commit before the rebase"), run: (root, _, before) => resetBack(root, before) }, ); } @@ -438,11 +514,12 @@ export async function deleteInRepos(name: string, roots: readonly string[]) { if (!ok) return; await eachRepo( roots, - async (root) => - (await runOp(tx`Delete branch ${name} (${repoName(root)})`, () => call(commands.vcsDeleteBranch(root, name)), { + (root) => + runOp(tx`Delete branch ${name} (${repoName(root)})`, () => call(commands.vcsDeleteBranch(root, name)), { refresh: false, - })) !== undefined, + }), `Deleted branch ${name}`, + { undo: tx`restore branch ${name}`, run: (root, deleted) => call(commands.vcsRestoreBranch(root, deleted)) }, ); } diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index 6930959c9..53b6df13d 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -76,13 +76,21 @@ export async function commitChanges(): Promise { const id = await runOp( amend ? "Amend commit" : "Commit", async () => { - if (plan.unstage.length) await call(commands.vcsUnstage(plan.unstage)); + // The commit is made from a copy of the index, so changes left out keep their + // staged state and the other repositories' indexes stay as they are. const stage = [...plan.stage, ...partial.whole]; - if (stage.length) await call(commands.vcsStage(stage)); let id = ""; for (const repo of repos) { - const files = partial.files.filter((f) => (rootOf(f.path, roots) ?? null) === repo || repos.length === 1); - const options = { amend, signOff, author: author.trim() || null, runHooks, partial: files }; + const inRepo = (path: string) => repos.length === 1 || (rootOf(path, roots) ?? null) === repo; + const options = { + amend, + signOff, + author: author.trim() || null, + runHooks, + partial: partial.files.filter((f) => inRepo(f.path)), + stage: stage.filter(inRepo), + exclude: plan.exclude.filter(inRepo), + }; id = await call(commands.vcsCommit(repo, message.trim(), options)); } return id; diff --git a/src/features/vcs/changelistsUi.tsx b/src/features/vcs/changelistsUi.tsx index 28dbd22cd..2f4021233 100644 --- a/src/features/vcs/changelistsUi.tsx +++ b/src/features/vcs/changelistsUi.tsx @@ -15,9 +15,10 @@ import { useChangelists, validateListName, } from "./changelists"; +import { groupOf } from "./changes"; import { useCommit } from "./commitStore"; import { confirmDialog, openDialog } from "./dialogs"; -import { fileCount } from "./model"; +import { fileCount, stripSlash } from "./model"; import { useVcs } from "./store"; import styles from "./common.module.css"; import { tx } from "@/platform/i18n"; @@ -184,6 +185,15 @@ export function commitChangelist(id: string) { useCommit.setState((s) => ({ checks, tab: "changes", focusTick: s.focusTick + 1 })); } +/** Commit File…: include exactly these changes (absolute paths) and focus the message. */ +export function commitFiles(paths: readonly string[]) { + const chosen = new Set(paths.map(stripSlash)); + const checks = new Map(); + for (const f of useVcs.getState().status) + if (groupOf(f) === "changes") checks.set(f.path, chosen.has(stripSlash(f.path)) ? "all" : "none"); + useCommit.setState((s) => ({ checks, tab: "changes", focusTick: s.focusTick + 1 })); +} + /** Moves files (absolute paths) to a list. */ export function moveToChangelist(paths: readonly string[], id: string) { const rels = paths.map(relOf); @@ -196,43 +206,94 @@ export function showMoveToChangelist(paths: readonly string[]) { notifications().notify({ group: "vcs", level: "info", title: "Select changes to move" }); return; } - openDialog((close) => ); + const rels = paths.map(relOf); + openDialog((close) => ( + useChangelists.getState().state.files?.[relOf(p)] ?? useChangelists.getState().state.active, + )} + onClose={close} + onChoose={(id) => updateChangelists((s) => moveFiles(s, rels, id))} + /> + )); } -function MoveDialog({ paths, onClose }: { paths: readonly string[]; onClose: () => void }) { +/** + * Select Target Changelist (Undo Commit): an existing list, or a new one named + * `suggestedName`. Resolves the chosen list's id (created if new), or null. + */ +export function chooseTargetChangelist(suggestedName: string): Promise { + return new Promise((resolve) => { + let chosen: string | null = null; + openDialog((close) => ( + { + close(); + resolve(chosen); + }} + onChoose={(id) => { + chosen = id; + }} + /> + )); + }); +} + +function TargetListDialog({ + title, + hint, + exclude = [], + suggestedName = "", + onChoose, + onClose, +}: { + title: string; + hint?: string; + /** Lists the changes are all in already (not offered). */ + exclude?: readonly string[]; + suggestedName?: string; + onChoose: (id: string) => void; + onClose: () => void; +}) { const state = useChangelists((s) => s.state); - const currentLists = new Set(paths.map((p) => state.files?.[relOf(p)] ?? state.active)); + const [currentLists] = useState(() => new Set(exclude)); const others = state.lists.filter((l) => !(currentLists.size === 1 && currentLists.has(l.id))); const [mode, setMode] = useState<"existing" | "new">(others.length ? "existing" : "new"); - const [target, setTarget] = useState(others[0]?.id ?? state.active); - const [value, setValue] = useState({ name: "", comment: "", active: false }); + const [target, setTarget] = useState((others.find((l) => l.id === state.active) ?? others[0])?.id ?? state.active); + const [value, setValue] = useState({ name: suggestedName, comment: "", active: false }); const error = mode === "new" ? validateListName(state, value.name) : null; - const move = () => { + const choose = () => { if (error) return; - onClose(); - const rels = paths.map(relOf); if (mode === "new") { + let id = ""; updateChangelists((s) => { const r = addList(s, value.name, { comment: value.comment, makeActive: value.active }); - return moveFiles(r.state, rels, r.id); + id = r.id; + return r.state; }); + onChoose(id); } else { - updateChangelists((s) => moveFiles(s, rels, target)); + onChoose(target); } + onClose(); }; return (
-
{fileCount(paths.length)}
+ {hint &&
{hint}
}