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.
187 lines
5.3 KiB
TypeScript
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;
|
|
}
|