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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-06 12:13:59 +08:00
1 джерело ba8d224a46
коміт 8c381a07c4
31 files changed
+1264 -143

No files matched your search

+39 -2
Переглянути файл
@@ -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<String>,
#[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<PathBuf>, 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<PathBuf>,
commit: String,
message: String,
) -> IdeResult<ide_vcs::HeadChange> {
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<PathBuf>,
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
});
+46 -4
Переглянути файл
@@ -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<PartialCommitFile>,
/// Files committed with their working-tree content, staged or not.
#[serde(default)]
pub stage: Vec<ide_core::IpcPath>,
/// Staged files left out of the commit; they stay staged afterwards.
#[serde(default)]
pub exclude: Vec<ide_core::IpcPath>,
}
/// A file of a partial commit: which of its changes against HEAD to commit.
@@ -480,6 +486,10 @@ pub enum CheckoutOutcome {
LocalChanges {
files: Vec<String>,
},
/// These untracked files (repository-relative paths) would be overwritten.
UntrackedFiles {
files: Vec<String>,
},
/// 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<String> {
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<HeadChange> {
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<()> {
+13 -17
Переглянути файл
@@ -106,7 +106,7 @@ pub(crate) fn merge_args(branches: &[String], o: &MergeOptions) -> IdeResult<Vec
if branches.is_empty() {
return Err(IdeError::invalid("no branch to merge"));
}
let mut args: Vec<String> = vec!["merge".into(), "--autostash".into()];
let mut args: Vec<String> = 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<Vec
/// `git rebase` arguments for the Rebase dialog.
pub(crate) fn rebase_args(upstream: &str, o: &RebaseOptions) -> IdeResult<Vec<String>> {
let mut args: Vec<String> = vec!["rebase".into(), "--autostash".into()];
let mut args: Vec<String> = 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<Vec<St
/// `git pull` arguments for the Pull dialog: `remote` and its `branches` (none = the
/// upstream's configured merge branch).
pub(crate) fn pull_args(remote: &str, branches: &[String], rebase: bool, o: &MergeOptions) -> IdeResult<Vec<String>> {
let mut args: Vec<String> = vec!["pull".into(), "--progress".into(), "--autostash".into()];
let mut args: Vec<String> = 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());
}
+70 -12
Переглянути файл
@@ -57,7 +57,8 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult<MergeSides> {
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.
+68
Переглянути файл
@@ -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/<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)
}
+163 -17
Переглянути файл
@@ -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<TextEncoding>,
) -> 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<Vec<String>> {
let paths: Vec<PathBuf> = 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<DivergedLo
/// Files git refuses to overwrite with local changes, from its error message.
fn overwritten_files(stderr: &str) -> Option<Vec<String>> {
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<Vec<String>> {
listed_files(stderr, "untracked working tree files would be")
}
fn listed_files(stderr: &str, header: &str) -> Option<Vec<String>> {
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<String> = 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<String> {
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<()> {
+97
Переглянути файл
@@ -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());
}
+42
Переглянути файл
@@ -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");
}
+170
Переглянути файл
@@ -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<String> =
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());
}
+2
Переглянути файл
@@ -280,6 +280,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+27
Переглянути файл
@@ -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<PathBuf>,
commit: String,
message: String,
) -> IdeResult<ide_vcs::HeadChange> {
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<PathBuf>,
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(
+21 -2
Переглянути файл
@@ -530,6 +530,13 @@ export const commands = {
* Returns the undone commit's message.
*/
vcsUndoCommit: (repo: string | null, commit: string) => typedError<string, IdeError>(__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<HeadChange, IdeError>(__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<null, IdeError>(__TAURI_INVOKE("vcs_restore_head", { repo, before, after })),
vcsRenameBranch: (repo: string | null, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to })),
vcsMerge: (repo: string | null, branch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_merge", { repo, branch })),
vcsRebase: (repo: string | null, onto: string) => typedError<null, IdeError>(__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,
+57 -14
Переглянути файл
@@ -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<void>;
run: (push: boolean, rebaseFixup?: boolean) => Promise<void>;
}
/**
@@ -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 (
<div className={styles.commit}>
@@ -958,16 +988,29 @@ function CommitPanel({ included }: { included: number }) {
<Button variant="primary" disabled={!canCommit} onClick={() => void runner.run(false)} title="⌘↩">
<T>{commitLabel(amend, checkProblems.length > 0)}</T>
</Button>
<Button
className={styles.pushButton}
disabled={!canCommit || !!remoteBusy}
onClick={() => void runner.run(true)}
title="⌥⌘↩"
>
<span>
<T>{commitAndPushLabel(amend)}</T>
</span>
</Button>
{fixup ? (
<Button
className={styles.pushButton}
disabled={!canCommit}
onClick={() => void runner.run(false, true)}
title="Commit and Rebase"
>
<span>
<T>Commit and Rebase</T>
</span>
</Button>
) : (
<Button
className={styles.pushButton}
disabled={!canCommit || !!remoteBusy}
onClick={() => void runner.run(true)}
title="⌥⌘↩"
>
<span>
<T>{commitAndPushLabel(amend)}</T>
</span>
</Button>
)}
<span className={styles.grow} />
<IconButton
ref={optionsRef}
+2 -2
Переглянути файл
@@ -197,14 +197,14 @@ describe("commit inclusion", () => {
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<string, CheckState>([
["/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",
+14 -3
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
@@ -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();
});
});
+2 -2
Переглянути файл
@@ -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([]);
});
+83
Переглянути файл
@@ -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<string, string>,
ops: {} as Record<string, string | null>,
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
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();
});
});
+101 -24
Переглянути файл
@@ -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<T> {
/** What rolling back does, for the question: "check out the previous branch again". */
undo: string;
run: (root: string, result: T, before: BranchInfo) => Promise<unknown>;
}
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<boolean>, title: string) {
let done = 0;
async function eachRepo<T>(
roots: readonly string[],
step: (root: string) => Promise<T | undefined>,
title: string,
rollback?: Rollback<T>,
) {
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)) },
);
}
+12 -4
Переглянути файл
@@ -76,13 +76,21 @@ export async function commitChanges(): Promise<string | undefined> {
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;
+75 -14
Переглянути файл
@@ -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<string, "all" | "none">();
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) => <MoveDialog paths={paths} onClose={close} />);
const rels = paths.map(relOf);
openDialog((close) => (
<TargetListDialog
title="Move Changes to Another Changelist"
hint={fileCount(paths.length)}
exclude={paths.map(
(p) => 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<string | null> {
return new Promise((resolve) => {
let chosen: string | null = null;
openDialog((close) => (
<TargetListDialog
title="Select Target Changelist"
suggestedName={suggestedName}
onClose={() => {
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<FormValue>({ name: "", comment: "", active: false });
const [target, setTarget] = useState((others.find((l) => l.id === state.active) ?? others[0])?.id ?? state.active);
const [value, setValue] = useState<FormValue>({ 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 (
<Dialog
open
title="Move Changes to Another Changelist"
title={title}
onClose={onClose}
width={440}
buttons={[
{ label: "OK", role: "default", disabled: !!error, onClick: move },
{ label: "OK", role: "default", disabled: !!error, onClick: choose },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.form} role="radiogroup" aria-label={tx("Target changelist")}>
<div className={styles.hint}>{fileCount(paths.length)}</div>
{hint && <div className={styles.hint}>{hint}</div>}
<label className={styles.radioRow}>
<input
type="radio"
+8 -6
Переглянути файл
@@ -327,8 +327,10 @@ export function groupFileNodes(roots: readonly ChangeNode[], node: ChangeNode):
}
export interface CommitPlan {
/** Included files committed with their working-tree content. */
stage: string[];
unstage: string[];
/** Staged files left out of the commit; they stay staged. */
exclude: string[];
/** Files that end up in the commit. */
included: FileStatus[];
/** Absolute paths of included files committed with only some of their hunks. */
@@ -357,19 +359,19 @@ export function renamedFromPath(fs: FileStatus): string | null {
return root === null ? null : `${root}${fs.renamedFrom}`;
}
/** What to stage / unstage so the index holds exactly the included changes. */
/** What the commit takes beyond the index, and what staged changes it leaves out. */
export function planCommit(status: readonly FileStatus[], checks: ReadonlyMap<string, CheckState>): CommitPlan {
const plan: CommitPlan = { stage: [], unstage: [], included: [], partial: [] };
const plan: CommitPlan = { stage: [], exclude: [], included: [], partial: [] };
for (const fs of status) {
if (!groupOf(fs)) continue;
const c = checkOf(fs, checks);
const path = stripSlash(fs.path);
if (c === "none") {
if (fs.staged && fs.staged !== "conflicted") {
plan.unstage.push(path);
// A staged rename is a deletion + addition: leaving the deletion staged would commit it.
plan.exclude.push(path);
// A staged rename is a deletion + addition: leaving the deletion in would commit it.
const from = renamedFromPath(fs);
if (from) plan.unstage.push(from);
if (from) plan.exclude.push(from);
}
continue;
}
+12
Переглянути файл
@@ -1,6 +1,7 @@
/** Commit tool window state shared with actions (selection, message draft). */
import { create } from "zustand";
import { call, commands } from "@/platform/ipc";
import type { Commit } from "@/bindings";
import type { CheckState } from "./changes";
import type { CommitProblem } from "./commitChecks";
import { currentRepo, useVcs } from "./store";
@@ -37,6 +38,8 @@ interface CommitState {
selectedList: string | null;
/** Selected shelf tree key: shelf id, optionally followed by NUL and a file path. */
selectedShelf: string | null;
/** Commit a Fixup… / Squash Into… message was prepared for: offers Commit and Rebase. */
fixupTarget: Commit | null;
}
export type CommitTab = "changes" | "shelf" | "stashes";
@@ -60,8 +63,17 @@ export const useCommit = create<CommitState>(() => ({
tab: "changes",
selectedList: null,
selectedShelf: null,
fixupTarget: null,
}));
/** The Fixup… / Squash Into… target while the message still is the one prepared for it. */
export function pendingFixup(message: string, target: Commit | null): Commit | null {
if (!target) return null;
return message.startsWith(`fixup! ${target.summary}`) || message.startsWith(`squash! ${target.summary}`)
? target
: null;
}
function readBool(key: string, fallback: boolean): boolean {
try {
const v = localStorage.getItem(key);
+2 -1
Переглянути файл
@@ -28,7 +28,8 @@ export function openMergeEditor(path: string) {
void editorService().openInput(mergeInput(stripSlash(path)), { focus: true });
}
async function acceptSide(f: FileStatus, side: "ours" | "theirs") {
/** Accept Yours / Accept Theirs for a whole conflicted file. */
export async function acceptSide(f: FileStatus, side: "ours" | "theirs") {
const path = stripSlash(f.path);
await runOp(
side === "ours" ? tx`Accept yours for ${baseName(path)}` : tx`Accept theirs for ${baseName(path)}`,
+15 -1
Переглянути файл
@@ -15,6 +15,8 @@ import { diffInput, openDiff, type DiffData, type DiffSide } from "./diffInput";
import { openDialog, promptText } from "./dialogs";
import { KIND_COLOR, fileCount } from "./model";
import { runGitOperation, runOp } from "./ops";
import { loadLogDetails } from "./logDetails";
import { isProtectedBranch } from "./remoteUi";
import { reloadLog, useGitLog } from "./logStore";
import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoName, repoOf, useVcs } from "./store";
import styles from "./common.module.css";
@@ -238,8 +240,20 @@ export async function cherryPick(commits: Commit[]) {
const ordered = newestFirst(commits).reverse();
const label =
ordered.length === 1 ? `${short(ordered[0]!.id)} “${ordered[0]!.summary}”` : `${ordered.length} commits`;
// Commits already pushed to a protected branch get "(cherry picked from commit …)".
const remoteBranches = new Set(
useVcs
.getState()
.branches.filter((b) => b.remote)
.map((b) => b.name),
);
const fromProtected = async (c: Commit) =>
(await loadLogDetails(c).catch(() => null))?.containingBranches.some(
(b) => remoteBranches.has(b) && isProtectedBranch(b.slice(b.indexOf("/") + 1)),
) ?? false;
const recordOrigin = (await Promise.all(ordered.map(fromProtected))).every(Boolean);
await runGitOperation(
{ op: "cherryPick", commits: ordered.map((c) => c.id) },
{ op: "cherryPick", commits: ordered.map((c) => c.id), recordOrigin },
{
progress: "Cherry-picking",
success: tx`Cherry-picked ${label}`,
+4 -1
Переглянути файл
@@ -331,6 +331,8 @@ export interface GitOperationOptions {
failure: string;
/** Called with the failure message; returning true suppresses the error balloon. */
onError?: (message: string) => boolean | Promise<boolean>;
/** Links of the success balloon (e.g. Undo), worked out after the status refreshed. */
successActions?: () => Promise<{ label: string; run: () => void }[]>;
}
/**
@@ -378,7 +380,8 @@ export async function runGitOperation(
success = success();
}
logConsole("info", success);
notifications().notify({ group: "vcs", level: "info", key: "vcs.operation", title: success });
const actions = opts.successActions ? await opts.successActions() : undefined;
notifications().notify({ group: "vcs", level: "info", key: "vcs.operation", title: success, actions });
return true;
} catch (e) {
const cancelled = isCancelled(e);
+15
Переглянути файл
@@ -48,6 +48,21 @@ export function showCreatePatchForChanges(paths?: readonly string[]) {
openDialog((close) => <CreatePatchDialog origin={{ kind: "changes", files }} onClose={close} />);
}
/** Copy as Patch to Clipboard: the selected local changes, without a dialog. */
export async function copyChangesAsPatch(paths: readonly string[]) {
const files = defaultChangeFiles(paths);
if (!files.length) return;
await saveAll();
await runOp(
tx("Create patch"),
async () => {
const source: PatchSource = { from: "changes", paths: files.map((f) => stripSlash(f.path)), reverse: false };
await navigator.clipboard.writeText(await call(commands.vcsCreatePatch(currentRepoRoot(), source)));
},
{ success: "Patch copied to the clipboard", refresh: false },
);
}
export function showCreatePatchForCommits(commits: Commit[]) {
if (!commits.length) return;
openDialog((close) => <CreatePatchDialog origin={{ kind: "commits", commits }} onClose={close} />);
+80 -12
Переглянути файл
@@ -9,7 +9,9 @@ import { call, commands } from "@/platform/ipc";
import { Dialog, IconButton, Select, T } from "@/ui";
import { workflowSettings } from "@/platform/workflowSettings";
import { ask, showError } from "@/workbench/messages";
import { chooseTargetChangelist, moveToChangelist } from "./changelistsUi";
import { useCommit } from "./commitStore";
import { stripSlash } from "./model";
import { openCommit } from "./commitUi";
import { rememberSetting } from "./confirmations";
import { confirmDialog, openDialog } from "./dialogs";
@@ -30,6 +32,7 @@ import {
type RebaseRow,
} from "./rebasePlan";
import { currentRepo, refreshNow, useVcs } from "./store";
import { notifications } from "@/workbench/contributions";
import styles from "./common.module.css";
import { tx } from "@/platform/i18n";
@@ -61,12 +64,22 @@ export async function undoCommit(commit: Commit) {
});
if (!ok) return;
}
// The undone changes go to a change list the user picks (a new one is named after the commit).
const target = await chooseTargetChangelist(commit.summary);
if (target === null) return;
const files = await call(commands.vcsCommitFiles(repo, commit.id)).catch(() => []);
const undo = () => call(commands.vcsUndoCommit(repo, commit.id));
const message = await runOp(tx`Undo commit ${short(commit.id)}`, undo, {
success: tx`Undid commit ${short(commit.id)} “${commit.summary}”; its changes are staged`,
success: tx`Undid commit ${short(commit.id)} “${commit.summary}”`,
});
// IDEA offers the undone message for the next commit.
if (message !== undefined && !useCommit.getState().message.trim()) useCommit.setState({ message });
if (message === undefined) return;
const root = repo ?? useVcs.getState().root ?? "";
moveToChangelist(
files.flatMap((f) => [f.path, f.oldPath].filter((p): p is string => !!p).map((p) => `${stripSlash(root)}/${p}`)),
target,
);
// The undone commit's message becomes the next commit's.
useCommit.setState({ message });
}
// ---------------------------------------------------------------------------
@@ -116,8 +129,38 @@ async function rangeFrom(target: Commit, verb: string): Promise<(RebaseRange & {
}
}
async function runRebase(base: string | null, entries: RebaseEntry[], done: string, repo: string | null) {
async function headOf(repo: string | null): Promise<string | null> {
return (await call(commands.vcsBranchInfo(repo)).catch(() => null))?.headCommit ?? null;
}
/**
* The Undo link of a history rewrite's balloon (keyed `key`): back to `before` while HEAD
* is still `after`. Once undone, the balloon is replaced by one without the link.
*/
function undoLink(repo: string | null, before: string | null, after: string | null, undone: string, key: string) {
if (!before || !after || before === after) return [];
return [
{
label: "Undo",
run: () =>
void runOp(tx("Undo"), () => call(commands.vcsRestoreHead(repo, before, after))).then((done) => {
if (done === undefined) return;
notifications().notify({ group: "vcs", level: "info", key, title: undone });
}),
},
];
}
/** Rewrites the branch with `entries`; a finished rewrite can be undone from its balloon. */
async function runRebase(
base: string | null,
entries: RebaseEntry[],
done: string,
repo: string | null,
undone = tx("Rebase undone"),
) {
await saveAll();
const before = await headOf(repo);
await runGitOperation(
{ op: "interactiveRebase", base, entries },
{
@@ -125,6 +168,8 @@ async function runRebase(base: string | null, entries: RebaseEntry[], done: stri
success: () => (inProgress() === "rebase" ? "Rebase stopped for editing; continue the rebase when done" : done),
failure: tx("Rebase failed"),
onError: conflictsInstead,
successActions: async () =>
inProgress() === "rebase" ? [] : undoLink(repo, before, await headOf(repo), undone, "vcs.operation"),
},
repo,
);
@@ -136,12 +181,20 @@ export async function editCommitMessage(commit: Commit) {
if (!range) return;
const message = await promptMessage(`Edit Commit Message (${short(commit.id)})`, commit.message);
if (message === null || message.trim() === commit.message.trim()) return;
await runRebase(
range.base,
singleCommitPlan(range, commit.id, "reword", message),
`Commit message of ${short(commit.id)} changed`,
range.repo,
// Written without a rebase: the index and the working tree (local changes) stay as they are.
const change = await runOp(tx`Edit commit message of ${short(commit.id)}`, () =>
call(commands.vcsReword(range.repo, commit.id, message)),
);
if (!change) return;
await refreshNow();
const key = `vcs.reword:${change.after}`;
notifications().notify({
group: "vcs",
level: "info",
key,
title: tx`Commit message of ${short(commit.id)} changed`,
actions: undoLink(range.repo, change.before, change.after, tx("Commit message change undone"), key),
});
}
/** Drop Commit(s): removes commits from the current branch. */
@@ -163,7 +216,7 @@ export async function dropCommits(commits: Commit[]) {
action: ids.has(c.id) ? ("drop" as const) : ("pick" as const),
message: null,
}));
await runRebase(range.base, plan, `Dropped ${commits.length} commits`, range.repo);
await runRebase(range.base, plan, `Dropped ${commits.length} commits`, range.repo, tx("Drop undone"));
}
async function dropCommit(commit: Commit) {
@@ -181,6 +234,7 @@ async function dropCommit(commit: Commit) {
singleCommitPlan(range, commit.id, "drop"),
`Dropped commit ${short(commit.id)}`,
range.repo,
tx("Drop undone"),
);
}
@@ -225,7 +279,13 @@ export async function squashCommits(commits: Commit[]) {
oldestFirst.map((c) => c.message.trim()).join("\n\n"),
);
if (message === null || !message.trim()) return;
await runRebase(range.base, squashPlan(range, ids, message), `Squashed ${commits.length} commits`, range.repo);
await runRebase(
range.base,
squashPlan(range, ids, message),
`Squashed ${commits.length} commits`,
range.repo,
tx("Squash undone"),
);
}
/**
@@ -233,10 +293,18 @@ export async function squashCommits(commits: Commit[]) {
* for `target` in the commit interface; the next interactive rebase autosquashes it.
*/
export function prepareFixup(target: Commit, kind: "fixup" | "squash") {
useCommit.setState({ message: `${kind}! ${target.summary}` });
useCommit.setState({ message: `${kind}! ${target.summary}`, fixupTarget: target });
openCommit();
}
/** Commit and Rebase (after Fixup… / Squash Into…): autosquashes the new commit into `target`. */
export async function rebaseAfterFixup(target: Commit) {
useCommit.setState({ fixupTarget: null });
const range = await rangeFrom(target, "rebase");
if (!range) return;
await runRebase(range.base, toEntries(initialRows(range.commits)), tx("Rebased"), range.repo);
}
/** Interactively Rebase from Here…: IDEA's rebase dialog for `target..HEAD`. */
export async function interactivelyRebaseFrom(target: Commit) {
const range = await rangeFrom(target, "rebase");
+1 -1
Переглянути файл
@@ -6057,7 +6057,7 @@
"Create patch": "Crear parche",
"Apply patch ({source})": "Aplicar parche ({source})",
"Undo commit {id}": "Deshacer confirmación {id}",
"Undid commit {id} “{summary}”; its changes are staged": "Se deshizo la confirmación {id} “{summary}”; sus cambios están preparados (staged)",
"Undid commit {id} “{summary}”": "Se deshizo la confirmación {id} “{summary}”",
"Shelve changes “{trim}”": "Guardar cambios en el shelf “{trim}”",
"Unshelve “{name}”": "Restaurar “{name}” del shelf",
"Delete shelved changes “{name}”": "Eliminar cambios del shelf “{name}”",
+1 -1
Переглянути файл
@@ -5967,7 +5967,7 @@
"Create patch": "Utwórz łatkę (patch)",
"Apply patch ({source})": "Zastosuj łatkę ({source})",
"Undo commit {id}": "Cofnij zatwierdzenie {id}",
"Undid commit {id} “{summary}”; its changes are staged": "Cofnięto zatwierdzenie {id} “{summary}”; zmiany znajdują się w poczekalni (staged)",
"Undid commit {id} “{summary}”": "Cofnięto zatwierdzenie {id} “{summary}”",
"Shelve changes “{trim}”": "Odłóż zmiany na półkę (shelve) “{trim}”",
"Unshelve “{name}”": "Przywróć “{name}” z półki (unshelve)",
"Delete shelved changes “{name}”": "Usuń odłożone zmiany z półki “{name}”",
+1 -1
Переглянути файл
@@ -5981,7 +5981,7 @@
"Create patch": "Criar patch",
"Apply patch ({source})": "Aplicar patch ({source})",
"Undo commit {id}": "Desfazer commit {id}",
"Undid commit {id} “{summary}”; its changes are staged": "Commit {id} “{summary}” desfeito; suas alterações estão preparadas",
"Undid commit {id} “{summary}”": "Commit {id} “{summary}” desfeito",
"Shelve changes “{trim}”": "Guardar alterações no shelf “{trim}”",
"Unshelve “{name}”": "Restaurar “{name}” do shelf",
"Delete shelved changes “{name}”": "Excluir alterações do shelf “{name}”",
+21 -2
Переглянути файл
@@ -6153,7 +6153,7 @@
"Create patch": "创建补丁",
"Apply patch ({source})": "应用补丁({source})",
"Undo commit {id}": "撤销提交 {id}",
"Undid commit {id} “{summary}”; its changes are staged": "已撤销提交 {id}“{summary}”,其更改已暂存",
"Undid commit {id} “{summary}”": "已撤销提交 {id}“{summary}”",
"Shelve changes “{trim}”": "搁置更改“{trim}”",
"Unshelve “{name}”": "取消搁置“{name}”",
"Delete shelved changes “{name}”": "删除搁置的更改“{name}”",
@@ -6333,5 +6333,24 @@
"Choose Mapping Target": "选择映射目标",
"Plugin navigation indexing incomplete": "插件导航索引未完成",
"Choose Declaration": "选择声明",
"Go to mapping: {target}": "跳转到映射:{target}"
"Go to mapping: {target}": "跳转到映射:{target}",
"Untracked files prevent checkout": "未跟踪的文件阻止了签出",
"The following untracked working tree files would be overwritten by checkout:\n{files}\nPlease move or remove them before you switch branches.": "签出将覆盖以下未跟踪的工作区文件:\n{files}\n请在切换分支前移动或删除它们。",
"Select Target Changelist": "选择目标更改列表",
"Rebase undone": "已撤销变基",
"Edit commit message of {id}": "编辑 {id} 的提交消息",
"Commit message of {id} changed": "已更改 {id} 的提交消息",
"Commit message change undone": "已撤销提交消息的更改",
"Drop undone": "已撤销丢弃",
"Squash undone": "已撤销压缩",
"Commit and Rebase": "提交并变基",
"The operation failed in {failedName}, but succeeded in {succeeded}. Roll those back ({undo}) so the repositories don't diverge?": "操作在 {failedName} 中失败,但在 {succeeded} 中已成功。是否回滚这些仓库({undo}),以免各仓库分叉?",
"Don't Rollback": "不回滚",
"Rollback ({repo})": "回滚({repo})",
"check out the previous branch again": "重新签出之前的分支",
"delete branch {name}": "删除分支 {name}",
"reset to the commit before the merge": "重置到合并前的提交",
"reset to the commit before the rebase": "重置到变基前的提交",
"restore branch {name}": "恢复分支 {name}",
"Rebased": "已变基"
}