дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(vcs): allow explicitly overwriting a local branch (#1851)
The New Branch dialog refused any existing local name, so a branch could not be moved to a selected commit. When the name matches a local branch of the target repository, the dialog now offers an unchecked Overwrite existing branch option (git switch -C / git branch --force); the consent clears when the name changes, and Git's checkout checks keep the old tip and local changes if switching fails. New Branch from a Git Log commit also targets that commit's repository in a multi-repository project. Co-authored-by: shohruh-genius <[email protected]>
Цей коміт міститься в:
1 джерело
715ebf19e2
коміт
9298be8798
13 files changed
+177
-52
No files matched your search
@@ -583,12 +583,15 @@ pub async fn vcs_create_branch(
|
||||
name: String,
|
||||
start: Option<String>,
|
||||
checkout: bool,
|
||||
overwrite: Option<bool>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let recorded = checkout.then_some("Git checkout");
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout))
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| {
|
||||
r.create_branch_with_overwrite(&name, start.as_deref(), checkout, overwrite.unwrap_or(false))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -1335,7 +1338,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_create_branch", |c| async move {
|
||||
vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await
|
||||
vcs_create_branch(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("repo")?,
|
||||
c.arg("name")?,
|
||||
c.arg("start")?,
|
||||
c.arg("checkout")?,
|
||||
c.arg("overwrite")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add(
|
||||
"vcs_undo_commit",
|
||||
|
||||
@@ -151,7 +151,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
|
||||
|
||||
// A branch changes the line its way.
|
||||
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
|
||||
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true, None).await.unwrap();
|
||||
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
|
||||
f.edit_line(lib, theirs).await;
|
||||
f.save(lib).await;
|
||||
|
||||
@@ -1087,8 +1087,19 @@ impl Repo {
|
||||
/// Creates branch `name` at `start` (default HEAD), optionally switching to it. A
|
||||
/// remote-tracking start point (`origin/x`) becomes the new branch's upstream.
|
||||
pub fn create_branch(&self, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
|
||||
self.create_branch_with_overwrite(name, start, checkout, false)
|
||||
}
|
||||
|
||||
/// Creates a branch or moves its tip when overwrite is explicitly enabled.
|
||||
pub fn create_branch_with_overwrite(
|
||||
&self,
|
||||
name: &str,
|
||||
start: Option<&str>,
|
||||
checkout: bool,
|
||||
overwrite: bool,
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::create_branch(self, name, start, checkout)
|
||||
write::create_branch(self, name, start, checkout, overwrite)
|
||||
}
|
||||
/// Undo Commit: resets the current branch to the parent of `commit` (which must be
|
||||
/// HEAD and not a merge) keeping its changes staged; undoing the initial commit
|
||||
|
||||
@@ -641,7 +641,13 @@ fn stash_count(r: &Repo) -> IdeResult<usize> {
|
||||
}
|
||||
|
||||
/// Creates `name` at `start` (default HEAD); a remote-tracking start point is tracked.
|
||||
pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
|
||||
pub(crate) fn create_branch(
|
||||
r: &Repo,
|
||||
name: &str,
|
||||
start: Option<&str>,
|
||||
checkout: bool,
|
||||
overwrite: bool,
|
||||
) -> IdeResult<()> {
|
||||
let name = name.trim();
|
||||
let valid = !name.is_empty()
|
||||
&& !name.starts_with('-')
|
||||
@@ -658,7 +664,12 @@ pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout:
|
||||
let repo = r.gix()?;
|
||||
resolve(&repo, s)?;
|
||||
}
|
||||
let mut args = if checkout { vec!["switch", "-c", name] } else { vec!["branch", "--", name] };
|
||||
let mut args = match (checkout, overwrite) {
|
||||
(true, false) => vec!["switch", "-c", name],
|
||||
(true, true) => vec!["switch", "-C", name],
|
||||
(false, false) => vec!["branch", "--", name],
|
||||
(false, true) => vec!["branch", "--force", "--", name],
|
||||
};
|
||||
args.extend(start);
|
||||
run(r.root(), &args, None).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -950,6 +950,36 @@ fn branch_create_checkout_merge_rebase_delete() {
|
||||
assert!(!repo.branches().unwrap().iter().any(|b| b.name == "unmerged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_overwrite_preserves_the_ref_if_checkout_fails() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "base\n");
|
||||
let base = fx.commit_all("base");
|
||||
fx.write("a.txt", "main\n");
|
||||
let tip = fx.commit_all("main");
|
||||
let repo = fx.repo();
|
||||
repo.create_branch("topic", None, false).unwrap();
|
||||
|
||||
repo.create_branch_with_overwrite("topic", Some(&base), false, true).unwrap();
|
||||
assert_eq!(fx.git(&["rev-parse", "topic"]), base);
|
||||
assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip);
|
||||
|
||||
repo.create_branch_with_overwrite("topic", Some(&tip), false, true).unwrap();
|
||||
fx.write("a.txt", "dirty\n");
|
||||
assert!(repo.create_branch_with_overwrite("topic", Some(&base), true, true).is_err());
|
||||
assert_eq!(fx.git(&["rev-parse", "topic"]), tip);
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main"));
|
||||
assert_eq!(fx.read("a.txt"), "dirty\n");
|
||||
|
||||
fx.write("a.txt", "main\n");
|
||||
repo.create_branch_with_overwrite("topic", Some(&base), true, true).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("topic"));
|
||||
assert_eq!(fx.git(&["rev-parse", "HEAD"]), base);
|
||||
|
||||
repo.create_branch_with_overwrite("topic", Some(&tip), true, true).unwrap();
|
||||
assert_eq!(fx.git(&["rev-parse", "HEAD"]), tip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_branch_from_remote_branch_tracks_it() {
|
||||
let (fx, _bare) = Fixture::with_origin();
|
||||
|
||||
@@ -7,30 +7,47 @@ import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
|
||||
async function openNewBranchDialog(page: Page) {
|
||||
async function openNewBranchDialog(page: Page, from?: string) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async (root) => {
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: "1111111",
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => [];
|
||||
h.vcs_create_branch = () => null;
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
void executeAction("vcs.newBranch");
|
||||
}, ROOT);
|
||||
const dialog = page.getByRole("dialog", { name: "Create New Branch" });
|
||||
await page.evaluate(
|
||||
async ({ root, from }) => {
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: "1111111",
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => [];
|
||||
h.vcs_branches = () => [
|
||||
{
|
||||
name: "main",
|
||||
current: true,
|
||||
remote: false,
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
lastCommitMs: null,
|
||||
commit: "1111111",
|
||||
},
|
||||
];
|
||||
h.vcs_create_branch = () => null;
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow, loadBranches } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
await loadBranches();
|
||||
const actionsModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
|
||||
if (from) void executeAction("vcs.log.newBranch", { id: from, summary: "Selected commit", repo: root });
|
||||
else void executeAction("vcs.newBranch");
|
||||
},
|
||||
{ root: ROOT, from },
|
||||
);
|
||||
const dialog = page.getByRole("dialog", { name: from ? `Create New Branch from ${from}` : "Create New Branch" });
|
||||
const input = dialog.getByRole("textbox", { name: "New branch name:" });
|
||||
await expect(input).toBeVisible();
|
||||
return { dialog, input };
|
||||
@@ -48,6 +65,29 @@ test("a typed space becomes a separator and Create stays enabled", async ({ work
|
||||
expect(call).toMatchObject({ name: "fix-login-redirect" });
|
||||
});
|
||||
|
||||
test("overwrites a local branch only after explicitly opting in", async ({ workbench: page }) => {
|
||||
const from = "2".repeat(40);
|
||||
const { dialog, input } = await openNewBranchDialog(page, from);
|
||||
await input.fill("main");
|
||||
const create = dialog.getByRole("button", { name: "Create", exact: true });
|
||||
await expect(create).toBeDisabled();
|
||||
const overwrite = dialog.getByRole("checkbox", { name: "Overwrite existing branch" });
|
||||
await expect(overwrite).toBeVisible();
|
||||
await expect(overwrite).not.toBeChecked();
|
||||
await overwrite.check();
|
||||
await expect(create).toBeEnabled();
|
||||
await input.fill("new-branch");
|
||||
await expect(overwrite).toHaveCount(0);
|
||||
await input.fill("main");
|
||||
await expect(overwrite).not.toBeChecked();
|
||||
await expect(create).toBeDisabled();
|
||||
await overwrite.check();
|
||||
await create.click();
|
||||
await expect
|
||||
.poll(() => ipcCalls(page, "vcs_create_branch"))
|
||||
.toEqual([{ repo: ROOT, name: "main", start: from, checkout: true, overwrite: true }]);
|
||||
});
|
||||
|
||||
test("a space typed in the middle keeps the caret after it", async ({ workbench: page }) => {
|
||||
const { input } = await openNewBranchDialog(page);
|
||||
await input.fill("ab");
|
||||
|
||||
@@ -268,8 +268,9 @@ pub async fn vcs_create_branch(
|
||||
name: String,
|
||||
start: Option<String>,
|
||||
checkout: bool,
|
||||
overwrite: Option<bool>,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_create_branch(&state.host, window.label(), repo, name, start, checkout).await
|
||||
ide_host::ops::vcs::vcs_create_branch(&state.host, window.label(), repo, name, start, checkout, overwrite).await
|
||||
}
|
||||
|
||||
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
|
||||
|
||||
@@ -534,7 +534,7 @@ export const commands = {
|
||||
/** "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 })),
|
||||
vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean, overwrite: boolean | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout, overwrite })),
|
||||
/**
|
||||
* Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
|
||||
* Returns the undone commit's message.
|
||||
|
||||
@@ -35,15 +35,18 @@ function rememberBranch(name: string | null | undefined) {
|
||||
}
|
||||
|
||||
/** Valid-enough git ref name check (mirrors `git check-ref-format` basics). */
|
||||
export function validateBranchName(name: string): string | null {
|
||||
export function validateBranchName(
|
||||
name: string,
|
||||
overwrite = false,
|
||||
branches: readonly Branch[] = useVcs.getState().branches,
|
||||
): string | null {
|
||||
const n = name.trim();
|
||||
if (!n) return "Branch name must not be empty";
|
||||
if (/[\s~^:?*[\\\p{Cc}]/u.test(n)) return "Branch name contains invalid characters";
|
||||
if (n.startsWith("-") || n.startsWith("/") || n.endsWith("/") || n.endsWith(".") || n.endsWith(".lock"))
|
||||
return "Invalid branch name";
|
||||
if (n.includes("..") || n.includes("//") || n.includes("@{") || n === "@") return "Invalid branch name";
|
||||
const exists = useVcs.getState().branches.some((b) => !b.remote && b.name === n);
|
||||
if (exists) return `Branch '${n}' already exists`;
|
||||
if (!overwrite && branches.some((b) => !b.remote && b.name === n)) return tx`Branch '${n}' already exists`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -154,16 +157,18 @@ export async function checkoutBranch(name: string) {
|
||||
}
|
||||
|
||||
/** New Branch: creates a branch from HEAD (or `from`) and, by default, checks it out. */
|
||||
export async function newBranch(from?: string) {
|
||||
const repo = currentRepo();
|
||||
export async function newBranch(from?: string, repo = currentRepo()) {
|
||||
const state = useVcs.getState();
|
||||
const selected = repo === state.repo;
|
||||
const previous = selected ? state.branch?.head : repo ? state.heads[repo]?.head : null;
|
||||
const branches =
|
||||
selected && state.branchesLoaded
|
||||
? state.branches
|
||||
: await call(commands.vcsBranches(repo)).catch(() => (selected ? state.branches : []));
|
||||
// IDEA opens the dialog with a suggestion instead of an empty field: a
|
||||
// remote's name without the prefix, the source ref as `-copy`, or the
|
||||
// current branch for a plain New Branch from HEAD.
|
||||
const suggestion = from
|
||||
? from.includes("/")
|
||||
? shortRemoteName(from)
|
||||
: `${from}-copy`
|
||||
: (useVcs.getState().branch?.head ?? "");
|
||||
const suggestion = from ? (from.includes("/") ? shortRemoteName(from) : `${from}-copy`) : (previous ?? "");
|
||||
const r = await promptText({
|
||||
title: from ? tx`Create New Branch from ${from}` : "Create New Branch",
|
||||
label: "New branch name:",
|
||||
@@ -172,15 +177,15 @@ export async function newBranch(from?: string) {
|
||||
mono: true,
|
||||
okLabel: "Create",
|
||||
sanitize: cleanUpBranchNameOnTyping,
|
||||
validate: validateBranchName,
|
||||
validate: (name, overwrite) => validateBranchName(name, overwrite, branches),
|
||||
overwriteBranch: (name) => branches.some((b) => !b.remote && b.name === name.trim()),
|
||||
validateInitial: true,
|
||||
checkbox: { label: "Checkout branch", initial: true },
|
||||
});
|
||||
if (!r) return;
|
||||
const checkout = r.checked;
|
||||
const previous = useVcs.getState().branch?.head;
|
||||
if (checkout) await saveAll();
|
||||
const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout));
|
||||
const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout, r.overwrite ?? false));
|
||||
const ok = await runOp(tx`Create branch ${r.value}`, create, {
|
||||
success: checkout ? `Created and checked out ${r.value}` : `Created branch ${r.value}`,
|
||||
});
|
||||
@@ -525,9 +530,13 @@ export async function newBranchInRepos(roots: readonly string[], from?: string)
|
||||
await eachRepo(
|
||||
roots,
|
||||
(root) =>
|
||||
runOp(tx`Create branch ${name}`, () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), {
|
||||
refresh: false,
|
||||
}).then((done) => (done === undefined ? undefined : true)),
|
||||
runOp(
|
||||
tx`Create branch ${name}`,
|
||||
() => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked, false)),
|
||||
{
|
||||
refresh: false,
|
||||
},
|
||||
).then((done) => (done === undefined ? undefined : true)),
|
||||
r.checked ? `Created and checked out ${name}` : `Created branch ${name}`,
|
||||
{
|
||||
undo: tx`delete branch ${name}`,
|
||||
|
||||
@@ -42,18 +42,20 @@ export interface PromptOptions {
|
||||
okLabel?: string;
|
||||
mono?: boolean;
|
||||
/** Returns an error message, or null when valid. */
|
||||
validate?: (value: string) => string | null;
|
||||
validate?: (value: string, overwrite: boolean) => string | null;
|
||||
/** Rewrites the field as the user types or pastes (e.g. spaces in a branch name). */
|
||||
sanitize?: (value: string) => string;
|
||||
/** Show the prefilled value's problem (e.g. the branch exists) before it is edited. */
|
||||
validateInitial?: boolean;
|
||||
/** Optional checkbox below the field. */
|
||||
checkbox?: { label: string; initial: boolean };
|
||||
overwriteBranch?: (value: string) => boolean;
|
||||
}
|
||||
|
||||
export interface PromptResult {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
export function promptText(opts: PromptOptions): Promise<PromptResult | null> {
|
||||
@@ -75,11 +77,13 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null)
|
||||
const { ref, clean } = useSanitizedField(sanitize);
|
||||
const [value, setValue] = useState(initial);
|
||||
const [checked, setChecked] = useState(checkbox?.initial ?? false);
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [touched, setTouched] = useState(!!props.validateInitial && initial !== "");
|
||||
const error = validate ? validate(value) : value.trim() ? null : "Value must not be empty";
|
||||
const canOverwrite = props.overwriteBranch?.(value) ?? false;
|
||||
const error = validate ? validate(value, canOverwrite && overwrite) : value.trim() ? null : "Value must not be empty";
|
||||
const submit = () => {
|
||||
setTouched(true);
|
||||
if (!error) onDone({ value: value.trim(), checked });
|
||||
if (!error) onDone({ value: value.trim(), checked, ...(canOverwrite ? { overwrite } : {}) });
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
@@ -114,11 +118,14 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null)
|
||||
invalid={touched && !!error}
|
||||
onChange={(e) => {
|
||||
setTouched(true);
|
||||
setValue(clean(e));
|
||||
const next = clean(e);
|
||||
if (next.trim() !== value.trim()) setOverwrite(false);
|
||||
setValue(next);
|
||||
}}
|
||||
/>
|
||||
{touched && error && value && <div className={styles.error}>{error}</div>}
|
||||
{checkbox && <Checkbox checked={checked} onChange={setChecked} label={checkbox.label} />}
|
||||
{canOverwrite && <Checkbox checked={overwrite} onChange={setOverwrite} label="Overwrite existing branch" />}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -445,7 +445,7 @@ export function registerHistoryActions(): () => void {
|
||||
category: "Git Log",
|
||||
icon: "branch",
|
||||
when: VCS,
|
||||
run: withCommit((c) => newBranch(c.id)),
|
||||
run: withCommit((c) => newBranch(c.id, commitRepo(c) ?? currentRepo())),
|
||||
},
|
||||
{
|
||||
id: "vcs.log.newTag",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"Overwrite existing branch": "Overwrite existing branch",
|
||||
"Branch '{n}' already exists": "Branch '{n}' already exists",
|
||||
"Cannot copy reference": "Cannot copy reference",
|
||||
"Decompiled assembly (read-only).": "Decompiled assembly (read-only).",
|
||||
"Connect through:": "Connect through:",
|
||||
|
||||
@@ -448,6 +448,8 @@
|
||||
"Checkout Tag or Revision": "签出标签或修订",
|
||||
"Checkout Tag or Revision…": "签出标签或修订…",
|
||||
"Checkout branch": "签出分支",
|
||||
"Overwrite existing branch": "覆盖现有分支",
|
||||
"Branch '{n}' already exists": "分支 '{n}' 已存在",
|
||||
"Checks out the selected pull request, a number given as argument, or asks for one.": "签出所选拉取请求或参数指定编号的拉取请求,否则询问编号。",
|
||||
"Cherry-Pick": "拣选",
|
||||
"Children": "子项",
|
||||
|
||||
Reference in new issue
Заблокувати користувача