feat(git): 3-way merge editor, reflog, rebase/interactive rebase, reset with undo, force-with-lease push, remote branches and remotes
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease, remote-branch-delete, remote add/rename/remove) and git-conflicts.js (conflict stages read + conflict-save, reflog), dispatched from runGitAction; operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes, /git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView, RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog, rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @file ForcePushDialog.tsx
|
||||
* @description "Force push (with lease)" for the CURRENT branch only: shows
|
||||
* where it goes (remote and branch), the remote commit it will replace (the
|
||||
* lease — the server refuses with ESTALE if the remote moved since the last
|
||||
* fetch), how far ahead/behind HEAD is, and refuses a protected branch unless
|
||||
* the server allows it. Push is a two-click (`useArmed`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errCode, errText, gitRewrite, type PushPlan } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
|
||||
export function ForcePushDialog({
|
||||
target,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<PushPlan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planPush(target)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
if (!isArmed("push")) return arm("push");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "force-push", { expect: plan.remoteSha ?? "", confirm: true });
|
||||
toast.success(t("forcePush.done", { remote: plan.remote, branch: plan.remoteBranch }));
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errCode(e) === "ESTALE" ? t("forcePush.stale") : errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("forcePush.title")}
|
||||
onClose={onClose}
|
||||
testId="force-push-dialog"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="force-push-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={busy || !plan || !plan.allowed}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{isArmed("push") ? t("forcePush.runConfirm") : t("forcePush.run")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p data-testid="force-push-where" className="break-words">
|
||||
{t("forcePush.where", {
|
||||
branch: plan.branch,
|
||||
remote: plan.remote,
|
||||
remoteBranch: plan.remoteBranch,
|
||||
})}
|
||||
</p>
|
||||
<p className="break-words">
|
||||
{plan.remoteSha
|
||||
? t("forcePush.replaces", {
|
||||
sha: plan.remoteSha.slice(0, 7),
|
||||
ahead: plan.ahead,
|
||||
behind: plan.behind,
|
||||
})
|
||||
: t("forcePush.newBranch")}
|
||||
</p>
|
||||
{plan.behind > 0 && <Warn>{t("forcePush.dropsRemote", { count: plan.behind })}</Warn>}
|
||||
{!plan.allowed && <Warn testId="force-push-protected">{t("forcePush.protected")}</Warn>}
|
||||
<p className="text-[11px] text-fg-muted">{t("forcePush.lease")}</p>
|
||||
<ErrorLine error={error} />
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user