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.
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
/**
|
|
* @file InteractiveRebase.tsx
|
|
* @description Interactive rebase as a list (JetBrains' "Interactively Rebase
|
|
* from Here"): the branch's commits since a base — by default the ones no
|
|
* remote has yet, oldest first — each with Pick / Reword / Squash / Fixup /
|
|
* Drop, moved up and down, a new message for a reword or a squash. Before it
|
|
* runs it says what it rewrites ("This rewrites 4 commits; 2 are already
|
|
* pushed, so a force-push will be needed") and pushed commits need an explicit
|
|
* tick; Start is a two-click (`useArmed`). The server builds the todo and runs
|
|
* `git rebase -i` with no editor; a clean run stays open with **Undo**.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
|
import type { GitTarget } from "../../../lib/api";
|
|
import {
|
|
errText,
|
|
gitRewrite,
|
|
type InteractivePlan,
|
|
type PlanCommit,
|
|
type RewriteResult,
|
|
type TodoAction,
|
|
} from "../../../lib/gitRewrite";
|
|
import { toast } from "../../../lib/toast";
|
|
import { useArmed } from "../../../hooks/useArmed";
|
|
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
|
import { RewriteDone } from "./RewriteDone";
|
|
import { blockedReason } from "./RebaseDialog";
|
|
|
|
const ACTIONS: TodoAction[] = ["pick", "reword", "squash", "fixup", "drop"];
|
|
|
|
interface Row {
|
|
commit: PlanCommit;
|
|
action: TodoAction;
|
|
message: string;
|
|
}
|
|
|
|
/** Why the list cannot run as it stands, or null. */
|
|
export function todoProblem(rows: Row[]): "first" | "allDropped" | "unchanged" | "reword" | null {
|
|
const kept = rows.filter((r) => r.action !== "drop");
|
|
if (!kept.length) return "allDropped";
|
|
if (kept[0]!.action === "squash" || kept[0]!.action === "fixup") return "first";
|
|
if (rows.some((r) => r.action === "reword" && !r.message.trim())) return "reword";
|
|
return null;
|
|
}
|
|
|
|
export function InteractiveRebase({
|
|
target,
|
|
base,
|
|
onClose,
|
|
onChanged,
|
|
}: {
|
|
target: GitTarget;
|
|
/** Rewrite the commits after this one; absent = the unpushed ones. */
|
|
base?: string;
|
|
onClose: () => void;
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation(["gitTools"]);
|
|
const [plan, setPlan] = useState<InteractivePlan | null>(null);
|
|
const [rows, setRows] = useState<Row[]>([]);
|
|
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 { isArmed, arm, disarm } = useArmed();
|
|
const key = JSON.stringify(target);
|
|
|
|
useEffect(() => {
|
|
let live = true;
|
|
gitRewrite
|
|
.planInteractive(target, base)
|
|
.then((p) => {
|
|
if (!live) return;
|
|
setPlan(p);
|
|
setRows(p.commits.map((c) => ({ commit: c, action: "pick", message: c.message })));
|
|
})
|
|
.catch((e) => live && setError(errText(e)));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key, base]);
|
|
|
|
const set = (i: number, patch: Partial<Row>) =>
|
|
setRows((prev) => prev.map((r, n) => (n === i ? { ...r, ...patch } : r)));
|
|
const move = (i: number, step: number) =>
|
|
setRows((prev) => {
|
|
const j = i + step;
|
|
if (j < 0 || j >= prev.length) return prev;
|
|
const next = [...prev];
|
|
[next[i], next[j]] = [next[j]!, next[i]!];
|
|
return next;
|
|
});
|
|
|
|
const blocked = plan ? blockedReason(t, plan) : null;
|
|
const problem = todoProblem(rows);
|
|
const unchanged =
|
|
!!plan && rows.every((r, i) => r.action === "pick" && r.commit.sha === plan.commits[i]?.sha);
|
|
const pushed = plan?.pushed ?? 0;
|
|
|
|
const run = async () => {
|
|
if (!plan) return;
|
|
if (!isArmed("run")) return arm("run");
|
|
disarm();
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const r = await gitRewrite.run(target, "rebase-interactive", {
|
|
head: plan.head,
|
|
...(base ? { base: plan.base } : {}),
|
|
confirm: true,
|
|
...(pushed ? { force: true } : {}),
|
|
todo: rows.map((row) => ({
|
|
sha: row.commit.sha,
|
|
action: row.action,
|
|
...((row.action === "reword" || row.action === "squash") &&
|
|
row.message.trim() &&
|
|
row.message.trim() !== row.commit.message
|
|
? { message: row.message }
|
|
: row.action === "reword"
|
|
? { message: row.message }
|
|
: {}),
|
|
})),
|
|
});
|
|
onChanged();
|
|
if (r.conflicts?.length || r.stopped) {
|
|
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
|
|
onClose();
|
|
return;
|
|
}
|
|
toast.success(t("interactive.done"));
|
|
setResult(r);
|
|
} catch (e) {
|
|
setError(errText(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<GitModal
|
|
title={t("interactive.title")}
|
|
onClose={onClose}
|
|
testId="interactive-rebase"
|
|
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="interactive-run"
|
|
className={BTN_DANGER}
|
|
disabled={
|
|
busy || !plan || !!blocked || !!problem || unchanged || (pushed > 0 && !force)
|
|
}
|
|
onClick={() => void run()}
|
|
>
|
|
{isArmed("run") ? t("interactive.runConfirm") : t("interactive.run")}
|
|
</button>
|
|
</>
|
|
)
|
|
}
|
|
>
|
|
{result ? (
|
|
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
|
) : !plan ? (
|
|
error ? (
|
|
<ErrorLine error={error} />
|
|
) : (
|
|
<p className="text-fg-muted">{t("loading")}</p>
|
|
)
|
|
) : (
|
|
<>
|
|
<p className="mb-1.5 text-[11px] text-fg-muted">{t("interactive.explain")}</p>
|
|
{blocked && <Warn>{blocked}</Warn>}
|
|
{plan.merges && <Warn>{t("interactive.merges")}</Warn>}
|
|
{!rows.length ? (
|
|
<p data-testid="interactive-empty">{t("interactive.nothing")}</p>
|
|
) : (
|
|
<>
|
|
<Warn testId="interactive-summary">
|
|
{pushed
|
|
? t("rewritesPushed", { count: rows.length, pushed })
|
|
: t("rewrites", { count: rows.length })}
|
|
{pushed > 0 && (
|
|
<label className="mt-1 flex items-center gap-1.5">
|
|
<input
|
|
type="checkbox"
|
|
data-testid="interactive-force"
|
|
checked={force}
|
|
onChange={(e) => setForce(e.target.checked)}
|
|
/>
|
|
{t("forceAck")}
|
|
</label>
|
|
)}
|
|
</Warn>
|
|
<ol data-testid="interactive-list" className="space-y-1">
|
|
{rows.map((r, i) => (
|
|
<li
|
|
key={r.commit.sha}
|
|
data-testid="interactive-row"
|
|
className={`rounded border border-border-light p-1.5 ${
|
|
r.action === "drop" ? "opacity-50" : ""
|
|
}`}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<select
|
|
aria-label={t("interactive.action", { short: r.commit.short })}
|
|
data-testid="interactive-action"
|
|
value={r.action}
|
|
onChange={(e) => set(i, { action: e.target.value as TodoAction })}
|
|
className="input py-0.5 text-[11px]"
|
|
>
|
|
{ACTIONS.map((a) => (
|
|
<option key={a} value={a}>
|
|
{t(`interactive.actions.${a}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
|
{r.commit.short}
|
|
</span>
|
|
<span
|
|
className={`min-w-0 flex-1 truncate text-[11px] text-fg-primary ${
|
|
r.action === "drop" ? "line-through" : ""
|
|
}`}
|
|
title={r.commit.subject}
|
|
>
|
|
{r.commit.subject}
|
|
</span>
|
|
{r.commit.pushed && (
|
|
<span className="shrink-0 text-[10px] text-status-warning">
|
|
{t("pushed")}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
aria-label={t("interactive.up")}
|
|
disabled={i === 0}
|
|
onClick={() => move(i, -1)}
|
|
>
|
|
<ArrowUp className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
aria-label={t("interactive.down")}
|
|
disabled={i === rows.length - 1}
|
|
onClick={() => move(i, 1)}
|
|
>
|
|
<ArrowDown className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
{(r.action === "reword" || r.action === "squash") && (
|
|
<textarea
|
|
data-testid="interactive-message"
|
|
aria-label={t("interactive.message")}
|
|
value={r.message}
|
|
rows={3}
|
|
onChange={(e) => set(i, { message: e.target.value })}
|
|
placeholder={
|
|
r.action === "squash" ? t("interactive.squashHint") : undefined
|
|
}
|
|
className="input mt-1 w-full font-mono text-[11px]"
|
|
/>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
{problem && (
|
|
<p
|
|
data-testid="interactive-problem"
|
|
className="mt-1 text-[11px] text-status-danger"
|
|
>
|
|
{t(`interactive.problem.${problem}`)}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
<ErrorLine error={error} />
|
|
</>
|
|
)}
|
|
</GitModal>
|
|
);
|
|
}
|