- Add @RATIONALE/@REJECTED to 103+ C4/C5 contracts across backend core, services, API routes, and frontend models - Fix 109 unresolved @RELATION edges (Auth.*, SupersetClient.*, AgentChat.*, ADR cross-refs) - Add 13 @ingroup tags for DSA/HCA attention grouping - Repair 29 stale graph edges via index rebuild - Update .kilo agent prompts and skills for GRACE-Poly v2.6 compliance - Git integration: merge routes, branch lifecycle, remote providers, UX components - 0 broken anchor pairs, index rebuilt with 0 parse warnings
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
// frontend/src/lib/models/BranchModel.svelte.ts
|
|
// #region Git.BranchModel [C:4] [TYPE Model] [SEMANTICS git,branch,checkout,create,merge,delete,screen-model]
|
|
// @ingroup Git
|
|
// @BRIEF State model for branch selector — list, checkout, create, merge, and delete Git branches.
|
|
// @INVARIANT Loading guards all operations — only one operation runs at a time.
|
|
// @STATE idle — Branch list loaded, dropdown enabled.
|
|
// @STATE loading — Fetching branch list.
|
|
// @STATE createMode — Inline create form visible.
|
|
// @STATE checking_out — Branch checkout in progress.
|
|
// @STATE creating — Branch creation in progress.
|
|
// @STATE merging — Branch merge in progress.
|
|
// @STATE deleting — Branch deletion in progress.
|
|
// @ACTION loadBranches(dashboardId, envId) — Fetch branch list from API.
|
|
// @ACTION handleCheckout(dashboardId, branchName, envId) — Checkout a branch.
|
|
// @ACTION handleCreate(dashboardId, branchName, sourceBranch, envId) — Create a new branch.
|
|
// @ACTION handleMerge(dashboardId, sourceBranch, targetBranch, message, autoDelete, envId) — Merge branches.
|
|
// @ACTION handleDelete(dashboardId, branchName, force, envId) — Delete a branch.
|
|
// @ACTION handleSelect(event) — Handle dropdown selection.
|
|
// @ACTION toggleCreateForm() — Toggle create form visibility.
|
|
// @RELATION DEPENDS_ON -> [EXT:frontend:gitService]
|
|
// @RELATION DEPENDS_ON -> [ToastsModule]
|
|
// @RATIONALE Git.BranchModel encapsulates branch management (list, checkout, create, merge, delete) into a testable model, following the pattern established by GitStatusModel. Each git operation is gated by a dedicated loading flag ($state) so the UI can disable controls during in-flight operations. Error handling captures structured BranchError (message + next_steps) from the backend, enabling richer inline error display than a simple toast.
|
|
// @REJECTED Inline $state in BranchSelector.svelte rejected — model extraction enables L1 tests without DOM render,
|
|
// follows pattern established by GitStatusModel and GitManagerModel.
|
|
|
|
import { gitService } from '../../services/gitService.js';
|
|
import { addToast } from '$lib/toasts.svelte.js';
|
|
import { getT } from '$lib/i18n/index.svelte.js';
|
|
|
|
// ── Types ─────────────────────────────────────────────────────
|
|
|
|
interface BranchItem {
|
|
name: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface BranchError {
|
|
message: string;
|
|
next_steps: string[];
|
|
}
|
|
|
|
/** @returns Current i18n translations. */
|
|
function tt(): Record<string, unknown> {
|
|
return getT();
|
|
}
|
|
|
|
export class BranchModel {
|
|
// ── Identity ───────────────────────────────────────────────
|
|
/** Dashboard slug for git API calls. */
|
|
dashboardId: string = $state('');
|
|
/** Environment ID for scoped git operations. */
|
|
envId: string | null = $state(null);
|
|
/** Current active branch name. Updated on checkout. */
|
|
currentBranch: string = $state('prod');
|
|
/** Callback invoked when branch changes. */
|
|
onChange: ((_event: { branch: string }) => void) | null = $state(null);
|
|
|
|
// ── Branch List ────────────────────────────────────────────
|
|
/** Array of branch objects from the API. */
|
|
branches: BranchItem[] = $state([]);
|
|
/** True while fetching branches. */
|
|
loading: boolean = $state(false);
|
|
|
|
// ── Create Form ────────────────────────────────────────────
|
|
/** True when the create-branch form is visible. */
|
|
showCreate: boolean = $state(false);
|
|
/** New branch name input value. */
|
|
newBranchName: string = $state('');
|
|
/** Error object { message, next_steps? } from last failed checkout/create. */
|
|
branchError: BranchError | null = $state(null);
|
|
|
|
// ── Operation Loading Flags ────────────────────────────────
|
|
/** True while a checkout is in progress. */
|
|
checkingOut: boolean = $state(false);
|
|
/** True while a branch creation is in progress. */
|
|
creating: boolean = $state(false);
|
|
/** True while a branch merge is in progress. */
|
|
merging: boolean = $state(false);
|
|
/** True while a branch deletion is in progress. */
|
|
deleting: boolean = $state(false);
|
|
|
|
// ── Merge State ─────────────────────────────────────────────
|
|
/** Merge result from the last merge operation. */
|
|
mergeResult: { status: string; conflicts?: string[]; source_deleted?: boolean } | null = $state(null);
|
|
/** Name of the branch being merged (for dialog context). */
|
|
mergeSourceBranch: string = $state('');
|
|
/** Name of the target branch for merge. */
|
|
mergeTargetBranch: string = $state('');
|
|
|
|
// ── Delete State ────────────────────────────────────────────
|
|
/** True when delete confirmation dialog is shown. */
|
|
showDeleteConfirm: boolean = $state(false);
|
|
/** Name of the branch pending deletion. */
|
|
branchToDelete: string = $state('');
|
|
|
|
// ── Dialog Flags ────────────────────────────────────────────
|
|
/** True when create dialog is shown. */
|
|
showCreateDialog: boolean = $state(false);
|
|
|
|
constructor({
|
|
dashboardId = '',
|
|
envId = null,
|
|
currentBranch = 'prod',
|
|
onChange = null,
|
|
}: {
|
|
dashboardId?: string;
|
|
envId?: string | null;
|
|
currentBranch?: string;
|
|
onChange?: ((_event: { branch: string }) => void) | null;
|
|
} = {}) {
|
|
this.dashboardId = dashboardId;
|
|
this.envId = envId;
|
|
this.currentBranch = currentBranch;
|
|
this.onChange = onChange;
|
|
}
|
|
|
|
// ── Branch List ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch branch list from the backend.
|
|
* @SIDE_EFFECT GETs /git/repositories/{ref}/branches.
|
|
*/
|
|
async loadBranches(dashboardId?: string, envId?: string | null): Promise<void> {
|
|
const ref = dashboardId || this.dashboardId;
|
|
const eid = envId !== undefined ? envId : this.envId;
|
|
if (!ref) return;
|
|
this.loading = true;
|
|
this.branchError = null;
|
|
try {
|
|
this.branches = await gitService.getBranches(ref, eid);
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'Failed to load branches';
|
|
addToast((tt().git as Record<string, string>)?.load_branches_failed || msg, 'error');
|
|
this.branches = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
// ── Select (sync) ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Handle dropdown selection — delegates to handleCheckout.
|
|
*/
|
|
handleSelect(event: { target?: { value?: string } }): void {
|
|
const branchName = event?.target?.value;
|
|
if (!branchName) return;
|
|
void this.handleCheckout(this.dashboardId, branchName, this.envId);
|
|
}
|
|
|
|
// ── Checkout ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Switch repository to the selected branch.
|
|
* @POST currentBranch updated; onChange callback fired on success.
|
|
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/checkout.
|
|
*/
|
|
async handleCheckout(dashboardId?: string, branchName?: string, envId?: string | null): Promise<void> {
|
|
const ref = dashboardId || this.dashboardId;
|
|
const eid = envId !== undefined ? envId : this.envId;
|
|
if (!branchName || !ref) return;
|
|
this.branchError = null;
|
|
this.checkingOut = true;
|
|
try {
|
|
await gitService.checkoutBranch(ref, branchName, eid);
|
|
this.currentBranch = branchName;
|
|
if (typeof this.onChange === 'function') {
|
|
this.onChange({ branch: branchName });
|
|
}
|
|
addToast(
|
|
((tt().git as Record<string, string>)?.switched_to || 'Switched to {branch}').replace('{branch}', branchName),
|
|
'success',
|
|
);
|
|
} catch (e: unknown) {
|
|
const err = e as Record<string, unknown>;
|
|
const detail = typeof err?.detail === 'object' ? err.detail as Record<string, unknown> : {};
|
|
const shortMsg = (detail?.message as string) || (detail?.message_en as string) || (err.message as string) || 'Branch switch failed';
|
|
this.branchError = {
|
|
message: shortMsg,
|
|
next_steps: Array.isArray(detail?.next_steps) ? detail.next_steps as string[] : [],
|
|
};
|
|
addToast(shortMsg, 'warning');
|
|
} finally {
|
|
this.checkingOut = false;
|
|
}
|
|
}
|
|
|
|
// ── Create Branch ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Create a new branch from the current branch.
|
|
* @POST On success: branch list reloaded, create form closed, input cleared.
|
|
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/branches.
|
|
*/
|
|
async handleCreate(dashboardId?: string, branchName?: string, sourceBranch?: string, envId?: string | null): Promise<void> {
|
|
const ref = dashboardId || this.dashboardId;
|
|
const eid = envId !== undefined ? envId : this.envId;
|
|
const name = branchName || this.newBranchName;
|
|
const source = sourceBranch || this.currentBranch;
|
|
if (!name || !ref) return;
|
|
this.branchError = null;
|
|
this.creating = true;
|
|
try {
|
|
await gitService.createBranch(ref, name, source, eid);
|
|
addToast(
|
|
((tt().git as Record<string, string>)?.created_branch || 'Created branch {branch}').replace('{branch}', name),
|
|
'success',
|
|
);
|
|
this.showCreate = false;
|
|
this.newBranchName = '';
|
|
await this.loadBranches(ref, eid);
|
|
} catch (e: unknown) {
|
|
this.branchError = { message: e instanceof Error ? e.message : 'Branch creation failed', next_steps: [] };
|
|
addToast(e instanceof Error ? e.message : 'Branch creation failed', 'error');
|
|
} finally {
|
|
this.creating = false;
|
|
}
|
|
}
|
|
|
|
// ── Merge Branch ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Merge source branch into target branch with optional auto-delete.
|
|
* @POST mergeResult populated; branches reloaded on success.
|
|
* @SIDE_EFFECT POSTs to /git/repositories/{ref}/merge.
|
|
*/
|
|
async handleMerge(
|
|
sourceBranch: string,
|
|
targetBranch: string,
|
|
message: string | null = null,
|
|
autoDelete = false,
|
|
): Promise<void> {
|
|
if (!sourceBranch || !targetBranch || !this.dashboardId) return;
|
|
this.merging = true;
|
|
this.mergeResult = null;
|
|
this.mergeSourceBranch = sourceBranch;
|
|
this.mergeTargetBranch = targetBranch;
|
|
this.branchError = null;
|
|
try {
|
|
const res = await gitService.mergeBranch(
|
|
this.dashboardId, sourceBranch, targetBranch,
|
|
message, autoDelete, this.envId,
|
|
) as { status: string; conflicts?: string[]; source_deleted?: boolean };
|
|
this.mergeResult = res;
|
|
if (res.status === 'success') {
|
|
addToast(
|
|
'Merged ' + sourceBranch + ' → ' + targetBranch,
|
|
'success',
|
|
);
|
|
await this.loadBranches();
|
|
} else if (res.status === 'conflicts') {
|
|
const conflictFiles = (res.conflicts || []).join(', ');
|
|
addToast('Merge conflicts in: ' + conflictFiles, 'warning');
|
|
}
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'Merge failed';
|
|
this.branchError = { message: msg, next_steps: [] };
|
|
addToast(msg, 'error');
|
|
} finally {
|
|
this.merging = false;
|
|
}
|
|
}
|
|
|
|
// ── Delete Branch ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Delete a branch with confirmation state management.
|
|
* @POST Branch deleted; branches reloaded.
|
|
* @SIDE_EFFECT DELETEs /git/repositories/{ref}/branches/{name}.
|
|
*/
|
|
openDeleteConfirm(branchName: string): void {
|
|
this.branchToDelete = branchName;
|
|
this.showDeleteConfirm = true;
|
|
}
|
|
|
|
closeDeleteConfirm(): void {
|
|
this.branchToDelete = '';
|
|
this.showDeleteConfirm = false;
|
|
}
|
|
|
|
async handleDelete(branchName?: string, force = false): Promise<void> {
|
|
const name = branchName || this.branchToDelete;
|
|
if (!name || !this.dashboardId) return;
|
|
this.deleting = true;
|
|
this.branchError = null;
|
|
try {
|
|
await gitService.deleteBranch(this.dashboardId, name, force, this.envId);
|
|
addToast(
|
|
((tt().git as Record<string, string>)?.delete_branch?.success || 'Branch deleted').replace('{branch}', name),
|
|
'success',
|
|
);
|
|
this.closeDeleteConfirm();
|
|
await this.loadBranches();
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'Delete failed';
|
|
this.branchError = { message: msg, next_steps: [] };
|
|
addToast(msg, 'error');
|
|
} finally {
|
|
this.deleting = false;
|
|
}
|
|
}
|
|
|
|
// ── UI Actions (sync) ──────────────────────────────────────
|
|
|
|
/**
|
|
* Toggle create branch form visibility.
|
|
*/
|
|
toggleCreateForm(): void {
|
|
this.showCreate = !this.showCreate;
|
|
if (!this.showCreate) {
|
|
this.newBranchName = '';
|
|
}
|
|
}
|
|
}
|
|
// #endregion Git.BranchModel
|