Files
AgentHub/client/src/lib/gitRewrite.ts
T
nntrivi2001 2b5d7268ed 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.
2026-11-02 12:12:45 +07:00

187 lines
5.3 KiB
TypeScript

/**
* @file Client calls for the better-conflicts and history-rewrite surface
* (server/lib/git-conflicts.js, server/lib/git-rewrite.js): the merge editor's
* stage read and save, the reflog, remotes, the rewrite plans, and the rewrite
* / remote writes — each a POST /git/<action> like `api.lanes.gitAction`. Kept
* out of api.ts so its own surface stays in one file. Every write fires the
* shared `agenthub:git-changed` event once (`emitGitChanged`, gitChanged.ts),
* as `api.lanes.gitAction` does, so every open git view reads again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { gitUrl, request, type GitTarget } from "./api";
import { emitGitChanged } from "./gitChanged";
/** One conflicted file's three sides and the working file it saves over. */
export interface ConflictSides {
available: boolean;
path: string;
operation: "merge" | "cherry-pick" | "revert" | "rebase" | null;
branch: string | null;
incoming: { sha: string; short: string; subject: string } | null;
base: string | null;
ours: string | null;
theirs: string | null;
working: string | null;
sha: string | null;
usable: boolean;
binary: boolean;
tooBig: boolean;
}
export interface ReflogEntry {
index: number;
selector: string;
sha: string;
short: string;
action: string;
message: string;
time: number | null;
subject: string;
}
export interface GitRemote {
name: string;
url: string;
pushUrl: string;
defaultBranch: string | null;
/** Its fetched remote-tracking branches (every one, a local copy or not). */
branches?: string[];
}
export interface PlanCommit {
sha: string;
short: string;
subject: string;
message: string;
pushed: boolean;
}
interface PlanCommon {
available: boolean;
head: string;
branch: string | null;
subdir: boolean;
operation: string | null;
dirty: boolean;
}
export interface RebasePlan extends PlanCommon {
kind: "rebase";
onto: string;
commits: PlanCommit[];
behind: number;
pushed: number;
}
export interface InteractivePlan extends PlanCommon {
kind: "interactive";
base: string;
merges: boolean;
commits: PlanCommit[];
pushed: number;
}
export interface ResetPlan extends PlanCommon {
kind: "reset";
target: string;
related: boolean;
dropped: number;
added: number;
pushed: number;
}
export interface PushPlan extends PlanCommon {
kind: "push";
remote: string;
remoteBranch: string;
remoteSha: string | null;
ahead: number;
behind: number;
protected: boolean;
allowed: boolean;
}
export type TodoAction = "pick" | "reword" | "squash" | "fixup" | "drop";
export interface TodoEntry {
sha: string;
action: TodoAction;
message?: string;
}
export type ResetMode = "soft" | "mixed" | "keep" | "hard";
export type RewriteAction =
| "rebase"
| "rebase-continue"
| "rebase-skip"
| "rebase-abort"
| "rebase-interactive"
| "reset"
| "undo-rewrite"
| "force-push"
| "remote-branch-delete"
| "remote-add"
| "remote-rename"
| "remote-remove"
| "conflict-save";
export interface RewriteResult {
action: string;
ok: true;
output: string;
before?: string;
after?: string | null;
conflicts?: string[];
stopped?: boolean;
needsForcePush?: boolean;
resolved?: boolean;
}
export const gitRewrite = {
conflict: (t: GitTarget, path: string) =>
request<ConflictSides>(gitUrl(t, "/conflict", new URLSearchParams({ path }))),
reflog: (t: GitTarget, opts: { ref?: string; skip?: number; limit?: number } = {}) => {
const q = new URLSearchParams();
if (opts.ref) q.set("ref", opts.ref);
if (opts.skip) q.set("skip", String(opts.skip));
if (opts.limit) q.set("limit", String(opts.limit));
return request<{ available: boolean; ref?: string; entries: ReflogEntry[]; more: boolean }>(
gitUrl(t, "/reflog", q)
);
},
remotes: (t: GitTarget) =>
request<{ available: boolean; remotes: GitRemote[] }>(gitUrl(t, "/remotes")),
planRebase: (t: GitTarget, onto: string) =>
request<RebasePlan>(gitUrl(t, "/rewrite-plan", new URLSearchParams({ kind: "rebase", onto }))),
planInteractive: (t: GitTarget, base?: string) =>
request<InteractivePlan>(
gitUrl(
t,
"/rewrite-plan",
new URLSearchParams(base ? { kind: "interactive", base } : { kind: "interactive" })
)
),
planReset: (t: GitTarget, target: string) =>
request<ResetPlan>(gitUrl(t, "/rewrite-plan", new URLSearchParams({ kind: "reset", target }))),
planPush: (t: GitTarget) =>
request<PushPlan>(gitUrl(t, "/rewrite-plan", new URLSearchParams({ kind: "push" }))),
/** POST /git/<action>; fires `agenthub:git-changed` once after it answers (ok or not). */
run: async (t: GitTarget, action: RewriteAction, body: Record<string, unknown> = {}) => {
try {
return await request<RewriteResult>(gitUrl(t, `/${action}`), {
method: "POST",
body: JSON.stringify(body),
});
} finally {
emitGitChanged(typeof t === "number" ? t : t.lane, action);
}
},
};
/** The error message of a failed call, for an inline alert. */
export function errText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/** The error code of a failed call (`ApiError.code` / `.status` shape). */
export function errCode(e: unknown): string | undefined {
const c = (e as { code?: unknown })?.code;
return typeof c === "string" ? c : undefined;
}