2b5d7268ed
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.
208 lines
6.9 KiB
TypeScript
208 lines
6.9 KiB
TypeScript
/**
|
|
* @file RebaseDialog.tsx
|
|
* @description "Rebase current branch onto…": pick a local or fetched remote
|
|
* branch, see the plan before anything runs (GET /git/rewrite-plan
|
|
* kind=rebase — how many commits are replayed, how many are already pushed and
|
|
* so will need a force-push, how far behind the branch is), then `rebase`.
|
|
* A conflict closes the dialog: the Changes panel's banner then offers
|
|
* Continue / Skip / Abort and the merge editor resolves the files. A clean
|
|
* rebase stays open on its result with **Undo**.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api, type GitTarget } from "../../../lib/api";
|
|
import { errText, gitRewrite, type RebasePlan, type RewriteResult } from "../../../lib/gitRewrite";
|
|
import { toast } from "../../../lib/toast";
|
|
import { BTN, BTN_PRIMARY, ErrorLine, GitModal, Warn } from "./GitModal";
|
|
import { RewriteDone } from "./RewriteDone";
|
|
|
|
/** Why a rewrite cannot start, from the plan's common facts — or null. */
|
|
export function blockedReason(
|
|
t: (k: string, o?: Record<string, unknown>) => string,
|
|
p: { subdir: boolean; operation: string | null; dirty: boolean },
|
|
needsClean = true
|
|
): string | null {
|
|
if (p.subdir) return t("blocked.subdir");
|
|
if (p.operation) return t("blocked.operation", { op: p.operation });
|
|
if (needsClean && p.dirty) return t("blocked.dirty");
|
|
return null;
|
|
}
|
|
|
|
export function RebaseDialog({
|
|
target,
|
|
current,
|
|
onClose,
|
|
onChanged,
|
|
}: {
|
|
target: GitTarget;
|
|
current: string | null;
|
|
onClose: () => void;
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation(["gitTools"]);
|
|
const [refs, setRefs] = useState<string[]>([]);
|
|
const [onto, setOnto] = useState("");
|
|
const [plan, setPlan] = useState<RebasePlan | null>(null);
|
|
const [force, setForce] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [result, setResult] = useState<RewriteResult | null>(null);
|
|
const key = JSON.stringify(target);
|
|
|
|
useEffect(() => {
|
|
api.lanes
|
|
.gitBranches(target)
|
|
.then((b) => {
|
|
const list = [...(b.branches ?? []), ...(b.remotes ?? [])].filter((x) => x !== current);
|
|
setRefs(list);
|
|
})
|
|
.catch((e) => setError(errText(e)));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key]);
|
|
|
|
useEffect(() => {
|
|
setPlan(null);
|
|
setForce(false);
|
|
if (!onto) return;
|
|
let live = true;
|
|
gitRewrite
|
|
.planRebase(target, onto)
|
|
.then((p) => live && setPlan(p))
|
|
.catch((e) => live && setError(errText(e)));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key, onto]);
|
|
|
|
const blocked = plan ? blockedReason(t, plan) : null;
|
|
const run = async () => {
|
|
if (!plan) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const r = await gitRewrite.run(target, "rebase", {
|
|
onto,
|
|
head: plan.head,
|
|
...(plan.pushed ? { force: true } : {}),
|
|
});
|
|
onChanged();
|
|
if (r.conflicts?.length || r.stopped) {
|
|
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
|
|
onClose();
|
|
return;
|
|
}
|
|
toast.success(t("rebase.done", { onto }));
|
|
setResult(r);
|
|
} catch (e) {
|
|
setError(errText(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<GitModal
|
|
title={t("rebase.title", { branch: current ?? "HEAD" })}
|
|
onClose={onClose}
|
|
testId="rebase-dialog"
|
|
footer={
|
|
result ? (
|
|
<button type="button" className={BTN} onClick={onClose}>
|
|
{t("close")}
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button type="button" className={BTN} onClick={onClose}>
|
|
{t("cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="rebase-run"
|
|
className={BTN_PRIMARY}
|
|
disabled={
|
|
busy || !plan || !!blocked || !plan.commits.length || (plan.pushed > 0 && !force)
|
|
}
|
|
onClick={() => void run()}
|
|
>
|
|
{t("rebase.run")}
|
|
</button>
|
|
</>
|
|
)
|
|
}
|
|
>
|
|
{result ? (
|
|
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
|
) : (
|
|
<>
|
|
<label className="mb-1 block text-[11px]" htmlFor="rebase-onto">
|
|
{t("rebase.onto")}
|
|
</label>
|
|
<select
|
|
id="rebase-onto"
|
|
data-testid="rebase-onto"
|
|
value={onto}
|
|
onChange={(e) => setOnto(e.target.value)}
|
|
className="input mb-2 w-full py-1 text-[11px]"
|
|
>
|
|
<option value="">{t("rebase.pick")}</option>
|
|
{refs.map((r) => (
|
|
<option key={r} value={r}>
|
|
{r}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="mb-1 text-[11px] text-fg-muted">{t("rebase.explain")}</p>
|
|
{plan && (
|
|
<div data-testid="rebase-plan" className="space-y-1">
|
|
{blocked ? (
|
|
<Warn>{blocked}</Warn>
|
|
) : !plan.commits.length ? (
|
|
<p>{t("rebase.nothing", { onto })}</p>
|
|
) : (
|
|
<>
|
|
<p>
|
|
{t("rebase.summary", {
|
|
count: plan.commits.length,
|
|
onto,
|
|
behind: plan.behind,
|
|
})}
|
|
</p>
|
|
<ul className="max-h-40 overflow-y-auto rounded border border-border-light">
|
|
{plan.commits.map((c) => (
|
|
<li key={c.sha} className="flex gap-1.5 px-1.5 py-0.5 text-[11px]">
|
|
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
|
|
<span className="min-w-0 flex-1 truncate text-fg-primary">{c.subject}</span>
|
|
{c.pushed && (
|
|
<span className="shrink-0 text-status-warning">{t("pushed")}</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{plan.pushed > 0 && (
|
|
<Warn testId="rebase-pushed">
|
|
{t("rewritesPushed", { count: plan.commits.length, pushed: plan.pushed })}
|
|
<label className="mt-1 flex items-center gap-1.5">
|
|
<input
|
|
type="checkbox"
|
|
data-testid="rebase-force"
|
|
checked={force}
|
|
onChange={(e) => setForce(e.target.checked)}
|
|
/>
|
|
{t("forceAck")}
|
|
</label>
|
|
</Warn>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
<ErrorLine error={error} />
|
|
</>
|
|
)}
|
|
</GitModal>
|
|
);
|
|
}
|