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:
2026-11-02 12:04:02 +07:00
parent 2cc492bb88
commit 2b5d7268ed
35 changed files with 5055 additions and 48 deletions
@@ -150,6 +150,7 @@ import { HtmlViewerModal } from "../HtmlViewerModal";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { LaneShelves, withAgentConfirm } from "./LaneShelves";
import { useGitChanged } from "../../lib/gitChanged";
import { GitTools, GitToolsButton, RebaseSkipButton, type GitTool } from "./git/GitTools";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
@@ -869,6 +870,8 @@ export function LaneChangesPanel({
const opOpen = !!(git?.available && git.merging);
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session, source);
// The open history-rewrite / remote / merge-editor tool (./git/GitTools).
const [gitTool, setGitTool] = useState<GitTool | null>(null);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback(
(text: string) => {
@@ -1881,10 +1884,38 @@ export function LaneChangesPanel({
{t("changes.undoCommit")}
</MenuItem>
)}
{/* History rewrites (./git): both open a dialog that asks first. */}
<MenuItem
disabled={busy || opOpen}
testId="lane-changes-menu-reset"
onSelect={pick(commitCtx.close, () =>
setGitTool({ kind: "reset", commit: commitCtx.menu!.item })
)}
>
{t("gitTools:menu.resetHere")}
</MenuItem>
<MenuItem
disabled={busy || opOpen || commits?.[0]?.sha === commitCtx.menu.item.sha}
testId="lane-changes-menu-interactive"
onSelect={pick(commitCtx.close, () =>
setGitTool({ kind: "interactive", base: commitCtx.menu!.item.sha })
)}
>
{t("gitTools:menu.interactiveHere")}
</MenuItem>
</>
)}
</ContextMenu>
)}
<GitTools
tool={gitTool}
target={target}
branch={git?.available ? git.branch : null}
onClose={() => setGitTool(null)}
onOpen={setGitTool}
onChanged={() => void rereadAll()}
onViewCommit={viewCommit}
/>
</>
);
@@ -2484,6 +2515,12 @@ export function LaneChangesPanel({
</div>
)}
{!notRepo && (
<GitToolsButton
onPick={setGitTool}
hasRemote={git?.available ? git.hasRemote : undefined}
/>
)}
<button
type="button"
onClick={runReread}
@@ -2710,6 +2747,13 @@ export function LaneChangesPanel({
>
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
</button>
{git.operation === "rebase" && (
<RebaseSkipButton
target={target}
disabled={busy}
onChanged={() => void rereadAll()}
/>
)}
<button
type="button"
disabled={busy}
@@ -3114,6 +3158,9 @@ export function LaneChangesPanel({
editWanted.current = true;
setWhole(true);
}}
onMergeEditor={() =>
setGitTool({ kind: "merge", path: openFile, onResolved: goBack })
}
/>
) : mode === "file" ? (
current || (whole && text) || openImage ? (
@@ -8,7 +8,8 @@
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
* `resolve done`, which re-checks that no marker is left. Take all mine /
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
* file to the panel's plain editor.
* file to the panel's plain editor; Merge editor opens the 3-way editor
* (`./git/MergeEditor`, base/current/incoming side by side).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -42,7 +43,10 @@ export function LaneConflictView({
path,
onResolved,
onEdit,
onMergeEditor,
}: {
/** Open the 3-way merge editor (./git/MergeEditor) on this file. */
onMergeEditor?: () => void;
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
@@ -176,6 +180,17 @@ export function LaneConflictView({
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
{t("conflict.editByHand")}
</button>
{onMergeEditor && (
<button
type="button"
data-testid="conflict-open-merge-editor"
className={BTN}
disabled={busy}
onClick={onMergeEditor}
>
{t("gitTools:merge.open")}
</button>
)}
</div>
{error && (
@@ -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>
);
}
@@ -0,0 +1,111 @@
/**
* @file GitModal.tsx
* @description The shared modal shell of the Changes panel's git tools (merge
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
* portal over the screen with `useDialog`'s focus contract, a title row with a
* close button, a scrolling body and an optional footer. `wide` widens it for
* the three-pane merge editor; on a phone every size is the full width.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useDialog } from "../../../hooks/useDialog";
export const BTN =
"rounded border border-border-light px-2 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
export const BTN_PRIMARY =
"rounded border border-accent-solid bg-accent-solid px-2 py-1 text-[11px] font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40";
export const BTN_DANGER =
"rounded border border-status-danger/60 px-2 py-1 text-[11px] font-medium text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40";
export function GitModal({
title,
onClose,
testId,
wide = false,
footer,
children,
}: {
title: ReactNode;
onClose: () => void;
testId?: string;
wide?: boolean;
footer?: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation(["gitTools"]);
const panel = useRef<HTMLDivElement>(null);
const titleId = useId();
useDialog(true, panel, onClose);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-2 sm:p-3"
onClick={onClose}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid={testId}
onClick={(e) => e.stopPropagation()}
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
wide ? "max-w-6xl" : "max-w-lg"
}`}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<h2
id={titleId}
className="min-w-0 flex-1 truncate text-sm font-semibold text-fg-primary"
>
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t("close")}
className="shrink-0 rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-2 text-xs text-fg-secondary">
{children}
</div>
{footer && (
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border px-3 py-2">
{footer}
</div>
)}
</div>
</div>,
document.body
);
}
/** A red inline error line, or nothing. */
export function ErrorLine({ error }: { error: string | null }) {
if (!error) return null;
return (
<p role="alert" className="my-1 whitespace-pre-wrap break-words text-[11px] text-status-danger">
{error}
</p>
);
}
/** An amber explanation box shown before a rewrite runs. */
export function Warn({ children, testId }: { children: ReactNode; testId?: string }) {
return (
<div
data-testid={testId}
className="my-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-[11px] text-status-warning"
>
{children}
</div>
);
}
@@ -0,0 +1,210 @@
/**
* @file GitTools.tsx
* @description The Changes panel's entry points to the history-rewrite and
* remote tools: `GitToolsButton` (a toolbar menu — Rebase onto…, Interactive
* rebase…, Reflog, Force push…, Remotes…), `GitTools` (renders whichever tool
* is open, so the panel holds one `GitTool | null` state), and
* `RebaseSkipButton` (the rebase banner's Skip — Continue and Abort are the
* banner's own Complete/Abort, which the server maps onto the rebase).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { History } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { ContextMenu, MenuItem, MenuSeparator } from "../../ContextMenu";
import { ForcePushDialog } from "./ForcePushDialog";
import { InteractiveRebase } from "./InteractiveRebase";
import { MergeEditor } from "./MergeEditor";
import { RebaseDialog } from "./RebaseDialog";
import { ReflogView } from "./ReflogView";
import { RemotesDialog } from "./RemotesDialog";
import { ResetDialog } from "./ResetDialog";
export type GitTool =
| { kind: "rebase" }
| { kind: "interactive"; base?: string }
| { kind: "reset"; commit: { sha: string; short?: string; subject?: string } }
| { kind: "reflog" }
| { kind: "force-push" }
| { kind: "remotes" }
| { kind: "merge"; path: string; onResolved?: () => void };
export function GitToolsButton({
onPick,
disabled,
hasRemote,
}: {
onPick: (tool: GitTool) => void;
disabled?: boolean;
hasRemote?: boolean;
}) {
const { t } = useTranslation(["gitTools"]);
const ref = useRef<HTMLButtonElement>(null);
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
const pick = (tool: GitTool) => () => {
setAt(null);
onPick(tool);
};
return (
<>
<button
ref={ref}
type="button"
data-testid="git-tools-button"
aria-label={t("menu.label")}
title={t("menu.label")}
aria-haspopup="menu"
disabled={disabled}
onClick={() => {
const r = ref.current?.getBoundingClientRect();
setAt(r ? { x: r.left, y: r.bottom + 2 } : { x: 0, y: 0 });
}}
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary disabled:opacity-40"
>
<History className="h-3.5 w-3.5" />
</button>
{at && (
<ContextMenu x={at.x} y={at.y} onClose={() => setAt(null)} testId="git-tools-menu">
<MenuItem testId="git-tools-rebase" onSelect={pick({ kind: "rebase" })}>
{t("menu.rebase")}
</MenuItem>
<MenuItem testId="git-tools-interactive" onSelect={pick({ kind: "interactive" })}>
{t("menu.interactive")}
</MenuItem>
<MenuItem testId="git-tools-reflog" onSelect={pick({ kind: "reflog" })}>
{t("menu.reflog")}
</MenuItem>
<MenuSeparator />
<MenuItem
testId="git-tools-force-push"
danger
disabled={hasRemote === false}
onSelect={pick({ kind: "force-push" })}
>
{t("menu.forcePush")}
</MenuItem>
<MenuItem testId="git-tools-remotes" onSelect={pick({ kind: "remotes" })}>
{t("menu.remotes")}
</MenuItem>
</ContextMenu>
)}
</>
);
}
export function GitTools({
tool,
target,
branch,
onClose,
onOpen,
onChanged,
onViewCommit,
}: {
tool: GitTool | null;
target: GitTarget;
/** The current branch, for titles. */
branch: string | null;
onClose: () => void;
/** Open another tool (the reflog's "Reset here"). */
onOpen: (tool: GitTool) => void;
/** Re-read the panel's git facts after a write. */
onChanged: () => void;
onViewCommit?: (sha: string) => void;
}) {
if (!tool) return null;
switch (tool.kind) {
case "rebase":
return (
<RebaseDialog target={target} current={branch} onClose={onClose} onChanged={onChanged} />
);
case "interactive":
return (
<InteractiveRebase
target={target}
base={tool.base}
onClose={onClose}
onChanged={onChanged}
/>
);
case "reset":
return (
<ResetDialog target={target} commit={tool.commit} onClose={onClose} onChanged={onChanged} />
);
case "reflog":
return (
<ReflogView
target={target}
onClose={onClose}
onViewCommit={
onViewCommit &&
((sha) => {
onClose();
onViewCommit(sha);
})
}
onReset={(e) =>
onOpen({ kind: "reset", commit: { sha: e.sha, short: e.short, subject: e.subject } })
}
/>
);
case "force-push":
return <ForcePushDialog target={target} onClose={onClose} onChanged={onChanged} />;
case "remotes":
return <RemotesDialog target={target} onClose={onClose} onChanged={onChanged} />;
case "merge":
return (
<MergeEditor
target={target}
path={tool.path}
onClose={onClose}
onResolved={() => {
onChanged();
tool.onResolved?.();
}}
/>
);
}
}
/** The rebase banner's Skip: drops the commit the rebase stopped on. */
export function RebaseSkipButton({
target,
disabled,
onChanged,
}: {
target: GitTarget;
disabled?: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [busy, setBusy] = useState(false);
return (
<button
type="button"
data-testid="rebase-skip"
title={t("rebaseBanner.skipHint")}
disabled={disabled || busy}
onClick={async () => {
setBusy(true);
try {
const r = await gitRewrite.run(target, "rebase-skip", {});
if (r.conflicts?.length) toast.error(t("rebase.stopped", { count: r.conflicts.length }));
else if (!r.stopped) toast.success(t("rebaseBanner.finished"));
} catch (e) {
toast.error(errText(e));
} finally {
setBusy(false);
onChanged();
}
}}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("rebaseBanner.skip")}
</button>
);
}
@@ -0,0 +1,295 @@
/**
* @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>
);
}
@@ -0,0 +1,444 @@
/**
* @file MergeEditor.tsx
* @description The 3-way merge editor for ONE conflicted file — a merge,
* cherry-pick, revert or rebase (VS Code's / JetBrains' layout): Incoming
* (theirs, git stage :3) | Result | Current (ours, stage :2), with the common
* base (stage :1) one click away. Each conflict block of the result takes
* Accept current / Accept incoming / Accept both (either order) / a hand edit,
* undoable; next/previous conflict and "N conflicts left"; or the whole result
* edited as text. Save writes the result through the `conflict-save` action —
* the whole-file editor's own guards (baseSha → 409 ESTALE, secret refusal)
* then `git add` — which the server refuses while a marker is left.
* In a rebase git's "ours" is the branch being rebased ONTO and "theirs" the
* commit being replayed, so the labels say that rather than "mine".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { parseConflicts, type ConflictPart } from "../../../lib/conflicts";
import { errCode, errText, gitRewrite, type ConflictSides } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
export type BlockChoice =
{ kind: "ours" | "theirs" | "ours-theirs" | "theirs-ours" } | { kind: "custom"; text: string };
type Block = Extract<ConflictPart, { kind: "conflict" }>;
/** The lines a block resolves to, or null while it is unresolved. */
export function blockLines(p: Block, c: BlockChoice | undefined): string[] | null {
if (!c) return null;
if (c.kind === "custom") return c.text === "" ? [] : c.text.split("\n");
if (c.kind === "ours") return p.ours;
if (c.kind === "theirs") return p.theirs;
if (c.kind === "ours-theirs") return [...p.ours, ...p.theirs];
return [...p.theirs, ...p.ours];
}
/** The result file: resolved blocks as chosen, unresolved ones with their markers. */
export function composeResult(parts: ConflictPart[], choices: (BlockChoice | undefined)[]): string {
const out: string[] = [];
let i = 0;
for (const p of parts) {
if (p.kind === "text") {
out.push(...p.lines);
continue;
}
const lines = blockLines(p, choices[i++]);
if (lines) out.push(...lines);
else
out.push(
`<<<<<<< ${p.oursLabel}`.trimEnd(),
...p.ours,
"=======",
...p.theirs,
`>>>>>>> ${p.theirsLabel}`.trimEnd()
);
}
return out.join("\n");
}
function Pane({
title,
text,
testId,
tone,
}: {
title: string;
text: string | null;
testId: string;
tone: "incoming" | "current" | "base";
}) {
const { t } = useTranslation(["gitTools"]);
const head =
tone === "incoming"
? "bg-accent/20 text-accent"
: tone === "current"
? "bg-status-success/20 text-status-success"
: "bg-surface-3 text-fg-secondary";
return (
<section
data-testid={testId}
className="flex min-h-0 min-w-0 flex-col rounded border border-border-light"
>
<h3 className={`truncate px-2 py-1 text-[11px] font-semibold ${head}`} title={title}>
{title}
</h3>
<pre className="max-h-60 min-h-0 flex-1 overflow-auto whitespace-pre bg-surface-0 px-2 py-1 font-mono text-[11px] leading-5 text-fg-primary md:max-h-none">
{text == null ? <span className="italic text-fg-muted">{t("merge.sideGone")}</span> : text}
</pre>
</section>
);
}
export function MergeEditor({
target,
path,
onClose,
onResolved,
}: {
target: GitTarget;
path: string;
onClose: () => void;
/** The file was saved and marked resolved. */
onResolved: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [sides, setSides] = useState<ConflictSides | null>(null);
const [choices, setChoices] = useState<(BlockChoice | undefined)[]>([]);
const [editing, setEditing] = useState<{ i: number; text: string } | null>(null);
const [whole, setWhole] = useState<string | null>(null);
const [showBase, setShowBase] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [at, setAt] = useState(0);
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
const load = useCallback(() => {
let live = true;
setSides(null);
setChoices([]);
setEditing(null);
setWhole(null);
setError(null);
gitRewrite
.conflict(target, path)
.then((s) => live && setSides(s))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// `target` is rebuilt by the caller on every render; its parts are stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(target), path]);
useEffect(load, [load]);
const parts =
sides?.usable && sides.working != null ? parseConflicts(sides.working.split("\n")) : null;
const blocks = (parts ?? []).filter((p): p is Block => p.kind === "conflict");
const total = blocks.length;
const left = blocks.filter((_, i) => !choices[i]).length;
const rebase = sides?.operation === "rebase";
const incomingLabel = sides?.incoming
? t(rebase ? "merge.incomingRebase" : "merge.incoming", {
ref: `${sides.incoming.short} ${sides.incoming.subject}`,
})
: t("merge.incomingPlain");
const currentLabel = t(rebase ? "merge.currentRebase" : "merge.current", {
branch: sides?.branch ?? "HEAD",
});
const choose = (i: number, c: BlockChoice | undefined) =>
setChoices((prev) => {
const next = [...prev];
next[i] = c;
return next;
});
const jump = (step: number) => {
if (!total) return;
const open = blocks.map((_, i) => i).filter((i) => !choices[i]);
const pool = open.length ? open : blocks.map((_, i) => i);
const from = pool.findIndex((i) => i > at);
let i: number;
if (step > 0) i = from === -1 ? pool[0]! : pool[from]!;
else {
const before = pool.filter((x) => x < at);
i = before.length ? before[before.length - 1]! : pool[pool.length - 1]!;
}
setAt(i);
blockRefs.current[i]?.scrollIntoView?.({ block: "center" });
};
const save = async () => {
if (!sides?.sha || !parts) return;
const content = whole ?? composeResult(parts, choices);
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "conflict-save", { path, content, baseSha: sides.sha });
toast.success(t("merge.saved", { path }));
onResolved();
onClose();
} catch (e) {
const code = errCode(e);
setError(
code === "EMARKERS"
? t("merge.markersLeft")
: code === "ESTALE"
? t("merge.stale")
: errText(e)
);
} finally {
setBusy(false);
}
};
const choiceBtn = (i: number, c: BlockChoice, label: string, testId: string) => (
<button type="button" data-testid={testId} className={BTN} onClick={() => choose(i, c)}>
{label}
</button>
);
let blockIndex = -1;
const result = parts && (
<section
data-testid="merge-result"
className="flex min-h-0 min-w-0 flex-col rounded border border-accent/50"
>
<h3 className="truncate bg-surface-3 px-2 py-1 text-[11px] font-semibold text-fg-primary">
{t("merge.result")}
</h3>
{whole != null ? (
<textarea
data-testid="merge-whole"
aria-label={t("merge.result")}
value={whole}
onChange={(e) => setWhole(e.target.value)}
spellCheck={false}
className="input min-h-60 flex-1 resize-none whitespace-pre rounded-none border-0 font-mono text-[11px] leading-5"
/>
) : (
<div className="max-h-[50vh] min-h-0 flex-1 overflow-auto bg-surface-0 md:max-h-none">
{parts.map((p, n) => {
if (p.kind === "text")
return (
<pre
key={n}
className="whitespace-pre px-2 font-mono text-[11px] leading-5 text-fg-primary"
>
{p.lines.length ? p.lines.join("\n") : " "}
</pre>
);
const i = ++blockIndex;
const c = choices[i];
const lines = blockLines(p, c);
return (
<div
key={n}
ref={(el) => {
blockRefs.current[i] = el;
}}
data-testid="merge-block"
className={`my-1 border-y-2 ${
c ? "border-status-success/60" : "border-status-warning/70"
} ${at === i ? "ring-1 ring-accent" : ""}`}
>
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-1.5 py-1 text-[11px]">
<span className="mr-auto text-fg-muted">
{t("merge.blockOf", { n: i + 1, total })}
</span>
{editing?.i === i ? (
<button
type="button"
className={BTN}
onClick={() => {
choose(i, { kind: "custom", text: editing.text });
setEditing(null);
}}
>
{t("merge.editDone")}
</button>
) : c ? (
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
{t("merge.undoChoice")}
</button>
) : (
<>
{choiceBtn(
i,
{ kind: "ours" },
t("merge.acceptCurrent"),
"merge-accept-current"
)}
{choiceBtn(
i,
{ kind: "theirs" },
t("merge.acceptIncoming"),
"merge-accept-incoming"
)}
{choiceBtn(
i,
{ kind: "ours-theirs" },
t("merge.bothCurrentFirst"),
"merge-both-ct"
)}
{choiceBtn(
i,
{ kind: "theirs-ours" },
t("merge.bothIncomingFirst"),
"merge-both-ic"
)}
</>
)}
{editing?.i !== i && (
<button
type="button"
data-testid="merge-edit-block"
className={BTN}
onClick={() =>
setEditing({ i, text: (lines ?? [...p.ours, ...p.theirs]).join("\n") })
}
>
{t("merge.editBlock")}
</button>
)}
</div>
{editing?.i === i ? (
<textarea
data-testid="merge-block-text"
aria-label={t("merge.editBlock")}
value={editing.text}
onChange={(e) => setEditing({ i, text: e.target.value })}
spellCheck={false}
rows={Math.min(14, Math.max(3, editing.text.split("\n").length + 1))}
className="input w-full whitespace-pre rounded-none font-mono text-[11px] leading-5"
/>
) : lines ? (
<pre className="whitespace-pre bg-status-success/10 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{lines.length ? lines.join("\n") : " "}
</pre>
) : (
<>
<pre className="whitespace-pre border-l-4 border-status-success bg-status-success/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{p.ours.length ? p.ours.join("\n") : " "}
</pre>
<pre className="whitespace-pre border-l-4 border-accent bg-accent/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{p.theirs.length ? p.theirs.join("\n") : " "}
</pre>
</>
)}
</div>
);
})}
</div>
)}
</section>
);
return (
<GitModal
wide
testId="merge-editor"
title={t("merge.title", { path })}
onClose={onClose}
footer={
<>
<ErrorLine error={error} />
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="merge-save"
className={BTN_PRIMARY}
disabled={busy || !parts || !sides?.sha || (whole == null && left > 0)}
onClick={() => void save()}
>
{t("merge.save")}
</button>
</>
}
>
{!sides ? (
!error && <p className="text-fg-muted">{t("loading")}</p>
) : !sides.usable || !parts ? (
<p data-testid="merge-unusable" className="text-status-warning">
{sides.binary
? t("merge.binary")
: sides.tooBig
? t("merge.tooBig")
: sides.working == null
? t("merge.noWorking")
: t("merge.unbalanced")}
</p>
) : (
<div className="flex min-h-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<span
data-testid="merge-left"
className={`text-[11px] ${left ? "text-status-warning" : "text-status-success"}`}
>
{whole != null
? t("merge.wholeMode")
: left
? t("merge.left", { count: left, total })
: t("merge.allChosen")}
</span>
<button
type="button"
className={BTN}
aria-label={t("merge.prev")}
disabled={!total || whole != null}
onClick={() => jump(-1)}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className={BTN}
aria-label={t("merge.next")}
disabled={!total || whole != null}
onClick={() => jump(1)}
>
<ChevronDown className="h-3 w-3" />
</button>
<label className="ml-auto flex items-center gap-1 text-[11px]">
<input
type="checkbox"
checked={showBase}
onChange={(e) => setShowBase(e.target.checked)}
/>
{t("merge.showBase")}
</label>
<button
type="button"
data-testid="merge-whole-toggle"
className={BTN}
onClick={() => setWhole(whole == null ? composeResult(parts, choices) : null)}
>
{whole == null ? t("merge.editWhole") : t("merge.backToBlocks")}
</button>
</div>
{showBase && (
<Pane title={t("merge.base")} text={sides.base} testId="merge-base" tone="base" />
)}
<div className="grid min-h-0 grid-cols-1 gap-2 md:h-[60vh] md:grid-cols-3">
<Pane
title={incomingLabel}
text={sides.theirs}
testId="merge-incoming"
tone="incoming"
/>
{result}
<Pane title={currentLabel} text={sides.ours} testId="merge-current" tone="current" />
</div>
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,207 @@
/**
* @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>
);
}
@@ -0,0 +1,157 @@
/**
* @file ReflogView.tsx
* @description The read-only reflog of HEAD or of one local branch, paged
* (GET /git/reflog): each entry's selector, what moved it (commit, rebase,
* reset, checkout…), git's message, when, and the commit it points at — a
* click opens that commit in the Changes panel. "Reset here" hands the entry
* to the reset dialog, which is how a lost commit is brought back.
* @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 ReflogEntry } from "../../../lib/gitRewrite";
import { BTN, ErrorLine, GitModal } from "./GitModal";
const PAGE = 50;
function when(time: number | null, lang: string): string {
if (!time) return "";
try {
return new Date(time * 1000).toLocaleString(lang);
} catch {
return new Date(time * 1000).toISOString();
}
}
export function ReflogView({
target,
onClose,
onViewCommit,
onReset,
}: {
target: GitTarget;
onClose: () => void;
/** Open one commit (the panel's commit view). */
onViewCommit?: (sha: string) => void;
/** Open the reset dialog at this commit. */
onReset?: (entry: ReflogEntry) => void;
}) {
const { t, i18n } = useTranslation(["gitTools"]);
const [ref, setRef] = useState("HEAD");
const [branches, setBranches] = useState<string[]>([]);
const [entries, setEntries] = useState<ReflogEntry[] | null>(null);
const [more, setMore] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
api.lanes
.gitBranches(target)
.then((b) => setBranches(b.branches ?? []))
.catch(() => setBranches([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const page = async (skip: number) => {
setBusy(true);
setError(null);
try {
const r = await gitRewrite.reflog(target, { ref, skip, limit: PAGE });
setEntries((prev) => (skip && prev ? [...prev, ...r.entries] : r.entries));
setMore(r.more);
} catch (e) {
setError(errText(e));
if (!skip) setEntries([]);
} finally {
setBusy(false);
}
};
useEffect(() => {
setEntries(null);
void page(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, ref]);
return (
<GitModal title={t("reflog.title")} onClose={onClose} testId="reflog-view">
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<label className="text-[11px]" htmlFor="reflog-ref">
{t("reflog.of")}
</label>
<select
id="reflog-ref"
data-testid="reflog-ref"
value={ref}
onChange={(e) => setRef(e.target.value)}
className="input min-w-0 max-w-full flex-1 py-0.5 text-[11px]"
>
<option value="HEAD">HEAD</option>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
<p className="mb-1.5 text-[11px] text-fg-muted">{t("reflog.hint")}</p>
<ErrorLine error={error} />
{entries == null ? (
<p className="text-fg-muted">{t("loading")}</p>
) : !entries.length ? (
<p className="text-fg-muted">{t("reflog.empty")}</p>
) : (
<ul data-testid="reflog-list" className="divide-y divide-border">
{entries.map((e) => (
<li key={`${e.index}-${e.sha}`} className="flex flex-wrap items-start gap-1.5 py-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{e.selector}</span>
<span className="shrink-0 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
{e.action}
</span>
<span className="min-w-0 flex-1 break-words text-[11px] text-fg-primary">
{e.message || e.subject}
<span className="block text-[10px] text-fg-muted">
{when(e.time, i18n.language)}
</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{onViewCommit ? (
<button
type="button"
className="font-mono text-[11px] text-accent hover:underline"
title={e.subject}
onClick={() => onViewCommit(e.sha)}
>
{e.short}
</button>
) : (
<span className="font-mono text-[11px]" title={e.subject}>
{e.short}
</span>
)}
{onReset && (
<button type="button" className={BTN} onClick={() => onReset(e)}>
{t("reflog.resetHere")}
</button>
)}
</span>
</li>
))}
</ul>
)}
{more && (
<button
type="button"
data-testid="reflog-more"
disabled={busy}
className={`${BTN} mt-2 w-full`}
onClick={() => void page(entries?.length ?? 0)}
>
{t("reflog.more")}
</button>
)}
</GitModal>
);
}
@@ -0,0 +1,247 @@
/**
* @file RemotesDialog.tsx
* @description The repo's remotes: each one's URL (no password is ever
* shown — the server strips it) and default branch; add one (name + an
* https:// with no login, ssh:// or user@host:path URL — the server's
* allowlist), rename one, remove one (two clicks), and delete a branch ON a
* remote (two clicks; never its default branch nor main/master/develop —
* the server refuses those whatever this shows).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type GitRemote } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
const PROTECTED = new Set(["main", "master", "development", "develop"]);
export function RemotesDialog({
target,
onClose,
onChanged,
}: {
target: GitTarget;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [remotes, setRemotes] = useState<GitRemote[] | null>(null);
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [renaming, setRenaming] = useState<{ from: string; to: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
const load = useCallback(() => {
gitRewrite
.remotes(target)
.then((r) => setRemotes(r.remotes))
.catch((e) => setError(errText(e)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(load, [load]);
const act = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
setError(null);
try {
await fn();
toast.success(ok);
onChanged();
load();
return true;
} catch (e) {
setError(errText(e));
return false;
} finally {
setBusy(false);
}
};
const add = async () => {
const n = name.trim();
const u = url.trim();
if (
await act(
() => gitRewrite.run(target, "remote-add", { name: n, url: u }),
t("remotes.added", { name: n })
)
) {
setName("");
setUrl("");
}
};
const rename = async () => {
if (!renaming) return;
const { from, to } = renaming;
if (
await act(
() => gitRewrite.run(target, "remote-rename", { name: from, to: to.trim() }),
t("remotes.renamed", { from, to: to.trim() })
)
)
setRenaming(null);
};
const remove = (n: string) => {
if (!isArmed(`rm:${n}`)) return arm(`rm:${n}`);
disarm();
void act(
() => gitRewrite.run(target, "remote-remove", { name: n, confirm: true }),
t("remotes.removed", { name: n })
);
};
const deleteBranch = (ref: string) => {
if (!isArmed(`del:${ref}`)) return arm(`del:${ref}`);
disarm();
void act(
() => gitRewrite.run(target, "remote-branch-delete", { branch: ref, confirm: true }),
t("remotes.branchDeleted", { ref })
);
};
return (
<GitModal title={t("remotes.title")} onClose={onClose} testId="remotes-dialog">
{remotes == null ? (
!error && <p className="text-fg-muted">{t("loading")}</p>
) : !remotes.length ? (
<p className="mb-2">{t("remotes.none")}</p>
) : (
<ul data-testid="remotes-list" className="mb-3 space-y-2">
{remotes.map((r) => (
<li key={r.name} className="rounded border border-border-light p-1.5">
<div className="flex flex-wrap items-center gap-1.5">
{renaming?.from === r.name ? (
<input
aria-label={t("remotes.newName")}
data-testid="remote-rename-input"
value={renaming.to}
onChange={(e) => setRenaming({ from: r.name, to: e.target.value })}
className="input min-w-0 flex-1 py-0.5 font-mono text-[11px]"
/>
) : (
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-semibold text-fg-primary">
{r.name}
</span>
)}
{renaming?.from === r.name ? (
<>
<button
type="button"
className={BTN_PRIMARY}
disabled={busy || !renaming.to.trim()}
onClick={() => void rename()}
>
{t("remotes.rename")}
</button>
<button type="button" className={BTN} onClick={() => setRenaming(null)}>
{t("cancel")}
</button>
</>
) : (
<>
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setRenaming({ from: r.name, to: r.name })}
>
{t("remotes.rename")}
</button>
<button
type="button"
data-testid={`remote-remove-${r.name}`}
className={BTN_DANGER}
disabled={busy}
onClick={() => remove(r.name)}
>
{isArmed(`rm:${r.name}`) ? t("remotes.removeConfirm") : t("remotes.remove")}
</button>
</>
)}
</div>
<p className="mt-0.5 break-all font-mono text-[10px] text-fg-muted">{r.url}</p>
{r.defaultBranch && (
<p className="text-[10px] text-fg-muted">
{t("remotes.default", { branch: r.defaultBranch })}
</p>
)}
{!!r.branches?.length && (
<details className="mt-1">
<summary className="cursor-pointer text-[11px]">
{t("remotes.branches", { count: r.branches.length })}
</summary>
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto">
{r.branches.map((b) => {
const ref = `${r.name}/${b}`;
const locked = PROTECTED.has(b) || b === r.defaultBranch;
return (
<li key={b} className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{b}</span>
<button
type="button"
data-testid={`remote-branch-delete-${ref}`}
className={BTN_DANGER}
disabled={busy || locked}
title={locked ? t("remotes.branchLocked") : undefined}
onClick={() => deleteBranch(ref)}
>
{isArmed(`del:${ref}`)
? t("remotes.deleteBranchConfirm")
: t("remotes.deleteBranch")}
</button>
</li>
);
})}
</ul>
</details>
)}
</li>
))}
</ul>
)}
<form
onSubmit={(e) => {
e.preventDefault();
void add();
}}
className="space-y-1.5 rounded border border-border p-1.5"
>
<p className="text-[11px] font-medium text-fg-primary">{t("remotes.add")}</p>
<input
aria-label={t("remotes.name")}
placeholder={t("remotes.name")}
data-testid="remote-add-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input w-full font-mono text-[11px]"
/>
<input
aria-label={t("remotes.url")}
placeholder="https://host/owner/repo.git"
data-testid="remote-add-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input w-full font-mono text-[11px]"
/>
<p className="text-[10px] text-fg-muted">{t("remotes.urlHint")}</p>
<div className="flex justify-end">
<button
type="submit"
data-testid="remote-add-submit"
className={BTN_PRIMARY}
disabled={busy || !name.trim() || !url.trim()}
>
{t("remotes.addButton")}
</button>
</div>
</form>
<ErrorLine error={error} />
</GitModal>
);
}
@@ -0,0 +1,211 @@
/**
* @file ResetDialog.tsx
* @description "Reset current branch to here": the plan first (GET
* /git/rewrite-plan kind=reset — how many commits leave or join the branch and
* how many of those leaving are already pushed), then one of four modes, each
* explained: soft and mixed never touch the working tree, keep refuses to
* overwrite a local change, and hard runs only on a clean tree with the branch
* name typed back. Pushed commits need an explicit tick (a force-push will be
* needed). A done reset 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 type { GitTarget } from "../../../lib/api";
import {
errText,
gitRewrite,
type ResetMode,
type ResetPlan,
type RewriteResult,
} from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
import { blockedReason } from "./RebaseDialog";
const MODES: ResetMode[] = ["soft", "mixed", "keep", "hard"];
export function ResetDialog({
target,
commit,
onClose,
onChanged,
}: {
target: GitTarget;
commit: { sha: string; short?: string; subject?: string };
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<ResetPlan | null>(null);
const [mode, setMode] = useState<ResetMode>("mixed");
const [force, setForce] = useState(false);
const [typed, setTyped] = useState("");
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(() => {
let live = true;
gitRewrite
.planReset(target, commit.sha)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, commit.sha]);
const branch = plan?.branch ?? "HEAD";
const blocked = plan ? blockedReason(t, plan, false) : null;
const hardBlocked = mode === "hard" && !!plan?.dirty;
const ready =
!!plan &&
!blocked &&
plan.related &&
plan.head !== plan.target &&
!(plan.pushed > 0 && !force) &&
!hardBlocked &&
(mode !== "hard" || typed === branch);
const run = async () => {
if (!plan) return;
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "reset", {
target: plan.target,
mode,
head: plan.head,
confirm: true,
...(plan.pushed ? { force: true } : {}),
...(mode === "hard" ? { confirmBranch: typed } : {}),
});
onChanged();
toast.success(t("reset.done", { short: commit.short ?? plan.target.slice(0, 7) }));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("reset.title", { branch, short: commit.short ?? commit.sha.slice(0, 7) })}
onClose={onClose}
testId="reset-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="reset-run"
className={BTN_DANGER}
disabled={busy || !ready}
onClick={() => void run()}
>
{t("reset.run", { mode })}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : !plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<>
{commit.subject && (
<p className="mb-1 truncate text-[11px] text-fg-primary" title={commit.subject}>
<span className="mr-1 font-mono text-fg-muted">{commit.short}</span>
{commit.subject}
</p>
)}
{blocked && <Warn>{blocked}</Warn>}
{!plan.related ? (
<Warn>{t("reset.unrelated")}</Warn>
) : plan.head === plan.target ? (
<p>{t("reset.already")}</p>
) : (
<p data-testid="reset-summary" className="mb-1.5">
{t("reset.summary", { dropped: plan.dropped, added: plan.added, branch })}
</p>
)}
<fieldset className="space-y-1">
<legend className="mb-1 text-[11px] font-medium text-fg-primary">
{t("reset.mode")}
</legend>
{MODES.map((m) => (
<label key={m} className="flex items-start gap-1.5 text-[11px]">
<input
type="radio"
name="reset-mode"
data-testid={`reset-mode-${m}`}
value={m}
checked={mode === m}
onChange={() => setMode(m)}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="font-medium text-fg-primary">{t(`reset.modes.${m}`)}</span>
<span className="block text-fg-muted">{t(`reset.modesHelp.${m}`)}</span>
</span>
</label>
))}
</fieldset>
{mode === "hard" &&
(plan.dirty ? (
<Warn testId="reset-hard-dirty">{t("reset.hardDirty")}</Warn>
) : (
<div className="mt-1.5">
<label className="mb-0.5 block text-[11px]" htmlFor="reset-confirm-branch">
{t("reset.typeBranch", { branch })}
</label>
<input
id="reset-confirm-branch"
data-testid="reset-confirm-branch"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
className="input w-full font-mono text-[11px]"
/>
</div>
))}
{plan.pushed > 0 && (
<Warn testId="reset-pushed">
{t("reset.pushed", { count: plan.pushed })}
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="reset-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
</Warn>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,90 @@
/**
* @file RewriteDone.tsx
* @description What a finished rebase / interactive rebase / reset leaves on
* screen: the result, a force-push reminder when the rewrite touched pushed
* commits, and the recovery button — **Undo** = `undo-rewrite` (`reset --keep`
* back to the recorded `before`), which the server runs only while HEAD is
* still the `after` this dialog saw (409 ESTALE otherwise).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Undo2 } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type RewriteResult } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, ErrorLine, Warn } from "./GitModal";
export function RewriteDone({
target,
result,
onChanged,
onUndone,
}: {
target: GitTarget;
result: RewriteResult;
onChanged: () => void;
onUndone: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const canUndo = !!result.before && !!result.after && result.before !== result.after;
const undo = async () => {
if (!isArmed("undo")) return arm("undo");
disarm();
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "undo-rewrite", {
to: result.before,
head: result.after,
confirm: true,
});
toast.success(t("undone"));
onChanged();
onUndone();
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<div data-testid="git-rewrite-done" className="space-y-1.5">
<p className="flex items-center gap-1.5 text-status-success">
<CheckCircle2 className="h-4 w-4 shrink-0" />
{t("doneTitle")}
</p>
{result.output && (
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-surface-2 p-1.5 font-mono text-[10px] text-fg-muted">
{result.output}
</pre>
)}
{result.needsForcePush && <Warn>{t("needsForcePushAfter")}</Warn>}
{canUndo && (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
data-testid="git-rewrite-undo"
disabled={busy}
onClick={() => void undo()}
className={`${BTN} flex items-center gap-1`}
>
<Undo2 className="h-3 w-3" />
{isArmed("undo") ? t("undoConfirm") : t("undo")}
</button>
<span className="text-[11px] text-fg-muted">
{t("undoHint", { sha: result.before!.slice(0, 7) })}
</span>
</div>
)}
<ErrorLine error={error} />
</div>
);
}
@@ -0,0 +1,320 @@
/**
* @file Tests for the Changes panel's history tools: the 3-way merge editor
* (three panes, per-block accept/both/edit, "N left", Save only when every
* block is chosen and it sends the composed result with the opened sha), the
* rebase banner's Skip, the interactive-rebase list (pushed warning + tick,
* reorder, squash/reword/drop in the todo, two-click run, Undo after) and the
* reset dialog (mode, hard needs a clean tree and the typed branch name).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MergeEditor, composeResult } from "../MergeEditor";
import { InteractiveRebase, todoProblem } from "../InteractiveRebase";
import { ResetDialog } from "../ResetDialog";
import { RebaseSkipButton } from "../GitTools";
import { gitRewrite } from "../../../../lib/gitRewrite";
import { parseConflicts } from "../../../../lib/conflicts";
vi.mock("../../../../lib/gitRewrite", async (orig) => {
const real = await orig<typeof import("../../../../lib/gitRewrite")>();
return {
...real,
gitRewrite: {
conflict: vi.fn(),
planInteractive: vi.fn(),
planReset: vi.fn(),
run: vi.fn(),
},
};
});
vi.mock("../../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const WORKING = [
"top",
"<<<<<<< HEAD",
"mine",
"=======",
"theirs",
">>>>>>> feat",
"middle",
"<<<<<<< HEAD",
"a",
"=======",
"b",
">>>>>>> feat",
"",
].join("\n");
const HEAD = "a".repeat(40);
const commit = (n: number, pushed = false) => ({
sha: String(n).repeat(40),
short: String(n).repeat(7),
subject: `c${n}`,
message: `c${n}`,
pushed,
});
beforeEach(() => {
vi.mocked(gitRewrite.conflict)
.mockReset()
.mockResolvedValue({
available: true,
path: "a.txt",
operation: "merge",
branch: "main",
incoming: { sha: "f".repeat(40), short: "fffffff", subject: "feat work" },
base: "top\nbase\n",
ours: "top\nmine\n",
theirs: "top\ntheirs\n",
working: WORKING,
sha: "s1",
usable: true,
binary: false,
tooBig: false,
});
vi.mocked(gitRewrite.run).mockReset().mockResolvedValue({ action: "x", ok: true, output: "" });
});
describe("MergeEditor", () => {
it("shows three panes and saves the composed result once every block is chosen", async () => {
const user = userEvent.setup();
const onResolved = vi.fn();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={onResolved} />);
const incoming = await screen.findByTestId("merge-incoming");
expect(incoming).toHaveTextContent("Incoming: fffffff feat work");
expect(incoming).toHaveTextContent("theirs");
expect(screen.getByTestId("merge-current")).toHaveTextContent("Current: main");
expect(screen.getByTestId("merge-left")).toHaveTextContent("2 of 2 conflicts left");
const save = screen.getByTestId("merge-save");
expect(save).toBeDisabled();
const [first, second] = screen.getAllByTestId("merge-block") as [HTMLElement, HTMLElement];
await user.click(within(first).getByTestId("merge-accept-incoming"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("1 of 2 conflicts left");
await user.click(within(second).getByTestId("merge-both-ic"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("Every conflict has a choice");
await user.click(save);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "conflict-save", {
path: "a.txt",
content: "top\ntheirs\nmiddle\nb\na\n",
baseSha: "s1",
})
);
expect(onResolved).toHaveBeenCalled();
});
it("edits one block by hand, and the base pane is one click away", async () => {
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
const [first] = (await screen.findAllByTestId("merge-block")) as [HTMLElement];
await user.click(within(first).getByTestId("merge-edit-block"));
const box = within(first).getByTestId("merge-block-text");
await user.clear(box);
await user.type(box, "hand");
await user.click(within(first).getByRole("button", { name: "Done" }));
expect(first).toHaveTextContent("hand");
expect(screen.queryByTestId("merge-base")).toBeNull();
await user.click(screen.getByLabelText("Show base"));
expect(screen.getByTestId("merge-base")).toHaveTextContent("base");
});
it("labels the sides for a rebase and explains a refused save", async () => {
vi.mocked(gitRewrite.conflict).mockResolvedValueOnce({
...(await vi.mocked(gitRewrite.conflict).getMockImplementation()!(3, "a.txt")),
operation: "rebase",
});
vi.mocked(gitRewrite.run).mockRejectedValueOnce(
Object.assign(new Error("x"), { code: "EMARKERS" })
);
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
expect(await screen.findByTestId("merge-current")).toHaveTextContent(
"the branch you are rebasing onto"
);
await user.click(screen.getByTestId("merge-whole-toggle"));
await user.click(screen.getByTestId("merge-save"));
expect(await screen.findByRole("alert")).toHaveTextContent("still has conflict markers");
});
it("composeResult keeps markers on an unresolved block", () => {
const parts = parseConflicts(WORKING.split("\n"))!;
expect(composeResult(parts, [{ kind: "ours" }])).toBe(
"top\nmine\nmiddle\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> feat\n"
);
});
});
describe("RebaseSkipButton", () => {
it("skips the stopped commit and re-reads", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(<RebaseSkipButton target={3} onChanged={onChanged} />);
await user.click(screen.getByTestId("rebase-skip"));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-skip", {});
});
});
describe("InteractiveRebase", () => {
beforeEach(() => {
vi.mocked(gitRewrite.planInteractive)
.mockReset()
.mockResolvedValue({
available: true,
kind: "interactive",
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
base: "b".repeat(40),
merges: false,
commits: [commit(1, true), commit(2), commit(3)],
pushed: 1,
});
});
it("warns about pushed commits, needs the tick, and sends the edited todo", async () => {
const user = userEvent.setup();
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "rebase-interactive",
ok: true,
output: "",
before: HEAD,
after: "c".repeat(40),
conflicts: [],
stopped: false,
needsForcePush: true,
});
render(<InteractiveRebase target={3} onClose={vi.fn()} onChanged={vi.fn()} />);
expect(await screen.findByTestId("interactive-summary")).toHaveTextContent(
"This rewrites 3 commits; 1 are already pushed, so a force-push will be needed."
);
const run = screen.getByTestId("interactive-run");
expect(run).toBeDisabled(); // unchanged + pushed not acknowledged
const rows = screen.getAllByTestId("interactive-row") as HTMLElement[];
await user.selectOptions(within(rows[1]!).getByTestId("interactive-action"), "squash");
await user.selectOptions(within(rows[2]!).getByTestId("interactive-action"), "reword");
const msg = within(rows[2]!).getByTestId("interactive-message");
await user.clear(msg);
await user.type(msg, "new words");
await user.click(within(rows[2]!).getByRole("button", { name: "Move up" }));
expect(run).toBeDisabled();
await user.click(screen.getByTestId("interactive-force"));
expect(run).toBeEnabled();
await user.click(run);
expect(gitRewrite.run).not.toHaveBeenCalled(); // armed only
await user.click(screen.getByRole("button", { name: "Click again to rewrite" }));
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-interactive", {
head: HEAD,
confirm: true,
force: true,
todo: [
{ sha: commit(1).sha, action: "pick" },
{ sha: commit(3).sha, action: "reword", message: "new words" },
{ sha: commit(2).sha, action: "squash" },
],
})
);
expect(await screen.findByTestId("git-rewrite-done")).toHaveTextContent(
"Force push (with lease)"
);
expect(screen.getByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("todoProblem refuses a squash with nothing above and an empty reword", () => {
const c = commit(1);
expect(todoProblem([{ commit: c, action: "squash", message: "" }])).toBe("first");
expect(todoProblem([{ commit: c, action: "drop", message: "" }])).toBe("allDropped");
expect(todoProblem([{ commit: c, action: "reword", message: " " }])).toBe("reword");
expect(todoProblem([{ commit: c, action: "pick", message: "" }])).toBeNull();
});
});
describe("ResetDialog", () => {
const plan = (over = {}) => ({
available: true,
kind: "reset" as const,
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
target: "d".repeat(40),
related: true,
dropped: 2,
added: 0,
pushed: 0,
...over,
});
it("hard needs the branch name typed back", async () => {
vi.mocked(gitRewrite.planReset).mockReset().mockResolvedValue(plan());
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "reset",
ok: true,
output: "",
before: HEAD,
after: "d".repeat(40),
});
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40), short: "ddddddd", subject: "old" }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
expect(await screen.findByTestId("reset-summary")).toHaveTextContent("2 commit(s) leave feat");
await user.click(screen.getByTestId("reset-mode-hard"));
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.type(screen.getByTestId("reset-confirm-branch"), "feat");
expect(run).toBeEnabled();
await user.click(run);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "reset", {
target: "d".repeat(40),
mode: "hard",
head: HEAD,
confirm: true,
confirmBranch: "feat",
})
);
expect(await screen.findByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("refuses hard on a dirty tree and asks for the tick on pushed commits", async () => {
vi.mocked(gitRewrite.planReset)
.mockReset()
.mockResolvedValue(plan({ dirty: true, pushed: 1 }));
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40) }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
await screen.findByTestId("reset-pushed");
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.click(screen.getByTestId("reset-force"));
expect(run).toBeEnabled(); // mixed works on a dirty tree
await user.click(screen.getByTestId("reset-mode-hard"));
expect(screen.getByTestId("reset-hard-dirty")).toBeInTheDocument();
expect(run).toBeDisabled();
});
});