дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
ba8d224a46
коміт
8c381a07c4
31 files changed
+1264
-143
No files matched your search
@@ -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
|
||||
});
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)) },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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": "已变基"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача