дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(vcs): update, push, rebase, tags and file history behave as expected (#1708)
Update Project - The update summary and View Commits cover only the incoming commits (merge base with the tracked branch before and after), not rebased local commits or the merge commit. - An update that stops on conflicts opens the Conflicts dialog instead of an error balloon. - The Update dialog remembers the chosen method, has "Don't show again", and "Reset to the Remote Branch" (fetch, drop local commits, keep local changes). Push - A rejected push asks Merge / Rebase (Rebase Anyway with unpushed merges) with "remember and update silently", updates every repository and pushes again, up to ten times, without an error balloon first. - A tag that already exists, a hook or server refusal is not treated as a non-fast-forward rejection; a stale force-with-lease offers "Force push anyway". - Run Git hooks (--no-verify) when a pre-push hook exists; Set upstream also when pushing to another branch name; multi-repository push continues past failures. - Tags: Push to <remote> and Delete (Restore, Delete on Remote). Rebase - Reword in an interactive rebase keeps message lines starting with '#'. - Continue stages edits of files already staged; other unstaged changes report a clear error with Stage and Retry, Show Files and Abort. - A stopped rebase or merge offers Abort in its error balloon; resolving the last conflict from the Conflicts dialog continues by itself. - Branch menu rebase/merge use the full branch ref when a tag has the same name. - Files keep their changelists while local changes are set aside. File history follows renames done in merges and history merged from other branches, and names the path of each revision.
Цей коміт міститься в:
1 джерело
c4b87ae09e
коміт
fc938ac567
30 files changed
+1543
-145
No files matched your search
@@ -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<PushTags>,
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
remote: Option<String>,
|
||||
#[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<PathBuf>, name: String) -> IdeResult<DeletedTag> {
|
||||
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<PathBuf>, 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<PathBuf>, name: String) -> IdeResult<bool> {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<ObjectId>, HashMap<ObjectId, String>)> {
|
||||
let mut ids = HashSet::new();
|
||||
let mut paths = HashMap::new();
|
||||
let mut starts: std::collections::VecDeque<(Vec<String>, 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<String> =
|
||||
["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<Vec<(ObjectId, Option<char>)>> {
|
||||
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<ObjectId> = records.iter().filter(|(_, s)| *s == Some('A')).map(|(id, _)| *id).collect();
|
||||
let kept: Option<HashSet<ObjectId>> = 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<char>)> {
|
||||
let mut out: Vec<(ObjectId, Option<char>)> = 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<Option<String>> {
|
||||
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<Vec<Commit>> {
|
||||
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::<Vec<_>>(), 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)?)
|
||||
|
||||
@@ -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<PushTags>,
|
||||
/// 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<PushTags>,
|
||||
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<bool> {
|
||||
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<DeletedTag> {
|
||||
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<Vec<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::tags(self)
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
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<String> =
|
||||
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<String> {
|
||||
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<crate::DeletedTag> {
|
||||
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<Vec<String>> {
|
||||
let repo = r.gix()?;
|
||||
|
||||
@@ -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<crate::PushTags>,
|
||||
o: crate::PushOptions,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let mut args: Vec<String> = 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<String> {
|
||||
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<bool> {
|
||||
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.<b>.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<String> = 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`.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<_>>(),
|
||||
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<String> = 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")])
|
||||
);
|
||||
}
|
||||
@@ -278,6 +278,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
name: String,
|
||||
) -> IdeResult<ide_vcs::DeletedTag> {
|
||||
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<PathBuf>,
|
||||
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<PathBuf>,
|
||||
name: String,
|
||||
) -> IdeResult<bool> {
|
||||
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]
|
||||
|
||||
+31
-4
@@ -523,6 +523,10 @@ export const commands = {
|
||||
vcsDeleteBranch: (repo: string | null, branch: string) => typedError<DeletedBranch, IdeError>(__TAURI_INVOKE("vcs_delete_branch", { repo, branch })),
|
||||
/** "Restore" of the branch-deleted notification. */
|
||||
vcsRestoreBranch: (repo: string | null, deleted: DeletedBranch) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_restore_branch", { repo, deleted })),
|
||||
/** Tag › Delete: the result is what "Restore" needs. */
|
||||
vcsDeleteTag: (repo: string | null, name: string) => typedError<DeletedTag, IdeError>(__TAURI_INVOKE("vcs_delete_tag", { repo, name })),
|
||||
/** "Restore" of the tag-deleted notification. */
|
||||
vcsRestoreTag: (repo: string | null, deleted: DeletedTag) => typedError<null, IdeError>(__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<null, IdeError>(__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<FileLineStats[], IdeError>(__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<boolean, IdeError>(__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<string | null, IdeError>(__TAURI_INVOKE("vcs_merge_base", { repo, a, b })),
|
||||
vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError<string, IdeError>(__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). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string | null>,
|
||||
settings: [] as Array<[string, string]>,
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@tauri-apps/api/core")>()),
|
||||
Channel: class {
|
||||
onmessage: (line: string) => void = () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)) },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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 <remote> --delete <name>`); `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. */
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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 = () => {
|
||||
|
||||
@@ -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 }) {
|
||||
<Dialog
|
||||
open
|
||||
title="Conflicts"
|
||||
onClose={onClose}
|
||||
onClose={dismiss}
|
||||
width={600}
|
||||
footerStart={<span className={styles.hint}>{op ? operationTitle(op, head) : ""}</span>}
|
||||
buttons={[
|
||||
@@ -77,7 +106,7 @@ function ConflictsDialog({ onClose }: { onClose: () => void }) {
|
||||
role: "default" as const,
|
||||
autoFocus: true,
|
||||
onClick: () => {
|
||||
onClose();
|
||||
dismiss();
|
||||
conclude.run();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<boolean> {
|
||||
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.
|
||||
|
||||
@@ -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<T> {
|
||||
/** Runs `fn`, logging to the console and reporting failures. `undefined` on error. */
|
||||
export async function runOp<T>(title: string, fn: () => Promise<T>, opts: OpOptions<T> = {}): Promise<T | undefined> {
|
||||
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<T>(title: string, fn: () => Promise<T>, opts: OpOpti
|
||||
});
|
||||
return undefined;
|
||||
} finally {
|
||||
endWork();
|
||||
if (opts.refresh !== false) void refreshNow();
|
||||
}
|
||||
}
|
||||
@@ -114,11 +117,14 @@ export async function saveAll() {
|
||||
const REMOTE_TITLE: Record<RemoteOp["op"], [string, string]> = {
|
||||
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<string | null> = [currentRepo()],
|
||||
/** Failures the caller reports itself (e.g. a rejected push it goes on to update). */
|
||||
handled?: (message: string) => boolean,
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<boolean>;
|
||||
/** 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":
|
||||
|
||||
@@ -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 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
void resetToRemoteBranch();
|
||||
}}
|
||||
>
|
||||
<T>Reset to the Remote Branch</T>
|
||||
</Button>
|
||||
)}
|
||||
<Checkbox checked={hide} onChange={setHide} label="Don't show again" />
|
||||
</>
|
||||
}
|
||||
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 ? <MultiPushDialog onClose={close} /> : <PushDialog target={target} onClose={close} />));
|
||||
}
|
||||
|
||||
/** Whether any of `roots` has a pre-push hook: the Push dialog then offers Run Git hooks. */
|
||||
function usePrePushHook(roots: ReadonlyArray<string | null>): 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<PushTags>("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={
|
||||
<>
|
||||
<Checkbox checked={force} onChange={setForce} label="Force (with lease)" />
|
||||
{hasHook && <Checkbox checked={runHooks} onChange={setRunHooks} label="Run Git hooks" />}
|
||||
<Checkbox checked={pushTags} onChange={setPushTags} label="Push tags:" />
|
||||
<Select<PushTags>
|
||||
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<PushTags>("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)"
|
||||
/>
|
||||
</span>
|
||||
{hasHook && <Checkbox checked={runHooks} onChange={setRunHooks} label="Run Git hooks" />}
|
||||
{!revision && (
|
||||
<Checkbox
|
||||
checked={setUpstream || !tracked}
|
||||
disabled={!tracked}
|
||||
onChange={setSetUpstream}
|
||||
label="Set upstream"
|
||||
/>
|
||||
)}
|
||||
{!explicit && !retarget && !remoteChanged && (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={setUpstream || !upstream}
|
||||
disabled={!upstream}
|
||||
onChange={setSetUpstream}
|
||||
label="Set upstream"
|
||||
/>
|
||||
<Checkbox checked={pushTags} onChange={setPushTags} label="Push tags:" />
|
||||
<Select<PushTags>
|
||||
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<RemoteOp, { op: "push" }>,
|
||||
detail: string,
|
||||
repo: string | null = currentRepo(),
|
||||
session: PushSession = { method: null },
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<UpdateMethod | null> {
|
||||
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<RemoteOp, { op: "push" }>, 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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
if (useVcs.getState().repo === root) return Promise.resolve();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "왼쪽 탭",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "显示文件"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача