diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index d076c717d..29f4d58a9 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -103,12 +103,20 @@ pub enum RemoteOp { Pull { rebase: bool, }, + /// Update Project › Reset to the Remote Branch. + ResetToUpstream, Push { force_with_lease: bool, set_upstream: bool, /// Push dialog › Push tags. #[serde(default)] tags: Option, + /// Plain `--force` (Force push anyway). + #[serde(default)] + force: bool, + /// Push dialog › Run Git hooks unchecked (`--no-verify`). + #[serde(default)] + no_verify: bool, }, /// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`). DeleteBranch { @@ -125,6 +133,23 @@ pub enum RemoteOp { target: Option, #[serde(default)] remote: Option, + #[serde(default)] + force: bool, + /// Record the target as upstream (Push dialog › Set upstream). + #[serde(default)] + set_upstream: bool, + #[serde(default)] + no_verify: bool, + }, + /// Push Tag › Push to `remote`. + PushTag { + tag: String, + remote: String, + }, + /// Deletes tag `tag` on `remote` (Delete on Remote). + DeleteRemoteTag { + tag: String, + remote: String, }, /// Fast-forwards local `branch` (not checked out) to its upstream. UpdateBranch { @@ -155,6 +180,8 @@ pub struct GitExecutableInfo { pub enum GitOperation { /// Continue the merge / rebase / cherry-pick / revert in progress. Continue, + /// Stage and Retry: stage the changes of tracked files, then continue. + StageAndContinue, Abort, Skip, /// Commits oldest first; `record_origin` adds "(cherry picked from commit …)". @@ -613,6 +640,19 @@ pub async fn vcs_delete_branch( } /// "Restore" of the branch-deleted notification. +/// Deletes a local tag; the result restores it. +pub async fn vcs_delete_tag(host: &Host, window: &str, repo: Option, name: String) -> IdeResult { + host.require_trust(window)?; + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_tag(&name))).await +} + +pub async fn vcs_restore_tag(host: &Host, window: &str, repo: Option, deleted: DeletedTag) -> IdeResult<()> { + host.require_trust(window)?; + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_tag(&deleted))).await +} + pub async fn vcs_restore_branch( host: &Host, window: &str, @@ -708,26 +748,42 @@ pub async fn vcs_remote( let out = |line: String| { let _ = on_output.send(line); }; - let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull"); + let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. } | RemoteOp::ResetToUpstream) + .then_some("Git pull"); project.with_repo_recorded(repo.as_deref(), recorded, |r| match op { RemoteOp::Fetch => r.fetch(&cancel, &out), RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out), - RemoteOp::Push { force_with_lease, set_upstream, tags } => { - r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out) + RemoteOp::ResetToUpstream => r.reset_to_upstream(&cancel, &out), + RemoteOp::Push { force_with_lease, set_upstream, tags, force, no_verify } => { + r.push_with(PushOptions { force_with_lease, force, set_upstream, tags, no_verify }, &cancel, &out) } RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out), - RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to( + RemoteOp::PushBranch { + branch, + revision, + force_with_lease, + target, + remote, + force, + set_upstream, + no_verify, + } => r.push_branch_to( &branch, PushBranchOptions { revision: revision.as_deref(), target: target.as_deref(), remote: remote.as_deref(), force_with_lease, + force, + set_upstream, + no_verify, }, &cancel, &out, ), RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out), + RemoteOp::PushTag { tag, remote } => r.push_tag(&tag, &remote, &cancel, &out), + RemoteOp::DeleteRemoteTag { tag, remote } => r.delete_remote_tag(&tag, &remote, &cancel, &out), RemoteOp::PullFrom { remote, branches, rebase, options } => { r.pull_from(&remote, &branches, rebase, &options, &cancel, &out) } @@ -863,6 +919,7 @@ pub async fn vcs_operation( }; project.with_repo_recorded(repo.as_deref(), recorded, |r| match op { GitOperation::Continue => r.continue_operation(&cancel, &out), + GitOperation::StageAndContinue => r.stage_and_continue(&cancel, &out), GitOperation::Abort => r.abort_operation(&cancel, &out), GitOperation::Skip => r.skip_operation(&cancel, &out), GitOperation::CherryPick { commits, record_origin } => { @@ -1051,6 +1108,12 @@ pub async fn vcs_diff_numstat( blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await } +/// Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks). +pub async fn vcs_has_hook(host: &Host, window: &str, repo: Option, name: String) -> IdeResult { + let project = host.project(window)?; + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.has_hook(&name))).await +} + /// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull /// Request tab's three-dot diff. pub async fn vcs_merge_base( @@ -1287,6 +1350,13 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_delete_branch", |c| async move { vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await }); + d.add( + "vcs_delete_tag", + |c| async move { vcs_delete_tag(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await }, + ); + d.add("vcs_restore_tag", |c| async move { + vcs_restore_tag(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await + }); d.add("vcs_restore_branch", |c| async move { vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await }); @@ -1410,6 +1480,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_diff_numstat", |c| async move { vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await }); + d.add("vcs_has_hook", |c| async move { vcs_has_hook(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await }); d.add("vcs_merge_base", |c| async move { vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await }); diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 5b8749cab..8ab29e669 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -341,6 +341,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "vcs_diff_revisions" | "vcs_diff_numstat" | "vcs_merge_base" + | "vcs_has_hook" | "vcs_shelves" | "vcs_shelf_patch" | "vcs_changelists_load" diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs index 14873f7b3..593dcbc88 100644 --- a/crates/ide-vcs/src/history.rs +++ b/crates/ide-vcs/src/history.rs @@ -93,6 +93,131 @@ fn git_select( Ok(ids) } +/// The commits of a file's history and the path it had in each. The commits that changed +/// the path, merges included (`--full-history --simplify-merges`); where it appears, a +/// rename from any parent continues the history under the old path from that parent. +/// Unlike `--follow`, this sees renames done in merges and history merged in from other +/// branches. +fn file_history( + r: &Repo, + scopes: &[String], + rel: &str, + first_parent: bool, + cancel: &CancellationToken, +) -> IdeResult<(HashSet, HashMap)> { + let mut ids = HashSet::new(); + let mut paths = HashMap::new(); + let mut starts: std::collections::VecDeque<(Vec, String)> = + scopes.iter().map(|s| (s.split_whitespace().map(str::to_string).collect(), rel.to_string())).collect(); + let mut started = HashSet::new(); + while let Some((revs, path)) = starts.pop_front() { + if !started.insert((revs.clone(), path.clone())) { + continue; + } + let mut args: Vec = + ["log", "--format=%x00%H", "--name-status", "-z", "--full-history", "--simplify-merges", "--no-follow"] + .map(String::from) + .into(); + if first_parent { + args.push("--first-parent".into()); + } + args.push("--end-of-options".into()); + args.extend(revs); + let pathspec = ["--".to_string(), format!(":(literal){path}")]; + let run = |args: &[String]| -> IdeResult)>> { + let argv: Vec<&str> = args.iter().chain(&pathspec).map(String::as_str).collect(); + Ok(parse_name_status_log(&crate::cli::run_cancellable(r.root(), &argv, cancel)?.stdout_str())) + }; + let records = run(&args)?; + // Where the file appears its history ends: what is behind that is another file of + // the same name (a rename continues the history below). The commits of the history + // are those not behind a creation; the statuses come from the full listing (with + // its parents excluded a merge shows no changes). + let created: Vec = records.iter().filter(|(_, s)| *s == Some('A')).map(|(id, _)| *id).collect(); + let kept: Option> = if created.is_empty() { + None + } else { + args.extend(created.iter().map(|id| format!("^{id}^@"))); + Some(run(&args)?.into_iter().map(|(id, _)| id).collect()) + }; + let mut created = Vec::new(); + for (id, first_status) in records { + if kept.as_ref().is_some_and(|k| !k.contains(&id)) || !ids.insert(id) { + continue; + } + paths.insert(id, path.clone()); + if first_status == Some('A') { + created.push(id); + } + } + for commit in created { + let line = crate::cli::run_cancellable( + r.root(), + &["rev-list", "--parents", "-n", "1", "--end-of-options", &commit.to_string()], + cancel, + )? + .stdout_str(); + let parents: Vec<&str> = + line.split_whitespace().skip(1).take(if first_parent { 1 } else { usize::MAX }).collect(); + for parent in parents { + if let Some(old) = renamed_from(r, parent, &commit.to_string(), &path, cancel)? { + starts.push_back((vec![parent.to_string()], old)); + } + } + } + } + Ok((ids, paths)) +} + +/// `git log --format=%x00%H --name-status -z`: each commit with the status letter of its +/// first change (none for a merge listed without changes). +fn parse_name_status_log(text: &str) -> Vec<(ObjectId, Option)> { + let mut out: Vec<(ObjectId, Option)> = Vec::new(); + let mut tokens = text.split('\0'); + while let Some(token) = tokens.next() { + let token = token.trim_start_matches('\n'); + if let Ok(id) = ObjectId::from_hex(token.as_bytes()) { + out.push((id, None)); + continue; + } + let Some(status) = token.chars().next().filter(|c| c.is_ascii_uppercase()) else { continue }; + if !token[1..].bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let paths = if matches!(status, 'R' | 'C') { 2 } else { 1 }; + for _ in 0..paths { + tokens.next(); + } + if let Some(last) = out.last_mut() { + last.1.get_or_insert(status); + } + } + out +} + +/// The path `path` had in `parent` when `commit` renamed it. +fn renamed_from( + r: &Repo, + parent: &str, + commit: &str, + path: &str, + cancel: &CancellationToken, +) -> IdeResult> { + let args = ["diff", "-M", "--name-status", "-z", "--diff-filter=R", "--no-ext-diff", parent, commit, "--"]; + let text = crate::cli::run_cancellable(r.root(), &args, cancel)?.stdout_str(); + let mut tokens = text.split('\0'); + while let Some(status) = tokens.next() { + if !status.starts_with('R') { + continue; + } + let (Some(old), Some(new)) = (tokens.next(), tokens.next()) else { break }; + if new == path { + return Ok(Some(old.to_string())); + } + } + Ok(None) +} + pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResult> { if cancel.is_cancelled() { return Err(IdeError::cancelled()); @@ -230,45 +355,8 @@ fn log_indexed( let path_ids = if paths.is_empty() { None } else if q.follow && paths.len() == 1 { - let mut ids = HashSet::new(); - let rel = &paths[0]; - let mut args = vec![ - "log".to_string(), - "--format=%x00%H".into(), - "--name-status".into(), - "-z".into(), - "--follow".into(), - "--topo-order".into(), - ]; - if q.first_parent { - args.push("--first-parent".into()); - } - args.push("--end-of-options".into()); - for scope in &scopes { - let mut args = args.clone(); - args.extend(scope.split_whitespace().map(str::to_string)); - args.extend(["--".into(), rel.clone()]); - let output = - crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::>(), cancel)?; - let text = output.stdout_str(); - let mut tokens = text.split('\0').peekable(); - let mut current_path = rel.clone(); - while let Some(token) = tokens.next() { - let token = token.trim_start_matches('\n'); - if let Ok(id) = ObjectId::from_hex(token.as_bytes()) { - ids.insert(id); - history_paths.insert(id, current_path.clone()); - } else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) { - if let (Some(old), Some(new)) = (tokens.next(), tokens.next()) - && new == current_path - { - current_path = old.to_string(); - } - } else if ["A", "D", "M", "T", "U"].contains(&token) { - tokens.next(); - } - } - } + let (ids, at) = file_history(r, &scopes, &paths[0], q.first_parent, cancel)?; + history_paths = at; Some(ids) } else { Some(git_select(r, &scopes, &[], &paths, q.first_parent, cancel)?) diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index f5ef2206d..8595d72d8 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -362,6 +362,27 @@ pub struct PushBranchOptions<'a> { pub remote: Option<&'a str>, /// `git push --force-with-lease`. pub force_with_lease: bool, + /// `git push --force` (Force push anyway after a rejected force-with-lease push). + pub force: bool, + /// Record the target as the branch's upstream even when it already has one. + pub set_upstream: bool, + /// Skip the pre-push hook (`--no-verify`). + pub no_verify: bool, +} + +/// Options of [`Repo::push_with`]: the Push dialog's choices for the current branch. +#[derive(Clone, Copy, Debug, Default)] +pub struct PushOptions { + /// `git push --force-with-lease`. + pub force_with_lease: bool, + /// `git push --force` (Force push anyway after a rejected force-with-lease push). + pub force: bool, + /// Push to the branch's remote under the same name and record it as upstream. + pub set_upstream: bool, + /// Push tags too. + pub tags: Option, + /// Skip the pre-push hook (`--no-verify`). + pub no_verify: bool, } /// What an interactive rebase does with one commit. @@ -523,6 +544,15 @@ pub struct DeletedBranch { pub unmerged: bool, } +/// A deleted tag, enough to restore it (Restore in the tag-deleted notification). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DeletedTag { + pub name: String, + /// What the tag pointed at: its tag object (annotated tags) or commit. + pub target: String, +} + /// Who stands on each side of the conflicts of the operation in progress (merge editor /// titles, as IDEA labels them). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] @@ -594,6 +624,7 @@ mod manage; mod ops; mod patch; mod preserve; +pub use ops::UNSTAGED_CHANGES_PREVENT_REBASE; mod refs; mod remotes; mod repo; @@ -1169,8 +1200,7 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { - let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - write::push(self, force_with_lease, set_upstream, None, cancel, on_output) + self.push_with(PushOptions { force_with_lease, set_upstream, ..Default::default() }, cancel, on_output) } /// [`Repo::push`] that also pushes tags (Push dialog › Push tags). pub fn push_with_tags( @@ -1180,9 +1210,23 @@ impl Repo { tags: Option, cancel: &CancellationToken, on_output: &dyn Fn(String), + ) -> IdeResult<()> { + self.push_with(PushOptions { force_with_lease, set_upstream, tags, ..Default::default() }, cancel, on_output) + } + /// Pushes the current branch with the Push dialog's options. + pub fn push_with( + &self, + options: PushOptions, + cancel: &CancellationToken, + on_output: &dyn Fn(String), ) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - write::push(self, force_with_lease, set_upstream, tags, cancel, on_output) + write::push(self, options, cancel, on_output) + } + /// Whether the repository has an executable hook `name` (e.g. `pre-push`), wherever + /// `core.hooksPath` puts it: the Push dialog then offers to skip it. + pub fn has_hook(&self, name: &str) -> IdeResult { + write::has_hook(self, name) } /// Pushes a local branch, or its history up to `revision`, to its upstream (Git Log /// › Push…, Push All up to Here). @@ -1216,6 +1260,12 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::push_branch(self, branch, options, cancel, on_output) } + /// Update Project › Reset to the Remote Branch: the current branch is moved to its + /// freshly fetched tracked branch; local commits are dropped, local changes kept. + pub fn reset_to_upstream(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + write::reset_to_upstream(self, cancel, on_output) + } /// Fast-forwards a local branch that is not checked out to its upstream (Branches › /// Update). Fails when the branch has diverged. pub fn update_branch(&self, branch: &str, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { @@ -1228,7 +1278,12 @@ impl Repo { /// conflicts are unresolved. pub fn continue_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - ops::continue_op(self, cancel, on_output) + ops::continue_op(self, false, cancel, on_output) + } + /// Stage and Retry: stages the changes of tracked files, then continues. + pub fn stage_and_continue(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::continue_op(self, true, cancel, on_output) } pub fn abort_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); @@ -1293,6 +1348,38 @@ impl Repo { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::create_tag(self, name, commit, message, force) } + /// Deletes a local tag, returning what [`Repo::restore_tag`] needs to undo it. + pub fn delete_tag(&self, name: &str) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::delete_tag(self, name) + } + /// Recreates a deleted tag (annotated ones with their message). + pub fn restore_tag(&self, deleted: &DeletedTag) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::restore_tag(self, deleted) + } + /// Pushes one tag to `remote` (Push Tag › Push to …). + pub fn push_tag( + &self, + tag: &str, + remote: &str, + cancel: &CancellationToken, + on_output: &dyn Fn(String), + ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::push_tag(self, tag, remote, false, cancel, on_output) + } + /// Deletes a tag on `remote` (Delete on Remote, after deleting it locally). + pub fn delete_remote_tag( + &self, + tag: &str, + remote: &str, + cancel: &CancellationToken, + on_output: &dyn Fn(String), + ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + ops::push_tag(self, tag, remote, true, cancel, on_output) + } pub fn tags(&self) -> IdeResult> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::tags(self) diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index 844a95902..b8d58275c 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -5,6 +5,7 @@ use crate::cli::{self, run}; use crate::repo::{gx, resolve}; use crate::{ChangeKind, ChangedFile, FileLineStats, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode}; use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::collections::HashSet; /// The in-progress operation git can continue / abort / skip. fn sequencer(r: &Repo) -> IdeResult<&'static str> { @@ -114,13 +115,38 @@ fn check_commits(commits: &[String]) -> IdeResult<()> { Ok(()) } +/// Why a rebase can't continue: tracked files changed while it was stopped, not staged. +pub const UNSTAGED_CHANGES_PREVENT_REBASE: &str = + "There are unstaged changes in tracked files preventing rebase from continuing"; + +fn changed_paths(r: &Repo, args: &[&str]) -> IdeResult> { + let text = run(r.root(), args, None)?.stdout_str(); + Ok(text.split('\0').filter(|p| !p.is_empty()).map(str::to_string).collect()) +} + /// Concludes the operation in progress once every conflict is resolved (staged). -pub(crate) fn continue_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> { +/// `stage` first stages the changes of tracked files (Stage and Retry). Without it a +/// rebase stages only edits of files already staged (made while resolving them) and +/// otherwise fails with [`UNSTAGED_CHANGES_PREVENT_REBASE`]. +pub(crate) fn continue_op(r: &Repo, stage: bool, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> { let op = sequencer(r)?; let unresolved = unmerged_paths(r)?; if !unresolved.is_empty() { return Err(IdeError::invalid(format!("resolve all conflicts before continuing: {}", unresolved.join(", ")))); } + if stage { + run(r.root(), &["add", "-u"], None)?; + } else if op == "rebase" { + let unstaged = changed_paths(r, &["diff", "--name-only", "-z", "--no-ext-diff"])?; + if !unstaged.is_empty() { + let staged: HashSet = + changed_paths(r, &["diff", "--cached", "--name-only", "-z", "--no-ext-diff"])?.into_iter().collect(); + if !unstaged.iter().all(|p| staged.contains(p)) { + return Err(IdeError::invalid(UNSTAGED_CHANGES_PREVENT_REBASE)); + } + cli::run_paths(r.root(), &["add", "-u"], &unstaged)?; + } + } let args: &[&str] = match op { // `git merge --continue` commits with the prepared MERGE_MSG. "merge" => &["merge", "--continue"], @@ -328,7 +354,7 @@ pub(crate) fn interactive_rebase( let file = work.join(format!("msg-{i}")); std::fs::write(&file, m)?; todo.push_str(&format!( - "exec git commit --amend --allow-empty --no-verify --quiet --cleanup=strip -F {}\n", + "exec git commit --amend --allow-empty --no-verify --quiet --cleanup=whitespace -F {}\n", quote(&file) )); } @@ -383,6 +409,51 @@ pub(crate) fn create_tag(r: &Repo, name: &str, commit: &str, message: Option<&st run(r.root(), &args, message.map(str::as_bytes)).map(|_| ()) } +fn tag_ref(name: &str) -> IdeResult { + let name = name.trim(); + if name.is_empty() || name.starts_with('-') || name.contains("..") || name.contains(':') { + return Err(IdeError::invalid(format!("'{name}' is not a valid tag name"))); + } + Ok(format!("refs/tags/{name}")) +} + +pub(crate) fn delete_tag(r: &Repo, name: &str) -> IdeResult { + let tag = tag_ref(name)?; + let target = run(r.root(), &["rev-parse", "-q", "--verify", &tag], None) + .map_err(|_| IdeError::invalid(format!("Could not find tag {name}")))? + .stdout_str(); + let target = target.trim().to_string(); + run(r.root(), &["update-ref", "-d", &tag, &target], None)?; + Ok(crate::DeletedTag { name: name.trim().to_string(), target }) +} + +pub(crate) fn restore_tag(r: &Repo, deleted: &crate::DeletedTag) -> IdeResult<()> { + let tag = tag_ref(&deleted.name)?; + let target = deleted.target.trim(); + if target.is_empty() || target.starts_with('-') { + return Err(IdeError::invalid("invalid tag target")); + } + // Only recreates it: a tag made with that name meanwhile stays. + run(r.root(), &["update-ref", &tag, target, ""], None).map(|_| ()) +} + +pub(crate) fn push_tag( + r: &Repo, + name: &str, + remote: &str, + delete: bool, + cancel: &CancellationToken, + on_output: &dyn Fn(String), +) -> IdeResult<()> { + let tag = tag_ref(name)?; + let remote = remote.trim(); + if remote.is_empty() || remote.starts_with('-') { + return Err(IdeError::invalid(format!("invalid remote '{remote}'"))); + } + let spec = if delete { format!(":{tag}") } else { format!("{tag}:{tag}") }; + cli::run_streaming(r.root(), &["push", "--progress", remote, &spec], cancel, on_output) +} + /// Tag names, sorted. pub(crate) fn tags(r: &Repo) -> IdeResult> { let repo = r.gix()?; diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 1c6ab0d09..1e4549744 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -927,20 +927,16 @@ pub(crate) fn delete_remote_branch( pub(crate) fn push( r: &Repo, - force_with_lease: bool, - set_upstream: bool, - tags: Option, + o: crate::PushOptions, cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { let mut args: Vec = vec!["push".into(), "--progress".into()]; - if force_with_lease { - args.push("--force-with-lease".into()); - } - if tags == Some(crate::PushTags::CurrentBranch) { + args.extend(force_args(o.force, o.force_with_lease, o.no_verify)); + if o.tags == Some(crate::PushTags::CurrentBranch) { args.push("--follow-tags".into()); } - if set_upstream { + if o.set_upstream { let repo = r.gix()?; let head = repo.head_name().map_err(gx)?.ok_or_else(|| IdeError::invalid("cannot set upstream: HEAD is detached"))?; @@ -963,14 +959,49 @@ pub(crate) fn push( } let argv: Vec<&str> = args.iter().map(String::as_str).collect(); cli::run_streaming(r.root(), &argv, cancel, on_output)?; - if tags == Some(crate::PushTags::All) { + if o.tags == Some(crate::PushTags::All) { // `git push --tags` alone pushes only tags, so it runs after the branch push. let remote = push_remote(r)?; - cli::run_streaming(r.root(), &["push", "--progress", &remote, "--tags"], cancel, on_output)?; + let mut args = vec!["push", "--progress"]; + if o.no_verify { + args.push("--no-verify"); + } + args.extend([remote.as_str(), "--tags"]); + cli::run_streaming(r.root(), &args, cancel, on_output)?; } Ok(()) } +/// `--force` / `--force-with-lease` and `--no-verify` of a push. +fn force_args(force: bool, force_with_lease: bool, no_verify: bool) -> Vec { + let mut args = Vec::new(); + if force { + args.push("--force".into()); + } else if force_with_lease { + args.push("--force-with-lease".into()); + } + if no_verify { + args.push("--no-verify".into()); + } + args +} + +pub(crate) fn has_hook(r: &Repo, name: &str) -> IdeResult { + if name.is_empty() || name.contains(['/', '\\']) { + return Err(IdeError::invalid(format!("invalid hook name '{name}'"))); + } + let path = run(r.root(), &["rev-parse", "--git-path", &format!("hooks/{name}")], None)?.stdout_str(); + let path = r.root().join(path.trim()); + let Ok(meta) = std::fs::metadata(&path) else { return Ok(false) }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + Ok(meta.is_file() && meta.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + Ok(meta.is_file()) +} + /// Remote and branch name a local branch pulls from (`branch..remote` / `.merge`). fn branch_upstream(r: &Repo, branch: &str) -> Option<(String, String)> { let get = |key: &str| { @@ -1001,10 +1032,18 @@ pub(crate) fn push_branch( cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { - let PushBranchOptions { revision, target, remote: remote_override, force_with_lease } = opts; + let PushBranchOptions { + revision, + target, + remote: remote_override, + force_with_lease, + force, + set_upstream, + no_verify, + } = opts; check_local_branch(r, branch)?; let remote_override = remote_override.map(str::trim).filter(|s| !s.is_empty()); - let (remote, mut name, set_upstream) = match (remote_override, branch_upstream(r, branch)) { + let (remote, mut name, untracked) = match (remote_override, branch_upstream(r, branch)) { // An explicit remote keeps the upstream's branch name but pushes elsewhere. (Some(remote), Some((_, name))) => (check_rev(remote)?.to_string(), name, false), (Some(remote), None) => (check_rev(remote)?.to_string(), branch.to_string(), revision.is_none()), @@ -1025,10 +1064,8 @@ pub(crate) fn push_branch( None => format!("refs/heads/{branch}"), }; let mut args: Vec = vec!["push".into(), "--progress".into()]; - if force_with_lease { - args.push("--force-with-lease".into()); - } - if set_upstream { + args.extend(force_args(force, force_with_lease, no_verify)); + if untracked || (set_upstream && revision.is_none()) { args.push("--set-upstream".into()); } args.extend([remote, format!("{source}:refs/heads/{target}")]); @@ -1050,6 +1087,27 @@ pub(crate) fn update_branch( cli::run_streaming(r.root(), &["fetch", "--progress", &remote, &refspec], cancel, on_output) } +/// Update Project › Reset to the Remote Branch: fetches the current branch's tracked +/// branch and moves the branch to it, dropping local commits. Local changes are put +/// aside and brought back (an error with the conflicts left in place when they clash). +pub(crate) fn reset_to_upstream(r: &Repo, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let head = run(r.root(), &["symbolic-ref", "-q", "--short", "HEAD"], None) + .map_err(|_| IdeError::invalid("cannot reset: HEAD is detached"))? + .stdout_str(); + let head = head.trim(); + let no_tracked = + || IdeError::invalid(format!("no tracked branch configured for branch {head} or the branch doesn't exist")); + let (remote, name) = branch_upstream(r, head).ok_or_else(no_tracked)?; + cli::run_streaming(r.root(), &["fetch", "--progress", &remote], cancel, on_output)?; + let tracked = format!("refs/remotes/{remote}/{name}"); + if run(r.root(), &["rev-parse", "-q", "--verify", &format!("{tracked}^{{commit}}")], None).is_err() { + return Err(no_tracked()); + } + crate::preserve::around(r, "Local changes saved before reset", || { + run(r.root(), &["reset", "-q", "--keep", &tracked], None).map(|_| ()) + }) +} + /// The remote the current branch pushes to (`origin`, or the only remote, by default). /// The remote and ref HEAD's branch tracks when the ref has another name: a plain /// `git push` refuses that under the default `push.default=simple`. diff --git a/crates/ide-vcs/tests/git_workflows.rs b/crates/ide-vcs/tests/git_workflows.rs index be30861b0..4103350ec 100644 --- a/crates/ide-vcs/tests/git_workflows.rs +++ b/crates/ide-vcs/tests/git_workflows.rs @@ -306,6 +306,17 @@ fn interactive_rebase_reorder_squash_fixup_reword_drop() { assert_eq!(fx.git(&["show", "--format=", "--name-only", "HEAD"]), "f1.txt\nf2.txt\nf3.txt"); } +#[test] +fn interactive_rebase_reword_keeps_lines_starting_with_a_hash() { + let (fx, ids) = linear(); + let (cancel, out) = quiet(); + let message = "Subject\n\n#body starting with a hash"; + fx.repo() + .interactive_rebase(Some(&ids[2]), &[entry(&ids[3], RebaseAction::Reword, Some(message))], &cancel, &out) + .unwrap(); + assert_eq!(fx.git(&["log", "-1", "--format=%B"]), message); +} + #[test] fn interactive_rebase_squash_without_message_combines_messages() { let (fx, ids) = linear(); @@ -407,6 +418,89 @@ fn interactive_rebase_conflict_stops_then_continues_with_reword() { assert_eq!(fx.subjects(), vec!["one", "two first", "base"]); } +/// `main` and `feature` both change `c.txt`: rebasing feature onto main stops on it. +fn rebase_stopped_on_a_conflict() -> (Fixture, Repo) { + let fx = Fixture::new(); + fx.write("c.txt", "base\n"); + fx.write("other.txt", "other\n"); + fx.commit_all("base"); + fx.git(&["switch", "-q", "-c", "feature"]); + fx.write("c.txt", "feature\n"); + fx.commit_all("feature change"); + fx.git(&["switch", "-q", "main"]); + fx.write("c.txt", "main\n"); + fx.commit_all("main change"); + fx.git(&["switch", "-q", "feature"]); + let repo = fx.repo(); + assert!(repo.rebase("main").is_err()); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); + (fx, repo) +} + +#[test] +fn edits_of_a_resolved_file_go_with_it_when_the_rebase_continues() { + let (fx, repo) = rebase_stopped_on_a_conflict(); + fx.write("c.txt", "resolved\n"); + repo.stage(&[fx.path("c.txt")]).unwrap(); + fx.write("c.txt", "resolved\nmore\n"); + let (cancel, out) = quiet(); + repo.continue_operation(&cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.git(&["show", "HEAD:c.txt"]), "resolved\nmore"); +} + +#[test] +fn unstaged_changes_of_other_files_stop_the_rebase_until_staged() { + let (fx, repo) = rebase_stopped_on_a_conflict(); + fx.write("c.txt", "resolved\n"); + repo.stage(&[fx.path("c.txt")]).unwrap(); + fx.write("other.txt", "changed while stopped\n"); + let (cancel, out) = quiet(); + let err = repo.continue_operation(&cancel, &out).unwrap_err(); + assert_eq!(err.message, UNSTAGED_CHANGES_PREVENT_REBASE); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); + repo.stage_and_continue(&cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.git(&["show", "HEAD:other.txt"]), "changed while stopped"); +} + +#[test] +fn stage_and_retry_after_editing_a_commit() { + let (fx, ids) = linear(); + let repo = fx.repo(); + let (cancel, out) = quiet(); + let plan = [entry(&ids[2], RebaseAction::Edit, None), entry(&ids[3], RebaseAction::Pick, None)]; + repo.interactive_rebase(Some(&ids[1]), &plan, &cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase")); + fx.write("f1.txt", "edited\n"); + std::fs::remove_file(fx.path("f2.txt")).unwrap(); + assert!(repo.continue_operation(&cancel, &out).is_err()); + repo.stage_and_continue(&cancel, &out).unwrap(); + assert_eq!(repo.branch_info().unwrap().operation, None); + assert_eq!(fx.git(&["status", "--porcelain"]), ""); + assert!(!fx.exists("f2.txt")); +} + +#[test] +fn rebase_and_merge_onto_a_branch_named_like_a_tag_use_the_branch() { + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + fx.commit_all("base"); + fx.git(&["tag", "main"]); + fx.write("m.txt", "m\n"); + let tip = fx.commit_all("main moved on"); + fx.git(&["switch", "-q", "-c", "feature", "HEAD~1"]); + fx.write("f.txt", "f\n"); + fx.commit_all("feature"); + let repo = fx.repo(); + repo.rebase("refs/heads/main").unwrap(); + assert_eq!(fx.git(&["rev-parse", "HEAD~1"]), tip); + fx.git(&["reset", "-q", "--hard", "HEAD~1"]); + fx.git(&["switch", "-q", "-c", "other", "refs/heads/main~1"]); + repo.merge("refs/heads/main").unwrap(); + assert_eq!(fx.head(), tip, "fast-forwarded to the branch, not the tag"); +} + #[test] fn interactive_rebase_edit_stops_for_amend() { let (fx, ids) = linear(); @@ -524,6 +618,157 @@ fn update_merge_with_local_changes_to_the_same_file() { assert_eq!(fx.git(&["rev-list", "--count", "--merges", "HEAD"]), "1"); } +#[test] +fn update_with_rebase_keeps_a_file_added_then_deleted_added_to_the_index() { + let (fx, bare) = Fixture::with_origin(); + fx.push_from_other(&bare, "remote.txt", "r\n"); + fx.write("local.txt", "l\n"); + fx.commit_all("local"); + fx.write("ad.txt", "x\n"); + fx.git(&["add", "ad.txt"]); + std::fs::remove_file(fx.path("ad.txt")).unwrap(); + let (cancel, out) = quiet(); + fx.repo().pull(true, &cancel, &out).unwrap(); + assert!(fx.exists("remote.txt")); + assert!(fx.git(&["status", "--porcelain", "--", "ad.txt"]).starts_with('A'), "still added"); +} + +#[test] +fn reset_to_the_remote_branch_drops_local_commits_and_keeps_local_changes() { + let (fx, bare) = Fixture::with_origin(); + for i in 0..3 { + fx.push_from_other(&bare, "bro.txt", &format!("bro {i}\n")); + } + fx.write("local1.txt", "1\n"); + fx.commit_all("local commit"); + fx.write("untracked.txt", "u\n"); + fx.write("a.txt", "edited\n"); + fx.git(&["add", "a.txt"]); + let (cancel, out) = quiet(); + fx.repo().reset_to_upstream(&cancel, &out).unwrap(); + assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"])); + assert!(!fx.exists("local1.txt"), "files of local commits are gone"); + assert!(fx.exists("untracked.txt")); + assert_eq!(fx.git(&["status", "--porcelain", "--", "a.txt"]), "M a.txt", "staged changes stay staged"); + assert_eq!(fx.git(&["stash", "list"]), ""); +} + +#[test] +fn reset_to_the_remote_branch_without_new_remote_commits_drops_local_ones() { + let (fx, _bare) = Fixture::with_origin(); + let before = fx.head(); + fx.write("local1.txt", "1\n"); + fx.commit_all("local commit"); + let (cancel, out) = quiet(); + fx.repo().reset_to_upstream(&cancel, &out).unwrap(); + assert_eq!(fx.head(), before); +} + +#[test] +fn reset_to_the_remote_branch_leaves_conflicting_local_changes_to_resolve() { + let (fx, bare) = Fixture::with_origin(); + fx.push_from_other(&bare, "bro.txt", "bro\n"); + fx.write("bro.txt", "local content\n"); + fx.git(&["add", "bro.txt"]); + let (cancel, out) = quiet(); + assert!(fx.repo().reset_to_upstream(&cancel, &out).is_err()); + assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"])); + assert!(fx.read("bro.txt").contains("<<<<<<<"), "the clash is left to resolve"); + assert_eq!(fx.git(&["stash", "list"]).lines().count(), 1, "a copy stays in the stash"); +} + +#[test] +fn reset_to_the_remote_branch_needs_a_tracked_branch_that_exists() { + let (fx, bare) = Fixture::with_origin(); + let (cancel, out) = quiet(); + fx.git(&["branch", "--unset-upstream"]); + let err = fx.repo().reset_to_upstream(&cancel, &out).unwrap_err(); + assert!(err.message.contains("no tracked branch"), "{}", err.message); + + fx.git(&["push", "-q", "-u", "origin", "main:feature"]); + fx.git(&["switch", "-q", "-c", "feature", "--track", "origin/feature"]); + git_in(&bare, &["branch", "-D", "feature"]); + fx.git(&["fetch", "-q", "--prune"]); + let err = fx.repo().reset_to_upstream(&cancel, &out).unwrap_err(); + assert!(err.message.contains("no tracked branch"), "{}", err.message); +} + +#[cfg(unix)] +#[test] +fn push_can_skip_a_failing_pre_push_hook() { + use std::os::unix::fs::PermissionsExt; + let (fx, _bare) = Fixture::with_origin(); + let repo = fx.repo(); + assert!(!repo.has_hook("pre-push").unwrap()); + let hook = fx.path(".git/hooks/pre-push"); + std::fs::create_dir_all(hook.parent().unwrap()).unwrap(); + std::fs::write(&hook, "#!/bin/sh\necho refused >&2\nexit 1\n").unwrap(); + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(repo.has_hook("pre-push").unwrap()); + + fx.write("local.txt", "l\n"); + fx.commit_all("local"); + let (cancel, out) = quiet(); + assert!(repo.push(false, false, &cancel, &out).is_err(), "the hook refuses the push"); + let options = PushOptions { no_verify: true, ..Default::default() }; + repo.push_with(options, &cancel, &out).unwrap(); + assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"])); +} + +#[test] +fn force_push_without_lease_overwrites_a_remote_that_moved() { + let (fx, bare) = Fixture::with_origin(); + fx.write("local.txt", "l\n"); + fx.commit_all("local"); + fx.push_from_other(&bare, "remote.txt", "r\n"); + fx.git(&["fetch", "-q"]); + fx.push_from_other(&bare, "remote2.txt", "r\n"); + let repo = fx.repo(); + let (cancel, out) = quiet(); + let lease = PushOptions { force_with_lease: true, ..Default::default() }; + let err = repo.push_with(lease, &cancel, &out).unwrap_err(); + assert!(err.message.contains("stale info"), "{}", err.message); + repo.push_with(PushOptions { force: true, ..Default::default() }, &cancel, &out).unwrap(); + assert_eq!(git_in(&bare, &["rev-parse", "main"]), fx.head()); +} + +#[test] +fn push_to_another_branch_can_make_it_the_upstream() { + let (fx, _bare) = Fixture::with_origin(); + fx.write("local.txt", "l\n"); + fx.commit_all("local"); + let repo = fx.repo(); + let (cancel, out) = quiet(); + let to = |target, set_upstream| PushBranchOptions { target: Some(target), set_upstream, ..Default::default() }; + repo.push_branch_to("main", to("feature", false), &cancel, &out).unwrap(); + assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{u}"]), "origin/main", "upstream kept"); + repo.push_branch_to("main", to("feature-1", true), &cancel, &out).unwrap(); + assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{u}"]), "origin/feature-1"); +} + +#[test] +fn tags_are_pushed_one_at_a_time_deleted_restored_and_deleted_on_the_remote() { + let (fx, bare) = Fixture::with_origin(); + fx.git(&["tag", "-a", "v1", "-m", "release one"]); + fx.git(&["tag", "v2"]); + let repo = fx.repo(); + let (cancel, out) = quiet(); + repo.push_tag("v1", "origin", &cancel, &out).unwrap(); + assert_eq!(git_in(&bare, &["tag"]), "v1", "only the chosen tag goes"); + repo.push_tag("v1", "origin", &cancel, &out).unwrap(); + + let deleted = repo.delete_tag("v1").unwrap(); + assert_eq!(fx.git(&["tag"]), "v2"); + repo.restore_tag(&deleted).unwrap(); + assert_eq!(fx.git(&["cat-file", "-t", "v1"]), "tag", "annotated, as it was"); + assert!(fx.git(&["tag", "-n1", "v1"]).contains("release one")); + + repo.delete_tag("v1").unwrap(); + repo.delete_remote_tag("v1", "origin", &cancel, &out).unwrap(); + assert_eq!(git_in(&bare, &["tag"]), ""); + assert!(repo.delete_tag("missing").is_err()); +} + #[test] fn fetch_prunes_deleted_remote_branches() { let (fx, bare) = Fixture::with_origin(); diff --git a/crates/ide-vcs/tests/log_projection.rs b/crates/ide-vcs/tests/log_projection.rs index a10d7dd63..451613663 100644 --- a/crates/ide-vcs/tests/log_projection.rs +++ b/crates/ide-vcs/tests/log_projection.rs @@ -462,3 +462,153 @@ fn case_insensitive_text_folds_non_ascii() { assert_eq!(ids(LogQuery { text: Some("ärger привет".into()), ..Default::default() }), vec![hit.clone()]); assert!(ids(LogQuery { text: Some("ärger".into()), text_match_case: true, ..Default::default() }).is_empty()); } + +/// File history (Show History of a file) as (subject, path at that revision), newest first. +fn file_history(dir: &Path, path: &str) -> Vec<(String, String)> { + let repo = Repo::discover(dir).unwrap().unwrap(); + let log = repo.log(&LogQuery { path: Some(path.into()), follow: true, ..Default::default() }).unwrap(); + log.into_iter().map(|c| (c.summary, c.history_path.unwrap_or_default())).collect() +} + +fn history_repo() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_path_buf(); + git(&dir, &["init", "-q"]); + (tmp, dir) +} + +fn pairs(list: &[(&str, &str)]) -> Vec<(String, String)> { + list.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect() +} + +#[test] +fn file_history_goes_through_a_rename_on_a_merged_branch() { + let (_tmp, dir) = history_repo(); + commit(&dir, "file.txt", "Created"); + git(&dir, &["switch", "-qc", "feature"]); + git(&dir, &["mv", "file.txt", "renamed.txt"]); + git(&dir, &["commit", "-qm", "Renamed"]); + git(&dir, &["switch", "-q", "main"]); + commit(&dir, "other.txt", "Unrelated"); + git(&dir, &["merge", "-q", "--no-ff", "-m", "Merged", "feature"]); + commit(&dir, "renamed.txt", "Modified"); + let history = file_history(&dir, "renamed.txt"); + assert_eq!(history, pairs(&[("Modified", "renamed.txt"), ("Renamed", "renamed.txt"), ("Created", "file.txt")])); +} + +#[test] +fn file_history_goes_through_a_rename_done_in_a_merge_commit() { + let (_tmp, dir) = history_repo(); + commit(&dir, "file.txt", "Created"); + git(&dir, &["switch", "-qc", "feature"]); + commit(&dir, "side.txt", "Side"); + git(&dir, &["switch", "-q", "main"]); + git(&dir, &["merge", "-q", "--no-ff", "--no-commit", "feature"]); + git(&dir, &["mv", "file.txt", "renamed.txt"]); + git(&dir, &["commit", "-qm", "Merge with rename"]); + let history = file_history(&dir, "renamed.txt"); + assert_eq!(history, pairs(&[("Merge with rename", "renamed.txt"), ("Created", "file.txt")])); +} + +#[test] +fn file_history_shows_merges_that_change_the_file_but_not_trivial_ones() { + let (_tmp, dir) = history_repo(); + std::fs::write(dir.join("file.txt"), "one\ntwo\nthree\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-qm", "Created"]); + git(&dir, &["switch", "-qc", "feature"]); + std::fs::write(dir.join("file.txt"), "one\ntwo\nthree\nfeature\n").unwrap(); + git(&dir, &["commit", "-qam", "Feature"]); + git(&dir, &["switch", "-q", "main"]); + commit(&dir, "other.txt", "Other"); + git(&dir, &["merge", "-q", "--no-ff", "-m", "Trivial merge", "feature"]); + assert_eq!( + file_history(&dir, "file.txt").into_iter().map(|(s, _)| s).collect::>(), + vec!["Feature", "Created"] + ); + + git(&dir, &["switch", "-qc", "second", "HEAD~1"]); + std::fs::write(dir.join("file.txt"), "zero\none\ntwo\nthree\n").unwrap(); + git(&dir, &["commit", "-qam", "Second"]); + git(&dir, &["switch", "-q", "main"]); + git(&dir, &["merge", "-q", "--no-ff", "--no-commit", "second"]); + std::fs::write(dir.join("file.txt"), "zero\none\ntwo\nthree\nfeature\nresolved\n").unwrap(); + git(&dir, &["commit", "-qam", "Non-trivial merge"]); + let subjects: Vec = file_history(&dir, "file.txt").into_iter().map(|(s, _)| s).collect(); + assert_eq!(subjects[0], "Non-trivial merge"); + assert!(subjects.contains(&"Second".to_string()) && subjects.contains(&"Feature".to_string())); +} + +#[test] +fn file_history_through_a_monorepo_merge_keeps_each_side() { + let (_tmp, dir) = history_repo(); + commit(&dir, "file1.txt", "Created file1"); + git(&dir, &["switch", "-q", "--orphan", "repo2"]); + commit(&dir, "file2.txt", "Created file2"); + git(&dir, &["switch", "-q", "main"]); + git(&dir, &["merge", "-q", "--no-commit", "--allow-unrelated-histories", "repo2"]); + std::fs::create_dir_all(dir.join("repo1")).unwrap(); + std::fs::create_dir_all(dir.join("repo2")).unwrap(); + git(&dir, &["mv", "file1.txt", "repo1/file1.txt"]); + git(&dir, &["mv", "file2.txt", "repo2/file2.txt"]); + git(&dir, &["commit", "-qm", "monorepo merge"]); + assert_eq!( + file_history(&dir, "repo1/file1.txt"), + pairs(&[("monorepo merge", "repo1/file1.txt"), ("Created file1", "file1.txt")]) + ); + assert_eq!( + file_history(&dir, "repo2/file2.txt"), + pairs(&[("monorepo merge", "repo2/file2.txt"), ("Created file2", "file2.txt")]) + ); +} + +#[test] +fn file_history_through_parallel_renames_names_the_path_of_each_revision() { + let (_tmp, dir) = history_repo(); + std::fs::write(dir.join("file.txt"), "base\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-qm", "Created"]); + git(&dir, &["switch", "-qc", "other"]); + std::fs::write(dir.join("file.txt"), "base\nother\n").unwrap(); + git(&dir, &["commit", "-qam", "Modified on other"]); + git(&dir, &["mv", "file.txt", "renamed.txt"]); + git(&dir, &["commit", "-qm", "Renamed on other"]); + git(&dir, &["switch", "-q", "main"]); + std::fs::write(dir.join("file.txt"), "main\nbase\n").unwrap(); + git(&dir, &["commit", "-qam", "Modified on main"]); + git(&dir, &["mv", "file.txt", "renamed.txt"]); + git(&dir, &["commit", "-qm", "Renamed on main"]); + // Both sides changed the file: the merge stops for the resolution. + let _ = Command::new("git").current_dir(&dir).args(["merge", "-q", "--no-ff", "--no-commit", "other"]).output(); + std::fs::write(dir.join("renamed.txt"), "main\nbase\nother\nmerged\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-qm", "Merged other"]); + let mut history = file_history(&dir, "renamed.txt"); + history.sort(); + assert_eq!( + history, + pairs(&[ + ("Created", "file.txt"), + ("Merged other", "renamed.txt"), + ("Modified on main", "file.txt"), + ("Modified on other", "file.txt"), + ("Renamed on main", "renamed.txt"), + ("Renamed on other", "renamed.txt"), + ]) + ); +} + +#[test] +fn file_history_through_a_cyclic_rename() { + let (_tmp, dir) = history_repo(); + commit(&dir, "a.txt", "Created a"); + git(&dir, &["mv", "a.txt", "b.txt"]); + git(&dir, &["commit", "-qm", "a to b"]); + commit(&dir, "b.txt", "Modified b"); + git(&dir, &["mv", "b.txt", "a.txt"]); + git(&dir, &["commit", "-qm", "b to a"]); + assert_eq!( + file_history(&dir, "a.txt"), + pairs(&[("b to a", "a.txt"), ("Modified b", "b.txt"), ("a to b", "b.txt"), ("Created a", "a.txt")]) + ); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 62e190609..ccf96577e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -278,6 +278,8 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_checkout, vcs::vcs_delete_branch, vcs::vcs_restore_branch, + vcs::vcs_delete_tag, + vcs::vcs_restore_tag, vcs::vcs_create_branch, vcs::vcs_undo_commit, vcs::vcs_reword, @@ -313,6 +315,7 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_compare_paths, vcs::vcs_diff_revisions, vcs::vcs_diff_numstat, + vcs::vcs_has_hook, vcs::vcs_merge_base, vcs::vcs_create_patch, vcs::vcs_patch_preview, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 6ed81767d..29cc34b92 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -323,6 +323,30 @@ pub async fn vcs_delete_branch( ide_host::ops::vcs::vcs_delete_branch(&state.host, window.label(), repo, branch).await } +/// Tag › Delete: the result is what "Restore" needs. +#[tauri::command] +#[specta::specta] +pub async fn vcs_delete_tag( + window: Window, + state: State<'_, AppState>, + repo: Option, + name: String, +) -> IdeResult { + ide_host::ops::vcs::vcs_delete_tag(&state.host, window.label(), repo, name).await +} + +/// "Restore" of the tag-deleted notification. +#[tauri::command] +#[specta::specta] +pub async fn vcs_restore_tag( + window: Window, + state: State<'_, AppState>, + repo: Option, + deleted: ide_vcs::DeletedTag, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_restore_tag(&state.host, window.label(), repo, deleted).await +} + /// "Restore" of the branch-deleted notification. #[tauri::command] #[specta::specta] @@ -673,6 +697,18 @@ pub async fn vcs_diff_numstat( ide_host::ops::vcs::vcs_diff_numstat(&state.host, window.label(), repo, from, to, paths).await } +/// Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks). +#[tauri::command] +#[specta::specta] +pub async fn vcs_has_hook( + window: Window, + state: State<'_, AppState>, + repo: Option, + name: String, +) -> IdeResult { + ide_host::ops::vcs::vcs_has_hook(&state.host, window.label(), repo, name).await +} + /// The best common ancestor of `a` and `b` (`git merge-base`), if any. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index 5255bfb30..9082906df 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -523,6 +523,10 @@ export const commands = { vcsDeleteBranch: (repo: string | null, branch: string) => typedError(__TAURI_INVOKE("vcs_delete_branch", { repo, branch })), /** "Restore" of the branch-deleted notification. */ vcsRestoreBranch: (repo: string | null, deleted: DeletedBranch) => typedError(__TAURI_INVOKE("vcs_restore_branch", { repo, deleted })), + /** Tag › Delete: the result is what "Restore" needs. */ + vcsDeleteTag: (repo: string | null, name: string) => typedError(__TAURI_INVOKE("vcs_delete_tag", { repo, name })), + /** "Restore" of the tag-deleted notification. */ + vcsRestoreTag: (repo: string | null, deleted: DeletedTag) => typedError(__TAURI_INVOKE("vcs_restore_tag", { repo, deleted })), /** New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. */ vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean) => typedError(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout })), /** @@ -595,6 +599,8 @@ export const commands = { * `paths` limits the diff to those repository-relative paths; empty diffs every file. */ vcsDiffNumstat: (repo: string | null, from: string, to: string, paths: string[]) => typedError(__TAURI_INVOKE("vcs_diff_numstat", { repo, from, to, paths })), + /** Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks). */ + vcsHasHook: (repo: string | null, name: string) => typedError(__TAURI_INVOKE("vcs_has_hook", { repo, name })), /** The best common ancestor of `a` and `b` (`git merge-base`), if any. */ vcsMergeBase: (repo: string | null, a: string, b: string) => typedError(__TAURI_INVOKE("vcs_merge_base", { repo, a, b })), vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError(__TAURI_INVOKE("vcs_create_patch", { repo, source })), @@ -2934,6 +2940,13 @@ export type DeletedBranch = { unmerged: boolean, }; +/** A deleted tag, enough to restore it (Restore in the tag-deleted notification). */ +export type DeletedTag = { + name: string, + /** What the tag pointed at: its tag object (annotated tags) or commit. */ + target: string, +}; + export type DependencyUpdate = { latest: string, replacement: string, @@ -3922,7 +3935,9 @@ export type GitExecutableInfo = { export type GitOperation = /** Continue the merge / rebase / cherry-pick / revert in progress. */ -{ op: "continue" } | { op: "abort" } | { op: "skip" } | +{ op: "continue" } | +/** Stage and Retry: stage the changes of tracked files, then continue. */ +{ op: "stageAndContinue" } | { op: "abort" } | { op: "skip" } | /** Commits oldest first; `record_origin` adds "(cherry picked from commit …)". */ { op: "cherryPick"; commits: string[]; recordOrigin?: boolean } | /** Commits newest first. */ @@ -6236,9 +6251,15 @@ export type RemoteLaunch = { path: string, }; -export type RemoteOp = { op: "fetch" } | { op: "pull"; rebase: boolean } | { op: "push"; force_with_lease: boolean; set_upstream: boolean; +export type RemoteOp = { op: "fetch" } | { op: "pull"; rebase: boolean } | +/** Update Project › Reset to the Remote Branch. */ +{ op: "resetToUpstream" } | { op: "push"; force_with_lease: boolean; set_upstream: boolean; /** Push dialog › Push tags. */ -tags?: PushTags | null } | +tags?: PushTags | null; +/** Plain `--force` (Force push anyway). */ +force?: boolean; +/** Push dialog › Run Git hooks unchecked (`--no-verify`). */ +no_verify?: boolean } | /** Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`). */ { op: "deleteBranch"; branch: string } | /** @@ -6246,7 +6267,13 @@ tags?: PushTags | null } | * remote branch `target`; `remote` overrides the upstream's remote (Push dialog's * remote picker). */ -{ op: "pushBranch"; branch: string; revision: string | null; force_with_lease: boolean; target?: string | null; remote?: string | null } | +{ op: "pushBranch"; branch: string; revision: string | null; force_with_lease: boolean; target?: string | null; remote?: string | null; force?: boolean; +/** Record the target as upstream (Push dialog › Set upstream). */ +set_upstream?: boolean; no_verify?: boolean } | +/** Push Tag › Push to `remote`. */ +{ op: "pushTag"; tag: string; remote: string } | +/** Deletes tag `tag` on `remote` (Delete on Remote). */ +{ op: "deleteRemoteTag"; tag: string; remote: string } | /** Fast-forwards local `branch` (not checked out) to its upstream. */ { op: "updateBranch"; branch: string } | /** Pull dialog: `branches` of `remote` (none = the configured merge branch). */ diff --git a/src/features/vcs/__tests__/changelists.test.ts b/src/features/vcs/__tests__/changelists.test.ts index 9a46f76b6..b17872e96 100644 --- a/src/features/vcs/__tests__/changelists.test.ts +++ b/src/features/vcs/__tests__/changelists.test.ts @@ -68,6 +68,15 @@ describe("change list model", () => { expect(next.files).toEqual({ "src/b.ts": feature, "new.ts": feature }); }); + it("keeps the lists of files whose changes a merge, rebase or update put aside", () => { + const { state, feature } = twoLists(); + // The rebase stashed every change: nothing looks changed until they come back. + const aside = syncAssignments(state, [], true); + expect(aside?.files ?? state.files).toEqual(state.files); + const back = syncAssignments(aside ?? state, ["src/b.ts"]); + expect((back ?? aside ?? state).files).toEqual({ "src/b.ts": feature }); + }); + it("files without an assignment belong to the active list", () => { const { state, feature } = twoLists(); expect(listOf(state, "src/b.ts")).toBe(feature); diff --git a/src/features/vcs/__tests__/confirmations.test.ts b/src/features/vcs/__tests__/confirmations.test.ts index 181403923..ae25ea66e 100644 --- a/src/features/vcs/__tests__/confirmations.test.ts +++ b/src/features/vcs/__tests__/confirmations.test.ts @@ -16,7 +16,7 @@ vi.mock("@/platform/workflowSettings", () => ({ })); vi.mock("../store", async () => { const { create } = await import("zustand"); - return { useVcs: create(() => ({})), refreshNow: mocks.refresh }; + return { useVcs: create(() => ({})), refreshNow: mocks.refresh, beginGitWork: () => () => {} }; }); import { notifyFileOperation } from "@/platform/fileOps"; import { useProject } from "@/platform/project"; diff --git a/src/features/vcs/__tests__/pushRejected.test.ts b/src/features/vcs/__tests__/pushRejected.test.ts new file mode 100644 index 000000000..c60cd8826 --- /dev/null +++ b/src/features/vcs/__tests__/pushRejected.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RemoteOp } from "@/bindings"; + +const mocks = vi.hoisted(() => ({ + ask: vi.fn(), + ops: [] as Array<{ repo: string | null; op: RemoteOp }>, + /** Results of the coming pushes, in order: an error message, or null for success. */ + pushes: [] as Array, + settings: [] as Array<[string, string]>, +})); + +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + Channel: class { + onmessage: (line: string) => void = () => {}; + }, +})); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + const ok = (data: unknown) => Promise.resolve({ status: "ok" as const, data }); + return { + ...real, + commands: { + ...real.commands, + vcsBranchInfo: () => + ok({ head: "main", headCommit: "c1", upstream: "origin/main", ahead: 1, behind: 0, operation: null }), + vcsRemote: (repo: string | null, _request: number, op: RemoteOp) => { + mocks.ops.push({ repo, op }); + const error = op.op === "push" ? (mocks.pushes.shift() ?? null) : null; + return error ? Promise.resolve({ status: "error", error: { kind: "failed", message: error } }) : ok(null); + }, + vcsLog: () => ok([]), + vcsStatus: () => ok([]), + settingsSet: (_scope: string, key: string, value: string) => { + mocks.settings.push([key, value]); + return ok({}); + }, + cancelRequest: () => ok(null), + }, + }; +}); +vi.mock("@/workbench/messages", () => ({ ask: mocks.ask, showError: vi.fn() })); + +import { provideNotificationService } from "@/workbench/contributions"; +import { pushWithAutoUpdate } from "../remoteUi"; +import { useVcs } from "../store"; + +const REJECTED = " ! [rejected] main -> main (fetch first)"; +const STALE = " ! [rejected] main -> main (stale info)"; +const push = { op: "push", force_with_lease: false, set_upstream: false, tags: null } as const; +const kinds = () => mocks.ops.map((o) => (o.op.op === "pull" ? `pull:${o.op.rebase ? "rebase" : "merge"}` : o.op.op)); +type Note = { title: string; actions?: Array<{ label: string }> }; +let notes: Note[] = []; + +beforeEach(() => { + mocks.ask.mockReset(); + mocks.ops = []; + mocks.pushes = []; + mocks.settings = []; + notes = []; + useVcs.setState({ roots: ["/a", "/b"], remoteBusy: null }); + provideNotificationService({ notify: (n) => void notes.push(n as Note), expire: () => {} }); +}); + +describe("a push rejected because the remote has new commits", () => { + it("asks how to update, updates every repository and pushes again", async () => { + mocks.pushes = [REJECTED]; + mocks.ask.mockResolvedValue({ button: "rebase", checked: false }); + expect(await pushWithAutoUpdate(push, "main → origin/main", "/a")).toBe(true); + expect(mocks.ask).toHaveBeenCalledTimes(1); + expect(mocks.ask.mock.calls[0]![0].title).toBe("Push Rejected"); + expect(kinds()).toEqual(["push", "pull:rebase", "pull:rebase", "push"]); + expect(mocks.ops.filter((o) => o.op.op === "pull").map((o) => o.repo)).toEqual(["/a", "/b"]); + // The chosen method becomes the update method; silent updates stay off. + expect(mocks.settings).toEqual([["vcs.git.updateMethod", '"rebase"']]); + }); + + it("keeps the chosen method while the push keeps being rejected, without asking again", async () => { + mocks.pushes = [REJECTED, REJECTED]; + mocks.ask.mockResolvedValue({ button: "merge", checked: true }); + expect(await pushWithAutoUpdate(push, "", "/a")).toBe(true); + expect(mocks.ask).toHaveBeenCalledTimes(1); + expect(kinds()).toEqual(["push", "pull:merge", "pull:merge", "push", "pull:merge", "pull:merge", "push"]); + expect(mocks.settings).toContainEqual(["vcs.git.autoUpdateOnPushRejected", "true"]); + }); + + it("gives up after ten attempts", async () => { + mocks.pushes = Array.from({ length: 20 }, () => REJECTED); + mocks.ask.mockResolvedValue({ button: "merge", checked: false }); + expect(await pushWithAutoUpdate(push, "", "/a")).toBe(false); + expect(kinds().filter((k) => k === "push")).toHaveLength(10); + }); + + it("doesn't update on Cancel", async () => { + mocks.pushes = [REJECTED]; + mocks.ask.mockResolvedValue({ button: "cancel", checked: false }); + expect(await pushWithAutoUpdate(push, "", "/a")).toBe(false); + expect(kinds()).toEqual(["push"]); + // The dialog, not an error balloon, reported the rejection; cancelling it leaves "Push rejected". + expect(notes.map((n) => n.title).filter((t) => /fail|reject/i.test(t))).toEqual(["Push rejected"]); + }); +}); + +describe("a rejected forced push", () => { + it("is not followed by an update; a stale lease offers to force push anyway", async () => { + mocks.pushes = [STALE]; + expect(await pushWithAutoUpdate({ ...push, force_with_lease: true }, "", "/a")).toBe(false); + expect(mocks.ask).not.toHaveBeenCalled(); + expect(kinds()).toEqual(["push"]); + const offer = notes.at(-1)!; + expect(offer.actions?.map((a) => a.label)).toEqual(["Force push anyway"]); + }); + + it("with a lease rejected for another reason only reports it", async () => { + mocks.pushes = [REJECTED]; + expect(await pushWithAutoUpdate({ ...push, force_with_lease: true }, "", "/a")).toBe(false); + expect(mocks.ask).not.toHaveBeenCalled(); + expect(kinds()).toEqual(["push"]); + }); +}); diff --git a/src/features/vcs/__tests__/workflow.test.ts b/src/features/vcs/__tests__/workflow.test.ts index 4506da6a1..7a4e9389d 100644 --- a/src/features/vcs/__tests__/workflow.test.ts +++ b/src/features/vcs/__tests__/workflow.test.ts @@ -5,6 +5,7 @@ import { linkifyIssues } from "../issueLinks"; import { defaultPushChecked, isProtectedBranch, + isLeaseRejected, isPushRejected, pushCandidates, pushRepoCommits, @@ -73,6 +74,16 @@ describe("git settings helpers", () => { it("recognises rejected pushes", () => { expect(isPushRejected(" ! [rejected] main -> main (fetch first)")).toBe(true); expect(isPushRejected("Permission denied (publickey)")).toBe(false); + expect(isPushRejected(" ! [rejected] main -> main (non-fast-forward)")).toBe(true); + // A tag that exists on the remote, a hook or the server refusing: updating doesn't help. + expect( + isPushRejected( + " ! [rejected] v1 -> v1 (already exists)\nhint: Updates were rejected because the tag already exists in the remote.", + ), + ).toBe(false); + expect(isPushRejected(" ! [remote rejected] main -> main (pre-receive hook declined)")).toBe(false); + expect(isPushRejected(" ! [rejected] main -> main (stale info)")).toBe(false); + expect(isLeaseRejected(" ! [rejected] main -> main (stale info)")).toBe(true); }); it("propagates folder colours only when enabled", () => { diff --git a/src/features/vcs/branchMenu.ts b/src/features/vcs/branchMenu.ts index cdf4cc9c1..2facfcde1 100644 --- a/src/features/vcs/branchMenu.ts +++ b/src/features/vcs/branchMenu.ts @@ -4,6 +4,7 @@ import { executeAction } from "@/platform/actions"; import type { MenuItem } from "@/ui"; import { isFavoriteBranch } from "./branches"; import { + branchRef, checkoutAndRebase, checkoutBranch, checkoutDetached, @@ -11,6 +12,9 @@ import { deleteBranch, deleteInRepos, deleteRemoteBranch, + deleteTag, + knownRemotes, + pushTag, mergeInRepos, newBranchInRepos, rebaseInRepos, @@ -81,8 +85,11 @@ export function branchMenu(b: Branch, current: string | null, done: () => void = { label: "Show Diff with Working Tree", run: run(() => showDiffWithWorkingTree(b.name)) }, history, { kind: "separator" }, - { label: tx`Rebase '${cur}' onto '${b.name}'`, run: run(() => rebaseOnto(b.name)) }, - { label: tx`Merge '${b.name}' into '${cur}'`, run: run(() => mergeIntoCurrent(b.name)) }, + { label: tx`Rebase '${cur}' onto '${b.name}'`, run: run(() => rebaseOnto(b.name, branchRef(b.name, b.remote))) }, + { + label: tx`Merge '${b.name}' into '${cur}'`, + run: run(() => mergeIntoCurrent(b.name, undefined, branchRef(b.name, b.remote))), + }, ]; if (b.remote) items.push( @@ -122,6 +129,12 @@ export function tagMenu(tag: string, done: () => void = () => {}): MenuItem[] { { label: "Compare with Current", icon: "diff", run: run(() => showCompareBranches(tag)) }, { label: "Show Diff with Working Tree", run: run(() => showDiffWithWorkingTree(tag)) }, { label: "Show History", icon: "history", run: run(() => executeAction("vcs.showLog", { branch: tag })) }, + ...knownRemotes().flatMap((remote, i): MenuItem[] => [ + ...(i === 0 ? [{ kind: "separator" } as const] : []), + { label: tx`Push to ${remote}`, icon: "git-push", run: run(() => pushTag(tag, remote)) }, + ]), + { kind: "separator" }, + { label: "Delete", danger: true, icon: "trash", run: run(() => deleteTag(tag)) }, ]; } diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index 808856a98..d2f6c0dfa 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -203,8 +203,12 @@ export async function checkoutRevision() { } export function conflictActions(message: string) { - return conflictedFiles().length || /conflict/i.test(message) - ? [{ label: "Resolve Conflicts…", run: () => void executeAction("vcs.resolveConflicts") }] + if (conflictedFiles().length || /conflict/i.test(message)) + return [{ label: "Resolve Conflicts…", run: () => void executeAction("vcs.resolveConflicts") }]; + // Stopped for another reason (the status was refreshed by openConflictsInstead): Abort. + const op = useVcs.getState().branch?.operation; + return op === "rebase" || op === "merge" + ? [{ label: "Abort", run: () => void executeAction(`vcs.${op}.abort`) }] : []; } @@ -219,21 +223,26 @@ export async function openConflictsInstead(): Promise { return true; } -export async function mergeIntoCurrent(branch: string, repo: string | null = currentRepo()) { +/** The full ref of a branch, so a tag of the same name isn't taken instead. */ +export function branchRef(name: string, remote: boolean): string { + return remote ? `refs/remotes/${name}` : `refs/heads/${name}`; +} + +export async function mergeIntoCurrent(branch: string, repo: string | null = currentRepo(), ref = branch) { const current = (repo === currentRepo() ? useVcs.getState().branch?.head : null) ?? "HEAD"; await saveAll(); - await runOp(tx`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(repo, branch)), { + await runOp(tx`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(repo, ref)), { success: tx`Merged ${branch} into ${current}`, errorActions: conflictActions, onError: openConflictsInstead, }); } -export async function rebaseOnto(branch: string) { +export async function rebaseOnto(branch: string, ref = branch) { const repo = currentRepo(); const current = useVcs.getState().branch?.head ?? "HEAD"; await saveAll(); - await runOp(tx`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(repo, branch)), { + await runOp(tx`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(repo, ref)), { success: tx`Rebased ${current} onto ${branch}`, errorActions: conflictActions, onError: openConflictsInstead, @@ -312,6 +321,65 @@ export async function deleteBranch(name: string) { }); } +/** Remotes of the selected repository, as its remote-tracking branches name them. */ +export function knownRemotes(): string[] { + const names = useVcs + .getState() + .branches.filter((b) => b.remote) + .map((b) => b.name.slice(0, b.name.indexOf("/"))) + .filter(Boolean); + return [...new Set(names)]; +} + +/** Tag › Delete: deletes the local tag; the notification restores it or deletes it on the remotes too. */ +export async function deleteTag(name: string) { + const repo = currentRepo(); + const deleted = await runOp(tx`Delete tag ${name}`, () => call(commands.vcsDeleteTag(repo, name))); + if (!deleted) return; + const remotes = knownRemotes(); + const key = `vcs.deletedTag.${name}`; + const done = () => notifications().expire?.(key); + notifications().notify({ + group: "vcs", + level: "info", + key, + title: tx`Deleted tag ${name}`, + actions: [ + { + label: "Restore", + run: () => { + done(); + void runOp(tx`Restore tag ${name}`, () => call(commands.vcsRestoreTag(repo, deleted)), { + success: tx`Restored tag ${name}`, + }); + }, + }, + ...(remotes.length + ? [ + { + label: remotes.length === 1 ? "Delete on Remote" : "Delete on Remotes", + run: () => { + done(); + void deleteRemoteTag(name, remotes, repo); + }, + }, + ] + : []), + ], + }); +} + +async function deleteRemoteTag(tag: string, remotes: readonly string[], repo: string | null) { + for (const remote of remotes) { + if (!(await runRemote({ op: "deleteRemoteTag", tag, remote }, `${remote}: ${tag}`, [repo]))) return; + } +} + +/** Push Tag › Push to `remote`. */ +export async function pushTag(tag: string, remote: string) { + await runRemote({ op: "pushTag", tag, remote }, `${tag} → ${remote}`); +} + /** Deletes a branch on its remote (`git push --delete `); `name` is e.g. `origin/feature`. */ export async function deleteRemoteBranch(name: string) { const ok = await confirmDialog({ @@ -333,7 +401,7 @@ export async function checkoutAndRebase(name: string) { rememberBranch(current); rememberBranch(name); await refreshNow(); - await rebaseOnto(current); + await rebaseOnto(current, branchRef(current, false)); } /** Update for a branch other than the current one: fast-forwards it to its upstream. */ diff --git a/src/features/vcs/changelists.ts b/src/features/vcs/changelists.ts index b0e57e4a3..9822df04e 100644 --- a/src/features/vcs/changelists.ts +++ b/src/features/vcs/changelists.ts @@ -11,7 +11,7 @@ import { setLocalChanges, type LocalChangelist } from "@/workbench/contributions import type { CheckState } from "./changes"; import { groupOf } from "./changes"; import { effectiveKind, relTo, stripSlash } from "./model"; -import { useVcs } from "./store"; +import { localChangesMayBeAside, useVcs } from "./store"; export const DEFAULT_LIST_ID = "default"; export const DEFAULT_LIST_NAME = "Changes"; @@ -32,14 +32,20 @@ export function activeList(state: ChangelistState): Changelist { /** * Pins files that just became changed to the active list and forgets files that - * are no longer changed. Returns null when nothing changed. + * are no longer changed, unless `keepGone`: while a merge, rebase or update has the + * local changes put aside, their files keep their lists for when they come back. + * Returns null when nothing changed. */ -export function syncAssignments(state: ChangelistState, changed: readonly string[]): ChangelistState | null { +export function syncAssignments( + state: ChangelistState, + changed: readonly string[], + keepGone = false, +): ChangelistState | null { const live = new Set(changed); const files: Record = {}; let dirty = false; for (const [rel, id] of Object.entries(state.files ?? {})) { - if (live.has(rel) && state.lists.some((l) => l.id === id)) files[rel] = id; + if ((keepGone || live.has(rel)) && state.lists.some((l) => l.id === id)) files[rel] = id; else dirty = true; } for (const rel of changed) { @@ -195,7 +201,7 @@ export function installChangelists(): () => void { const sync = () => { const { status, root, available, statusLoaded } = useVcs.getState(); if (!available || !root || !statusLoaded || !useChangelists.getState().loaded) return; - const next = syncAssignments(useChangelists.getState().state, changedRels(status, root)); + const next = syncAssignments(useChangelists.getState().state, changedRels(status, root), localChangesMayBeAside()); if (next) updateChangelists(() => next); }; const load = () => { diff --git a/src/features/vcs/conflictsUi.tsx b/src/features/vcs/conflictsUi.tsx index 9a3d364fb..69d803381 100644 --- a/src/features/vcs/conflictsUi.tsx +++ b/src/features/vcs/conflictsUi.tsx @@ -1,5 +1,5 @@ /** "Resolve Conflicts" dialog and in-editor conflict resolution actions. */ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { FileStatus } from "@/bindings"; import { activeEditor } from "@/editor/api"; import { call, commands } from "@/platform/ipc"; @@ -9,13 +9,28 @@ import { blockAt, parseConflicts, resolutionText, type Resolution } from "./conf import { openDialog } from "./dialogs"; import { mergeInput } from "./mergeKind"; import { baseName, dirName, relTo, stripSlash } from "./model"; -import { controlLabel, controlOperation, inProgress, operationTitle } from "./operations"; +import { controlLabel, controlOperation, inProgress, operationTitle, type InProgress } from "./operations"; import { runOp, writeFileText } from "./ops"; import { executeAction } from "@/platform/actions"; import { conflictedFiles, useVcs } from "./store"; import styles from "./common.module.css"; import { tx } from "@/platform/i18n"; +/** + * The stopped rebase / cherry-pick / revert whose conflicts are being resolved from the + * Conflicts dialog (there or in the merge editor it opened): once the last one is + * resolved the operation continues by itself, as IDEA does. Closing the dialog + * without resolving them leaves it stopped. + */ +let continueWhenResolved: InProgress | null = null; + +/** The operation to continue now that its conflicts are resolved, if any (only once). */ +export function takeContinueWhenResolved(): InProgress | null { + const op = continueWhenResolved; + continueWhenResolved = null; + return op; +} + export function showConflictsDialog() { if (!conflictedFiles().length) { notifications().notify({ group: "vcs", level: "info", title: "No merge conflicts" }); @@ -52,6 +67,20 @@ function ConflictsDialog({ onClose }: { onClose: () => void }) { const files = useVcs((s) => s.status).filter((f) => f.unstaged === "conflicted"); const root = useVcs((s) => s.root) ?? ""; const op = useVcs((s) => inProgress(s.branch?.operation)); + useEffect(() => { + continueWhenResolved = op && op !== "merge" ? op : null; + }, [op]); + // The last conflict resolved here: the operation continues (see onConflictsCleared), the dialog goes. + const hadConflicts = useRef(files.length > 0); + useEffect(() => { + if (hadConflicts.current && !files.length && op && op !== "merge") onClose(); + hadConflicts.current = files.length > 0; + }, [files.length, op, onClose]); + // Closed without going on to resolve them: the operation stays stopped. + const dismiss = () => { + continueWhenResolved = null; + onClose(); + }; const head = useVcs((s) => s.branch?.head ?? null); const [active, setActive] = useState(0); const current = files[Math.min(active, files.length - 1)]; @@ -66,7 +95,7 @@ function ConflictsDialog({ onClose }: { onClose: () => void }) { {op ? operationTitle(op, head) : ""}} buttons={[ @@ -77,7 +106,7 @@ function ConflictsDialog({ onClose }: { onClose: () => void }) { role: "default" as const, autoFocus: true, onClick: () => { - onClose(); + dismiss(); conclude.run(); }, }, diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 750bfded2..3d4b7889c 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -25,7 +25,7 @@ import { controlLabel, controlOperation, inProgress } from "./operations"; import { annotationsShown, blameSlot } from "./blame"; import { inlineBlame } from "./inlineBlame"; import { BranchWidget, useBranchesPopup } from "./BranchWidget"; -import { showConflictsDialog } from "./conflictsUi"; +import { showConflictsDialog, takeContinueWhenResolved } from "./conflictsUi"; import { registerCompareActions } from "./compare"; import { DIFF_KIND, diffTitle, type DiffData } from "./diffInput"; import { VcsDialogHost } from "./dialogs"; @@ -186,8 +186,13 @@ export function activate(): () => void { onConflictsCleared(() => { notifications().expire?.("vcs.conflicts"); - // As IDEA does once the last conflict is resolved: offer to continue the stopped operation. const op = inProgress(); + // Resolved from the Conflicts dialog: the stopped rebase / cherry-pick goes on by itself. + if (op && takeContinueWhenResolved() === op) { + void controlOperation("continue", op); + return; + } + // Otherwise, as IDEA does once the last conflict is resolved: offer to continue it. if (op) notifications().notify({ group: "vcs", diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index 76cd02daf..54a2bcab3 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -17,6 +17,7 @@ import { KIND_COLOR, fileCount } from "./model"; import { runGitOperation, runOp } from "./ops"; import { loadLogDetails } from "./logDetails"; import { isProtectedBranch } from "./remoteUi"; +import { showLocalChanges } from "./commitUi"; import { reloadLog, useGitLog } from "./logStore"; import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoName, repoOf, useVcs } from "./store"; import styles from "./common.module.css"; @@ -82,6 +83,38 @@ export function controlLabel(op: InProgress, control: "continue" | "skip" | "abo return `${control === "continue" ? "Continue" : "Abort"} ${OP_NOUN[op]}`; } +/** Why `git rebase --continue` refused (see the backend's UNSTAGED_CHANGES_PREVENT_REBASE). */ +const UNSTAGED_CHANGES_PREVENT_REBASE = "There are unstaged changes in tracked files preventing rebase from continuing"; + +/** + * Links of the balloon of an operation that failed and is still stopped: Abort, and for + * a rebase held up by unstaged changes, Stage and Retry (stages the tracked files' + * changes and continues) and Show Files. + */ +export function stoppedActions(op: InProgress, message: string): Array<{ label: string; run: () => void }> { + if (!inProgress()) return []; + const abort = { label: "Abort", run: () => void controlOperation("abort", op) }; + if (op !== "rebase" || !message.includes(UNSTAGED_CHANGES_PREVENT_REBASE)) return [abort]; + return [ + { + label: "Stage and Retry", + run: () => + void runGitOperation( + { op: "stageAndContinue" }, + { + progress: "Continuing rebase", + success: "Rebase continued", + failure: tx`${controlLabel(op, "continue")} failed`, + onError: conflictsInstead, + errorActions: (m) => stoppedActions(op, m), + }, + ), + }, + { label: "Show Files", run: () => showLocalChanges() }, + abort, + ]; +} + /** Opens the conflicts dialog instead of an error balloon when the operation stopped on conflicts. */ export async function conflictsInstead(): Promise { await refreshNow(); @@ -170,6 +203,7 @@ export async function controlOperation(control: "continue" | "skip" | "abort", e success, failure: tx`${controlLabel(op, control)} failed`, onError: control === "abort" ? undefined : conflictsInstead, + errorActions: control === "abort" ? undefined : (message) => stoppedActions(op, message), }, ); // A rebase that continued and stopped again (next conflict) is reported by the conflicts listener. diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index 969fa94d9..a58f61005 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -5,13 +5,14 @@ import { Channel } from "@tauri-apps/api/core"; import { create } from "zustand"; import type { GitOperation, RemoteOp } from "@/bindings"; +import { executeAction } from "@/platform/actions"; import { tx } from "@/platform/i18n"; import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc"; import { attachDocument, editorsFor } from "@/editor/api"; import { maybeEditorService, notifications } from "@/workbench/contributions"; import { cancelTask, upsertTaskSoon, useTasks } from "@/workbench/tasks"; import { progressFraction } from "./model"; -import { currentRepo, refreshNow, repoName, useVcs } from "./store"; +import { beginGitWork, conflictedFiles, currentRepo, refreshNow, repoName, useVcs } from "./store"; // --------------------------------------------------------------------------- // Console (Git tool window → Console tab) @@ -73,6 +74,7 @@ export interface OpOptions { /** Runs `fn`, logging to the console and reporting failures. `undefined` on error. */ export async function runOp(title: string, fn: () => Promise, opts: OpOptions = {}): Promise { logConsole("cmd", title); + const endWork = beginGitWork(); try { const value = await fn(); const msg = typeof opts.success === "function" ? opts.success(value) : opts.success; @@ -95,6 +97,7 @@ export async function runOp(title: string, fn: () => Promise, opts: OpOpti }); return undefined; } finally { + endWork(); if (opts.refresh !== false) void refreshNow(); } } @@ -114,11 +117,14 @@ export async function saveAll() { const REMOTE_TITLE: Record = { fetch: ["Fetching", "Fetch"], pull: ["Updating project", "Update"], + resetToUpstream: ["Updating project", "Update"], pullFrom: ["Pulling", "Pull"], push: ["Pushing", "Push"], deleteBranch: ["Deleting remote branch", "Delete remote branch"], pushBranch: ["Pushing", "Push"], updateBranch: ["Updating branch", "Update"], + pushTag: ["Pushing", "Push"], + deleteRemoteTag: ["Deleting remote tag", "Delete remote tag"], }; let activeRemote: number | null = null; @@ -150,6 +156,8 @@ export async function runRemote( op: RemoteOp, detail?: string, repos: ReadonlyArray = [currentRepo()], + /** Failures the caller reports itself (e.g. a rejected push it goes on to update). */ + handled?: (message: string) => boolean, ): Promise { if (useVcs.getState().remoteBusy) { notifications().notify({ group: "vcs", level: "warning", title: "Another remote operation is in progress" }); @@ -200,7 +208,7 @@ export async function runRemote( } } try { - const before = op.op === "pull" || op.op === "pullFrom" ? await headCommit(repo) : null; + const before = isUpdate(op) ? await publishedTip(repo, op) : null; // The push summary counts what the push carries: the commits the branch is // ahead of its upstream before the push (pushing does not move the local head). if (op.op === "push" || (op.op === "pushBranch" && !op.target)) { @@ -208,7 +216,7 @@ export async function runRemote( if (info?.upstream && info.ahead > 0) pushed.push({ to: info.upstream, commits: info.ahead }); } await call(commands.vcsRemote(repo, request, op, channel)); - const after = before ? await headCommit(repo) : null; + const after = before ? await publishedTip(repo, op) : null; if (before && after && before !== after) updated.push({ repo, from: before, to: after }); } catch (e) { if (isCancelled(e) || !name) throw e; @@ -218,7 +226,7 @@ export async function runRemote( } if (failures.length) throw new Error(failures.join("\n")); lastRemoteError = null; - if (op.op === "pull" || op.op === "pullFrom") await notifyUpdated(updated); + if (isUpdate(op)) await notifyUpdated(updated); else { // One repository's push names what went out; a partial multi-repo push stays generic. const one = pushed.length === 1 ? pushed[0] : null; @@ -226,7 +234,12 @@ export async function runRemote( group: "vcs", level: "info", key: "vcs.remote", - title: one ? pushedTitle(one.commits, one.to) : `${doneTitle} successful`, + title: + op.op === "pushTag" + ? tx`Pushed tag ${op.tag} to ${op.remote}` + : one + ? pushedTitle(one.commits, one.to) + : `${doneTitle} successful`, body: lastLine && !/%/.test(lastLine) ? lastLine : undefined, }); } @@ -236,8 +249,12 @@ export async function runRemote( lastRemoteError = cancelled ? null : errorMessage(e); logConsole(cancelled ? "info" : "err", cancelled ? `${doneTitle} cancelled` : errorMessage(e)); // The user asked for it: the progress balloon just goes away. - if (cancelled) notifications().expire?.("vcs.remote"); - else + if (cancelled || handled?.(errorMessage(e))) notifications().expire?.("vcs.remote"); + else if (isUpdate(op) && (await stoppedOnConflicts())) { + // An update that stopped on conflicts opens them for resolving, not an error. + notifications().expire?.("vcs.remote"); + void executeAction("vcs.resolveConflicts"); + } else notifications().notify({ group: "vcs", level: "error", @@ -260,12 +277,36 @@ interface UpdatedRange { to: string; } +/** Update Project and the operations that bring the branch up to date like it. */ +const isUpdate = (op: RemoteOp) => op.op === "pull" || op.op === "pullFrom" || op.op === "resetToUpstream"; + +async function stoppedOnConflicts(): Promise { + await refreshNow(); + return conflictedFiles().length > 0; +} + const headCommit = (repo: string | null) => call(commands.vcsBranchInfo(repo)).then( (i) => i.headCommit, () => null, ); +/** + * Where the branch meets the branch it updates from (their merge base), the start and + * end of what an update brought in: the incoming commits only, not the local ones a + * rebase rewrites or the merge commit. HEAD when that branch can't be resolved. + */ +async function publishedTip(repo: string | null, op: RemoteOp): Promise { + const tracked = + op.op === "pull" || op.op === "resetToUpstream" || (op.op === "pullFrom" && !op.branches.length) + ? "@{u}" + : op.op === "pullFrom" && op.branches.length === 1 + ? `${op.remote}/${op.branches[0]}` + : null; + const base = tracked ? await call(commands.vcsMergeBase(repo, "HEAD", tracked)).catch(() => null) : null; + return base ?? headCommit(repo); +} + /** Counts for a translated sentence: each variant is its own source text, as "{count} files". */ const filesText = (n: number) => (n === 1 ? tx`${n} file` : tx`${n} files`); const commitsText = (n: number) => (n === 1 ? tx`${n} commit` : tx`${n} commits`); @@ -333,6 +374,8 @@ export interface GitOperationOptions { onError?: (message: string) => boolean | Promise; /** Links of the success balloon (e.g. Undo), worked out after the status refreshed. */ successActions?: () => Promise<{ label: string; run: () => void }[]>; + /** Links of the error balloon, worked out after the status refreshed. */ + errorActions?: (message: string) => Array<{ label: string; run: () => void }>; } /** @@ -397,6 +440,7 @@ export async function runGitOperation( key: "vcs.operation", title: opts.failure, body: message, + actions: opts.errorActions?.(message), }); return false; } finally { @@ -413,6 +457,8 @@ export function describeOperation(op: GitOperation): string { case "abort": case "skip": return `${useVcs.getState().branch?.operation ?? ""} --${op.op}`.trim(); + case "stageAndContinue": + return `add -u && ${useVcs.getState().branch?.operation ?? ""} --continue`.trim(); case "cherryPick": return `cherry-pick ${op.commits.map((c) => c.slice(0, 8)).join(" ")}`; case "revert": diff --git a/src/features/vcs/remoteUi.tsx b/src/features/vcs/remoteUi.tsx index 38ce7d707..c5082c34b 100644 --- a/src/features/vcs/remoteUi.tsx +++ b/src/features/vcs/remoteUi.tsx @@ -3,9 +3,23 @@ import { useEffect, useMemo, useState } from "react"; import type { BranchInfo, Commit, PushTags, RemoteOp } from "@/bindings"; import { call, commands } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; -import { workflowSettings } from "@/platform/workflowSettings"; +import { workflowSettings, type UpdateMethod } from "@/platform/workflowSettings"; +import { useProject } from "@/platform/project"; import { notifications } from "@/workbench/contributions"; -import { Checkbox, Dialog, Input, List, Select, Spinner, SplitGroup, SplitHandle, SplitPanel, T, Tree } from "@/ui"; +import { + Button, + Checkbox, + Dialog, + Input, + List, + Select, + Spinner, + SplitGroup, + SplitHandle, + SplitPanel, + T, + Tree, +} from "@/ui"; import { ask } from "@/workbench/messages"; import { openDialog } from "./dialogs"; import { formatTime } from "./model"; @@ -34,12 +48,45 @@ async function updateProject(rebase: boolean) { await runRemote({ op: "pull", rebase }, detail, repos); } +/** Writes a Version Control setting of the project, as the Settings page would. */ +async function saveVcsSetting(key: string, value: unknown) { + const scope = useProject.getState().project ? "project" : "global"; + const settings = await call(commands.settingsSet(scope, key, JSON.stringify(value))).catch(() => null); + if (settings) useProject.setState({ settings }); +} + +/** Reset to the Remote Branch: after confirming, drops local commits and takes the tracked branch. */ +async function resetToRemoteBranch() { + const branch = useVcs.getState().branch; + if (branch?.head && branch.upstream) { + const local = branch.head; + const remote = branch.upstream; + const r = await ask({ + title: "Reset to the Remote Branch", + body: tx`Do you want to reset local branch ${local} to ${remote}? Local commits will be dropped`, + icon: "warning", + buttons: [ + { id: "ok", label: "OK", primary: true }, + { id: "cancel", label: "Cancel" }, + ], + }); + if (r.button !== "ok") return; + } + await saveAll(); + await runRemote({ op: "resetToUpstream" }, branch?.upstream ? `${branch.head} ← ${branch.upstream}` : undefined); +} + function UpdateDialog({ onClose }: { onClose: () => void }) { const branch = useVcs((s) => s.branch); - // Default from Settings › Version Control › Git › Update method. + const single = useVcs((s) => s.roots.length <= 1); + // Default from Settings › Version Control › Git › Update method; the choice is kept there. const [rebase, setRebase] = useState(() => workflowSettings().vcs.git.updateMethod === "rebase"); + const [hide, setHide] = useState(false); const run = async () => { onClose(); + const method = rebase ? "rebase" : "merge"; + if (method !== workflowSettings().vcs.git.updateMethod) await saveVcsSetting("vcs.git.updateMethod", method); + if (hide) await saveVcsSetting("vcs.confirmation.showUpdateOptions", false); await updateProject(rebase); }; return ( @@ -47,7 +94,22 @@ function UpdateDialog({ onClose }: { onClose: () => void }) { open title="Update Project" onClose={onClose} - width={420} + width={single ? 540 : 420} + footerStart={ + <> + {single && ( + + )} + + + } buttons={[ { label: "OK", role: "default", autoFocus: true, onClick: () => void run() }, { label: "Cancel", role: "cancel" }, @@ -122,6 +184,21 @@ export async function showPushDialog(target: PushTarget = {}) { openDialog((close) => (multi ? : )); } +/** Whether any of `roots` has a pre-push hook: the Push dialog then offers Run Git hooks. */ +function usePrePushHook(roots: ReadonlyArray): boolean { + const [found, setFound] = useState(false); + useEffect(() => { + let alive = true; + void Promise.all(roots.map((r) => call(commands.vcsHasHook(r, "pre-push")).catch(() => false))).then( + (has) => alive && setFound(has.some(Boolean)), + ); + return () => { + alive = false; + }; + }, [roots]); + return found; +} + /** A repository in the multi-repository Push dialog. */ export interface RepoPush { root: string; @@ -163,6 +240,8 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { const [force, setForce] = useState(false); const [pushTags, setPushTags] = useState(false); const [tagsMode, setTagsMode] = useState("all"); + const hasHook = usePrePushHook(roots); + const [runHooks, setRunHooks] = useState(true); useEffect(() => { let alive = true; @@ -201,6 +280,9 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { const picked = (repos ?? []).filter((r) => checked.has(r.root)); const push = async () => { onClose(); + // Every picked repository is pushed even when another fails; the outcome is summed up. + const session: PushSession = { method: null }; + const failed: string[] = []; for (const r of picked) { const upstream = r.branch.upstream; const ok = await pushWithAutoUpdate( @@ -209,12 +291,23 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { force_with_lease: force && !isProtectedBranch(targetOf(r)), set_upstream: !upstream, tags: pushTags ? tagsMode : null, + no_verify: hasHook && !runHooks, }, `${repoName(r.root)}: ${r.branch.head} → ${upstream ?? "new remote branch"}`, r.root, + session, ); - if (!ok) return; + if (!ok) failed.push(repoName(r.root)); } + const notPushed = failed.join(", "); + if (failed.length && picked.length > 1) + notifications().notify({ + group: "vcs", + level: "error", + key: "vcs.remote", + title: failed.length < picked.length ? "Push partially failed" : "Push failed", + body: tx`Not pushed: ${notPushed}. See the Git console for details.`, + }); }; const nodes: PushNode[] = (repos ?? []).map((repo) => ({ key: repo.root, @@ -237,6 +330,7 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { footerStart={ <> + {hasHook && } value={tagsMode} @@ -367,6 +461,9 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo const [setUpstream, setSetUpstream] = useState(!tracked); const [pushTags, setPushTags] = useState(false); const [tagsMode, setTagsMode] = useState("all"); + const hookRoots = useMemo(() => [repo], [repo]); + const hasHook = usePrePushHook(hookRoots); + const [runHooks, setRunHooks] = useState(true); const upstream = trackedBranch ? (remote ? `${remote}/${trackedBranch}` : tracked) : tracked; useEffect(() => { @@ -427,6 +524,8 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo force_with_lease: force && !protectedTarget, target: retarget ? edited : null, remote: remoteChanged ? remote : null, + set_upstream: setUpstream && !revision, + no_verify: hasHook && !runHooks, }, `${revision ? `${head} up to ${revision.slice(0, 8)}` : head} → ${targetLabel}`, [repo], @@ -439,6 +538,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo force_with_lease: force && !protectedTarget, set_upstream: setUpstream || !upstream, tags: pushTags ? tagsMode : null, + no_verify: hasHook && !runHooks, }, upstream ? `${head} → ${upstream}` : `${head} → new remote branch`, repo, @@ -494,14 +594,17 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo label="Force (with lease)" /> + {hasHook && } + {!revision && ( + + )} {!explicit && !retarget && !remoteChanged && ( <> - value={tagsMode} @@ -652,40 +755,125 @@ export function isProtectedBranch(branch: string, patterns = workflowSettings(). ); } -/** Rejected because the remote has commits we don't (non-fast-forward). */ +/** + * Rejected because the remote has commits we don't (non-fast-forward): an update helps. + * Not a tag that already exists there, nor a hook or the server refusing it. + */ export function isPushRejected(message: string): boolean { - return /\[rejected\]|non-fast-forward|fetch first|updates were rejected/i.test(message); + return /\((?:non-fast-forward|fetch first)\)|tip of your current branch is behind|remote contains work that you do/i.test( + message, + ); } +/** A force-with-lease push refused because the remote moved since the last fetch. */ +export function isLeaseRejected(message: string): boolean { + return /\(stale info\)/i.test(message); +} + +/** One Push of several repositories: the update method is asked for once. */ +interface PushSession { + method: UpdateMethod | null; +} + +/** Pushes and updates again at most this many times while the push keeps being rejected. */ +const MAX_PUSH_ATTEMPTS = 10; + /** - * Pushes; when the push is rejected, either updates (pull with the configured - * update method) and pushes again ("Auto-update if push of the current branch - * was rejected") or offers to. + * Pushes the current branch; when the push is rejected because the remote has new + * commits, updates every repository with the method chosen in the Push Rejected + * dialog (or, with "update silently", the configured one) and pushes again, as long + * as it keeps being rejected. A rejected forced push offers neither. */ export async function pushWithAutoUpdate( op: Extract, detail: string, repo: string | null = currentRepo(), + session: PushSession = { method: null }, ): Promise { - if (await runRemote(op, detail, [repo])) return true; - const error = lastRemoteFailure(); - if (!error || op.force_with_lease || !isPushRejected(error)) return false; - const git = workflowSettings().vcs.git; - const updateAndPush = async () => { - await saveAll(); - const pull = { op: "pull" as const, rebase: git.updateMethod === "rebase" }; - if (!(await runRemote(pull, "updating after rejected push", [repo]))) return false; - return runRemote(op, detail, [repo]); + // A rejection that leads to an update is reported by the Push Rejected dialog instead. + const updatable = (message: string) => !op.force && !op.force_with_lease && isPushRejected(message); + const rejected = (message: string) => { + notifications().notify({ group: "vcs", level: "error", key: "vcs.remote", title: "Push rejected", body: message }); + return false; }; - if (git.autoUpdateOnPushRejected) return updateAndPush(); + for (let attempt = 1; ; attempt++) { + if (await runRemote(op, detail, [repo], updatable)) return true; + const error = lastRemoteFailure(); + if (!error) return false; + if (op.force_with_lease && isLeaseRejected(error)) { + offerForcePush(op, detail, repo); + return false; + } + if (!updatable(error)) return false; + if (attempt >= MAX_PUSH_ATTEMPTS) return rejected(error); + // Only the tracked branch is brought up to date: a new remote branch has nothing to update from. + const branch = await call(commands.vcsBranchInfo(repo)).catch(() => null); + if (!branch?.head || !branch.upstream) return rejected(error); + session.method ??= await chooseUpdateMethod(repo, branch.head); + if (!session.method) return rejected(error); + await saveAll(); + const pull = { op: "pull" as const, rebase: session.method === "rebase" }; + if (!(await runRemote(pull, tx("updating after rejected push"), allRepos()))) return false; + } +} + +/** Unpushed merge commits, which a rebase would linearize (and may need resolving again). */ +async function hasOutgoingMerges(repo: string | null): Promise { + const commits = await logOf("HEAD", 1000, "@{u}..HEAD", repo).catch(() => []); + return commits.some((c) => c.parents.length > 1); +} + +/** The Push Rejected dialog: Merge or Rebase, optionally remembered as the silent update. */ +async function chooseUpdateMethod(repo: string | null, branch: string): Promise { + const git = workflowSettings().vcs.git; + if (git.autoUpdateOnPushRejected) return git.updateMethod; + const merges = await hasOutgoingMerges(repo); + const name = repo && useVcs.getState().roots.length > 1 ? repoName(repo) : null; + const rejected = name + ? tx`Push of the current branch "${branch}" in repository "${name}" was rejected.` + : tx`Push of the current branch "${branch}" was rejected.`; + const why = merges + ? tx( + "Remote changes need to be merged before pushing.\n\nIn this case merge is highly recommended, because there are non-pushed merge commits. Rebasing them can lead to problems.", + ) + : tx("Remote changes need to be merged before pushing."); + const rebaseFirst = !merges && git.updateMethod === "rebase"; + const r = await ask({ + title: "Push Rejected", + body: `${rejected}\n\n${why}`, + icon: "warning", + width: 520, + checkbox: + "Remember the update method and update silently in the future. Change this later in Settings | Version Control.", + buttons: [ + { id: "merge", label: "Merge", primary: !rebaseFirst }, + { id: "rebase", label: merges ? "Rebase Anyway" : "Rebase", primary: rebaseFirst }, + { id: "cancel", label: "Cancel" }, + ], + }); + if (r.button !== "merge" && r.button !== "rebase") return null; + if (r.checked) await saveVcsSetting("vcs.git.autoUpdateOnPushRejected", true); + // A merge chosen only because of the merge commits doesn't replace a preferred rebase. + if (!merges && r.button !== git.updateMethod) await saveVcsSetting("vcs.git.updateMethod", r.button); + return r.button; +} + +/** A rejected force-with-lease push: says why and offers a plain force push. */ +function offerForcePush(op: Extract, detail: string, repo: string | null) { + const branch = useVcs.getState().branch; + const source = branch?.head ?? "HEAD"; + const target = branch?.upstream ?? tx("the remote"); notifications().notify({ + group: "vcs", level: "error", key: "vcs.remote", title: "Push rejected", - body: "The remote contains commits that are not in your branch. Update the branch, then push again.", + body: tx`Force-with-lease push ${source} to ${target} was rejected. Force-with-lease push prevents overriding remote changes that haven't been fetched to the local repository. Fetch recent changes to verify that they can be discarded safely and repeat the push operation.`, actions: [ - { label: git.updateMethod === "rebase" ? "Rebase and Push" : "Merge and Push", run: () => void updateAndPush() }, + { + label: "Force push anyway", + run: () => void runRemote({ ...op, force_with_lease: false, force: true }, detail, [repo]), + }, ], }); - return false; } diff --git a/src/features/vcs/store.ts b/src/features/vcs/store.ts index b35476dee..3e64d3baa 100644 --- a/src/features/vcs/store.ts +++ b/src/features/vcs/store.ts @@ -110,6 +110,28 @@ export function attentionRepo( return Object.keys(now).find((root) => !before[root]) ?? null; } +let gitWork = 0; + +/** Marks a git command as running until the returned function is called. */ +export function beginGitWork(): () => void { + gitWork++; + let ended = false; + return () => { + if (!ended) gitWork--; + ended = true; + }; +} + +/** + * A git command is running or an operation is stopped in some repository: local + * changes may be put aside for the time being (merge, rebase, update), so files that + * look unchanged now may come back. + */ +export function localChangesMayBeAside(): boolean { + const s = useVcs.getState(); + return gitWork > 0 || !!s.remoteBusy || Object.keys(s.operations).length > 0 || !!s.branch?.operation; +} + /** Selects the repository branch / log / remote operations act on. */ export function selectRepo(root: string): Promise { if (useVcs.getState().repo === root) return Promise.resolve(); diff --git a/src/platform/locales/text/es.json b/src/platform/locales/text/es.json index 2d5ff1677..37333d489 100644 --- a/src/platform/locales/text/es.json +++ b/src/platform/locales/text/es.json @@ -1231,7 +1231,6 @@ "Memory Indicator": "Indicador de memoria", "Memory and diagnostics": "Memoria y diagnósticos", "Merge": "Fusionar", - "Merge and Push": "Fusionar y hacer push", "Merge conflicts": "Conflictos de fusión", "Merge incoming changes into the current branch": "Fusionar los cambios entrantes en la rama actual", "Merge…": "Fusionar…", @@ -1644,7 +1643,6 @@ "Read-only Attribute": "Atributo de solo lectura", "Rebase": "Rebase", "Rebase Current Branch onto Selected Commit": "Hacer rebase de la rama actual sobre la confirmación seleccionada", - "Rebase and Push": "Hacer rebase y push", "Rebase the current branch on top of incoming changes": "Hacer rebase de la rama actual sobre los cambios entrantes", "Rebuild Last": "Recompilar último", "Rebuild Project": "Recompilar proyecto", @@ -2267,7 +2265,6 @@ "Builds, sync and Maven run configurations run through mvnd for speed. It embeds its own Maven (the home and mvnw are bypassed), needs Java 17+ and builds in parallel by default. Falls back to mvn when mvnd is missing or the JDK is too old.": "Las compilaciones, la sincronización y las configuraciones de ejecución de Maven se ejecutan a través de mvnd para mayor velocidad. Incluye su propio Maven (se omiten el directorio raíz y mvnw), necesita Java 17+ y compila en paralelo de forma predeterminada. Recurre a mvn cuando falta mvnd o el JDK es demasiado antiguo.", "The project is not a Git repository.": "El proyecto no es un repositorio de Git.", "The project path is always shown in the status bar navigation bar.": "La ruta del proyecto se muestra siempre en la barra de navegación de la barra de estado.", - "The remote contains commits that are not in your branch. Update the branch, then push again.": "El repositorio remoto contiene confirmaciones que no están en tu rama. Actualiza la rama y vuelve a hacer push.", "The report covers no files": "El informe no abarca ningún archivo", "The shortcut is already assigned to other actions. Do you want to remove other assignments?": "El atajo ya está asignado a otras acciones. ¿Deseas quitar las demás asignaciones?", "The tab on the left": "La pestaña de la izquierda", diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index 95c382ebe..f5ff766d1 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -1160,7 +1160,6 @@ "Memory Indicator": "Indicateur de mémoire", "Memory and diagnostics": "Mémoire et diagnostics", "Merge": "Fusionner", - "Merge and Push": "Fusionner et pousser", "Merge conflicts": "Conflits de fusion", "Merge incoming changes into the current branch": "Fusionner les modifications entrantes dans la branche actuelle", "Merge…": "Fusionner…", @@ -1572,7 +1571,6 @@ "Read-only Attribute": "Attribut lecture seule", "Rebase": "Rebase", "Rebase Current Branch onto Selected Commit": "Rebaser la branche actuelle sur le commit sélectionné", - "Rebase and Push": "Rebaser et pousser", "Rebase the current branch on top of incoming changes": "Rebaser la branche actuelle sur les modifications entrantes", "Rebuild Last": "Recompiler le dernier", "Rebuild Project": "Recompiler le projet", @@ -2164,7 +2162,6 @@ "The patch file is empty": "Le fichier de patch est vide", "The project is not a Git repository.": "Le projet n'est pas un dépôt Git.", "The project path is always shown in the status bar navigation bar.": "Le chemin du projet est toujours affiché dans la barre de navigation de la barre d'état.", - "The remote contains commits that are not in your branch. Update the branch, then push again.": "Le dépôt distant contient des commits absents de votre branche. Mettez la branche à jour, puis poussez à nouveau.", "The report covers no files": "Le rapport ne couvre aucun fichier", "The shortcut is already assigned to other actions. Do you want to remove other assignments?": "Le raccourci est déjà attribué à d'autres actions. Voulez-vous retirer les autres attributions ?", "The tab on the left": "L'onglet de gauche", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 1a724815c..062ac14b4 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -1219,7 +1219,6 @@ "Memory Indicator": "메모리 표시기", "Memory and diagnostics": "메모리 및 진단", "Merge": "병합", - "Merge and Push": "병합 및 푸시", "Merge conflicts": "병합 충돌", "Merge incoming changes into the current branch": "수신 변경 사항을 현재 브랜치에 병합", "Merge…": "병합…", @@ -1632,7 +1631,6 @@ "Read-only Attribute": "읽기 전용 속성", "Rebase": "리베이스", "Rebase Current Branch onto Selected Commit": "현재 브랜치를 선택한 커밋으로 리베이스", - "Rebase and Push": "리베이스 후 푸시", "Rebase the current branch on top of incoming changes": "들어오는 변경 사항 위로 현재 브랜치 리베이스", "Rebuild Last": "마지막 항목 다시 빌드", "Rebuild Project": "프로젝트 다시 빌드", @@ -2250,7 +2248,6 @@ "The patch file is empty": "패치 파일이 비어 있습니다", "The project is not a Git repository.": "프로젝트가 Git 저장소가 아닙니다.", "The project path is always shown in the status bar navigation bar.": "프로젝트 경로는 항상 상태 표시줄 탐색 표시줄에 표시됩니다.", - "The remote contains commits that are not in your branch. Update the branch, then push again.": "원격에 현재 브랜치에 없는 커밋이 있습니다. 브랜치를 업데이트한 후 다시 푸시하세요.", "The report covers no files": "보고서에 포함된 파일이 없습니다", "The shortcut is already assigned to other actions. Do you want to remove other assignments?": "이 단축키는 이미 다른 액션에 할당되어 있습니다. 다른 할당을 제거하시겠습니까?", "The tab on the left": "왼쪽 탭", diff --git a/src/platform/locales/text/pl.json b/src/platform/locales/text/pl.json index fa53e83c9..3c9c5e5bf 100644 --- a/src/platform/locales/text/pl.json +++ b/src/platform/locales/text/pl.json @@ -1221,7 +1221,6 @@ "Memory Indicator": "Wskaźnik pamięci", "Memory and diagnostics": "Pamięć i diagnostyka", "Merge": "Scal (Merge)", - "Merge and Push": "Scal i wyślij (Merge and Push)", "Merge conflicts": "Konflikty scalania", "Merge incoming changes into the current branch": "Scal przychodzące zmiany z bieżącą gałęzią", "Merge…": "Scal…", @@ -1634,7 +1633,6 @@ "Read-only Attribute": "Atrybut tylko do odczytu", "Rebase": "Zmień bazę (Rebase)", "Rebase Current Branch onto Selected Commit": "Zmień bazę bieżącej gałęzi na wybrane zatwierdzenie", - "Rebase and Push": "Zmień bazę i wyślij (Rebase and Push)", "Rebase the current branch on top of incoming changes": "Zmień bazę bieżącej gałęzi na wierzch przychodzących zmian", "Rebuild Last": "Przebuduj ostatnie", "Rebuild Project": "Przebuduj projekt", @@ -2246,7 +2244,6 @@ "The patch file is empty": "Plik łatki jest pusty", "The project is not a Git repository.": "Projekt nie jest repozytorium Git.", "The project path is always shown in the status bar navigation bar.": "Ścieżka projektu jest zawsze wyświetlana na pasku nawigacyjnym paska stanu.", - "The remote contains commits that are not in your branch. Update the branch, then push again.": "Repozytorium zdalne zawiera zatwierdzenia, których nie ma w Twojej gałęzi. Zaktualizuj gałąź, a następnie wyślij ponowne.", "The report covers no files": "Raport nie obejmuje żadnych plików", "The shortcut is already assigned to other actions. Do you want to remove other assignments?": "Skrót jest już przypisany do innych akcji. Czy chcesz usunąć pozostałe przypisania?", "The tab on the left": "Karta po lewej stronie", diff --git a/src/platform/locales/text/pt.json b/src/platform/locales/text/pt.json index a7617da79..a531713b5 100644 --- a/src/platform/locales/text/pt.json +++ b/src/platform/locales/text/pt.json @@ -1221,7 +1221,6 @@ "Memory Indicator": "Indicador de memória", "Memory and diagnostics": "Memória e diagnósticos", "Merge": "Mesclar", - "Merge and Push": "Mesclar e fazer push", "Merge conflicts": "Conflitos de mesclagem", "Merge incoming changes into the current branch": "Mesclar alterações recebidas na branch atual", "Merge…": "Mesclar…", @@ -1634,7 +1633,6 @@ "Read-only Attribute": "Atributo somente leitura", "Rebase": "Rebase", "Rebase Current Branch onto Selected Commit": "Fazer rebase da branch atual no commit selecionado", - "Rebase and Push": "Fazer rebase e push", "Rebase the current branch on top of incoming changes": "Fazer rebase da branch atual sobre as alterações recebidas", "Rebuild Last": "Recompilar último", "Rebuild Project": "Recompilar projeto", @@ -2247,7 +2245,6 @@ "The patch file is empty": "O arquivo de patch está vazio", "The project is not a Git repository.": "O projeto não é um repositório Git.", "The project path is always shown in the status bar navigation bar.": "O caminho do projeto é sempre exibido na barra de navegação da barra de status.", - "The remote contains commits that are not in your branch. Update the branch, then push again.": "O repositório remoto contém commits que não estão na sua ramificação. Atualize a ramificação e faça push novamente.", "The report covers no files": "O relatório não cobre nenhum arquivo", "The shortcut is already assigned to other actions. Do you want to remove other assignments?": "O atalho já está atribuído a outras ações. Deseja remover as outras atribuições?", "The tab on the left": "A aba à esquerda", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 3ed6f978a..ef52b4e21 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1275,7 +1275,6 @@ "Memory Indicator": "内存指示器", "Memory and diagnostics": "内存和诊断", "Merge": "合并", - "Merge and Push": "合并并推送", "Merge conflicts": "合并冲突", "Merge incoming changes into the current branch": "将传入的更改合并到当前分支", "Merge…": "合并…", @@ -1690,7 +1689,6 @@ "Read-only Attribute": "只读属性", "Rebase": "变基", "Rebase Current Branch onto Selected Commit": "将当前分支变基到所选提交", - "Rebase and Push": "变基并推送", "Rebase the current branch on top of incoming changes": "将当前分支变基到传入的更改之上", "Rebuild Last": "重新构建上一次", "Rebuild Project": "重新构建项目", @@ -2328,7 +2326,6 @@ "Builds, sync and Maven run configurations run through mvnd for speed. It embeds its own Maven (the home and mvnw are bypassed), needs Java 17+ and builds in parallel by default. Falls back to mvn when mvnd is missing or the JDK is too old.": "构建、同步和 Maven 运行配置改用 mvnd 执行以提速。它内嵌自己的 Maven(主路径与 mvnw 被绕过),需要 Java 17+,且默认并行构建。mvnd 缺失或 JDK 过旧时自动回退到 mvn。", "The project is not a Git repository.": "该项目不是 Git 仓库。", "The project path is always shown in the status bar navigation bar.": "项目路径始终显示在状态栏导航栏中。", - "The remote contains commits that are not in your branch. Update the branch, then push again.": "远程包含您的分支中没有的提交。请更新分支,然后再次推送。", "The report covers no files": "报告未覆盖任何文件", "The shortcut is already assigned to other actions. Do you want to remove other assignments?": "此快捷键已分配给其他操作。要移除其他分配吗?", "The tab on the left": "左侧标签页", @@ -6352,5 +6349,32 @@ "reset to the commit before the merge": "重置到合并前的提交", "reset to the commit before the rebase": "重置到变基前的提交", "restore branch {name}": "恢复分支 {name}", - "Rebased": "已变基" + "Rebased": "已变基", + "Push to {remote}": "推送到 {remote}", + "Delete tag {name}": "删除标签 {name}", + "Deleted tag {name}": "已删除标签 {name}", + "Restore tag {name}": "恢复标签 {name}", + "Restored tag {name}": "已恢复标签 {name}", + "Delete on Remote": "在远程删除", + "Delete on Remotes": "在各远程删除", + "Pushed tag {tag} to {remote}": "已将标签 {tag} 推送到 {remote}", + "Reset to the Remote Branch": "重置为远程分支", + "Do you want to reset local branch {local} to {remote}? Local commits will be dropped": "是否将本地分支 {local} 重置为 {remote}?本地提交将被丢弃", + "Don't show again": "不再显示", + "Push partially failed": "推送部分失败", + "Push failed": "推送失败", + "Not pushed: {notPushed}. See the Git console for details.": "未推送:{notPushed}。详情请查看 Git 控制台。", + "updating after rejected push": "推送被拒后更新", + "Push of the current branch \"{branch}\" in repository \"{name}\" was rejected.": "仓库“{name}”中当前分支“{branch}”的推送被拒绝。", + "Push of the current branch \"{branch}\" was rejected.": "当前分支“{branch}”的推送被拒绝。", + "Remote changes need to be merged before pushing.\n\nIn this case merge is highly recommended, because there are non-pushed merge commits. Rebasing them can lead to problems.": "推送前需要先合并远程更改。\n\n此时强烈建议使用合并,因为存在尚未推送的合并提交,对它们变基可能会出问题。", + "Remote changes need to be merged before pushing.": "推送前需要先合并远程更改。", + "Push Rejected": "推送被拒绝", + "Remember the update method and update silently in the future. Change this later in Settings | Version Control.": "记住更新方式,以后静默更新。之后可在“设置 | 版本控制”中更改。", + "Rebase Anyway": "仍然变基", + "the remote": "远程", + "Force-with-lease push {source} to {target} was rejected. Force-with-lease push prevents overriding remote changes that haven't been fetched to the local repository. Fetch recent changes to verify that they can be discarded safely and repeat the push operation.": "将 {source} 以 force-with-lease 方式推送到 {target} 被拒绝。force-with-lease 推送可防止覆盖尚未获取到本地仓库的远程更改。请先获取最新更改,确认可以安全丢弃后再重新推送。", + "Force push anyway": "仍然强制推送", + "Stage and Retry": "暂存并重试", + "Show Files": "显示文件" }