rebase rework

This commit is contained in:
2026-03-09 13:19:06 +03:00
parent 0a108f7db5
commit 8fb9fa15e0
12 changed files with 1010 additions and 21 deletions

View File

@@ -15,10 +15,9 @@
// [/SECTION]
// [SECTION: PROPS]
/** @type {Array<{file_path: string, mine: string, theirs: string}>} */
let {
conflicts = [],
show = false,
show = $bindable(false),
} = $props();
// [/SECTION]

View File

@@ -18,6 +18,7 @@
import { Button, Card, PageHeader, Select, Input } from '../../lib/ui';
import BranchSelector from './BranchSelector.svelte';
import DeploymentModal from './DeploymentModal.svelte';
import ConflictResolver from './ConflictResolver.svelte';
// [/SECTION]
// [SECTION: PROPS]
@@ -63,6 +64,15 @@
let repositoryProvider = $state('');
let repositoryBindingRemoteUrl = $state('');
let repositoryConfigUrl = $state('');
let showUnfinishedMergeDialog = $state(false);
let unfinishedMergeContext = $state(null);
let copyingUnfinishedMergeCommands = $state(false);
let mergeRecoveryLoading = $state(false);
let mergeConflicts = $state([]);
let showConflictResolver = $state(false);
let mergeResolveInProgress = $state(false);
let mergeAbortInProgress = $state(false);
let mergeContinueInProgress = $state(false);
// [/SECTION]
const hasWorkspaceChanges = $derived.by(() => {
@@ -302,6 +312,181 @@
* @purpose Pull remote changes for current branch.
* @post Workspace status is refreshed.
*/
function tryParseJsonObject(value) {
const source = String(value || '').trim();
if (!source.startsWith('{') || !source.endsWith('}')) return null;
try {
const parsed = JSON.parse(source);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch (_e) {
return null;
}
}
function extractUnfinishedMergeContext(error) {
if (!error || Number(error?.status) !== 409) return null;
const parsedMessage = tryParseJsonObject(error?.message);
const detail = error?.detail && typeof error.detail === 'object' ? error.detail : null;
const payload = detail || parsedMessage;
if (!payload || payload.error_code !== 'GIT_UNFINISHED_MERGE') return null;
const commands = Array.isArray(payload.manual_commands)
? payload.manual_commands.filter(Boolean).map((item) => String(item))
: [];
const nextSteps = Array.isArray(payload.next_steps)
? payload.next_steps.filter(Boolean).map((item) => String(item))
: [];
return {
message: String(payload.message || ''),
repositoryPath: String(payload.repository_path || ''),
gitDir: String(payload.git_dir || ''),
currentBranch: String(payload.current_branch || ''),
mergeHead: String(payload.merge_head || ''),
mergeMessagePreview: String(payload.merge_message_preview || ''),
nextSteps,
commands,
};
}
function openUnfinishedMergeDialogFromError(error) {
const context = extractUnfinishedMergeContext(error);
if (!context) return false;
unfinishedMergeContext = context;
showUnfinishedMergeDialog = true;
return true;
}
async function loadMergeRecoveryState() {
mergeRecoveryLoading = true;
try {
const status = await gitService.getMergeStatus(dashboardId, envId);
if (!status?.has_unfinished_merge) {
closeUnfinishedMergeDialog();
return;
}
unfinishedMergeContext = {
...(unfinishedMergeContext || {}),
message: unfinishedMergeContext?.message || ($t.git?.unfinished_merge?.default_message || ''),
repositoryPath: String(status.repository_path || unfinishedMergeContext?.repositoryPath || ''),
gitDir: String(status.git_dir || unfinishedMergeContext?.gitDir || ''),
currentBranch: String(status.current_branch || unfinishedMergeContext?.currentBranch || ''),
mergeHead: String(status.merge_head || unfinishedMergeContext?.mergeHead || ''),
mergeMessagePreview: String(status.merge_message_preview || unfinishedMergeContext?.mergeMessagePreview || ''),
nextSteps: Array.isArray(unfinishedMergeContext?.nextSteps) ? unfinishedMergeContext.nextSteps : [],
commands: Array.isArray(unfinishedMergeContext?.commands) ? unfinishedMergeContext.commands : [],
conflictsCount: Number(status.conflicts_count || 0),
};
} catch (e) {
toast(e.message || ($t.git?.unfinished_merge?.load_status_failed || 'Failed to load merge status'), 'error');
} finally {
mergeRecoveryLoading = false;
}
}
function closeUnfinishedMergeDialog() {
showUnfinishedMergeDialog = false;
unfinishedMergeContext = null;
mergeConflicts = [];
showConflictResolver = false;
}
async function handleOpenConflictResolver() {
mergeRecoveryLoading = true;
try {
mergeConflicts = await gitService.getMergeConflicts(dashboardId, envId);
if (!Array.isArray(mergeConflicts) || mergeConflicts.length === 0) {
toast($t.git?.unfinished_merge?.no_conflicts || 'No unresolved conflicts were found', 'info');
return;
}
showConflictResolver = true;
} catch (e) {
toast(e.message || ($t.git?.unfinished_merge?.load_conflicts_failed || 'Failed to load merge conflicts'), 'error');
} finally {
mergeRecoveryLoading = false;
}
}
async function handleResolveConflicts(event) {
const detail = event?.detail || {};
const resolutions = Object.entries(detail).map(([file_path, resolution]) => ({
file_path,
resolution,
}));
if (!resolutions.length) {
toast($t.git?.unfinished_merge?.resolve_empty || 'No conflict resolutions selected', 'warning');
return;
}
mergeResolveInProgress = true;
try {
await gitService.resolveMergeConflicts(dashboardId, resolutions, envId);
toast($t.git?.unfinished_merge?.resolve_success || 'Conflicts were resolved and staged', 'success');
showConflictResolver = false;
await loadMergeRecoveryState();
await loadWorkspace();
} catch (e) {
toast(e.message || ($t.git?.unfinished_merge?.resolve_failed || 'Failed to resolve conflicts'), 'error');
} finally {
mergeResolveInProgress = false;
}
}
async function handleAbortUnfinishedMerge() {
mergeAbortInProgress = true;
try {
await gitService.abortMerge(dashboardId, envId);
toast($t.git?.unfinished_merge?.abort_success || 'Merge was aborted', 'success');
closeUnfinishedMergeDialog();
await loadWorkspace();
} catch (e) {
toast(e.message || ($t.git?.unfinished_merge?.abort_failed || 'Failed to abort merge'), 'error');
} finally {
mergeAbortInProgress = false;
}
}
async function handleContinueUnfinishedMerge() {
mergeContinueInProgress = true;
try {
await gitService.continueMerge(dashboardId, '', envId);
toast($t.git?.unfinished_merge?.continue_success || 'Merge commit created successfully', 'success');
closeUnfinishedMergeDialog();
await loadWorkspace();
} catch (e) {
toast(e.message || ($t.git?.unfinished_merge?.continue_failed || 'Failed to continue merge'), 'error');
await loadMergeRecoveryState();
} finally {
mergeContinueInProgress = false;
}
}
function getUnfinishedMergeCommandsText() {
if (!unfinishedMergeContext?.commands?.length) return '';
return unfinishedMergeContext.commands.join('\n');
}
async function handleCopyUnfinishedMergeCommands() {
const commandsText = getUnfinishedMergeCommandsText();
if (!commandsText) {
toast($t.git?.unfinished_merge?.copy_empty || 'Команды для копирования отсутствуют', 'warning');
return;
}
copyingUnfinishedMergeCommands = true;
try {
if (typeof navigator === 'undefined' || !navigator?.clipboard?.writeText) {
throw new Error('Clipboard API unavailable');
}
await navigator.clipboard.writeText(commandsText);
toast($t.git?.unfinished_merge?.copy_success || 'Команды скопированы в буфер обмена', 'success');
} catch (_e) {
toast($t.git?.unfinished_merge?.copy_failed || 'Не удалось скопировать команды', 'error');
} finally {
copyingUnfinishedMergeCommands = false;
}
}
async function handlePull() {
isPulling = true;
try {
@@ -309,7 +494,12 @@
toast($t.git?.pull_success || 'Изменения получены из Git', 'success');
await loadWorkspace();
} catch (e) {
toast(e.message, 'error');
const handledByDialog = openUnfinishedMergeDialogFromError(e);
if (handledByDialog) {
await loadMergeRecoveryState();
} else {
toast(e.message, 'error');
}
} finally {
isPulling = false;
}
@@ -891,9 +1081,118 @@
</div>
{/if}
</div>
{#if showUnfinishedMergeDialog && unfinishedMergeContext}
<div
class="absolute inset-0 z-20 flex items-center justify-center bg-black bg-opacity-40 p-4"
role="dialog"
aria-modal="true"
aria-label={$t.git?.unfinished_merge?.title || 'Repository has an unfinished merge'}
>
<div class="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-amber-300 bg-white p-5 shadow-2xl">
<div class="mb-3 text-lg font-semibold text-amber-900">
{$t.git?.unfinished_merge?.title || 'Repository has an unfinished merge'}
</div>
<p class="mb-4 text-sm text-slate-700">
{unfinishedMergeContext.message || ($t.git?.unfinished_merge?.default_message || 'An unfinished merge was detected in this repository.')}
</p>
<div class="grid grid-cols-1 gap-2 text-sm md:grid-cols-2">
<div class="rounded border border-slate-200 bg-slate-50 p-2">
<div class="text-xs text-slate-500">{$t.git?.unfinished_merge?.repository_path || 'Repository path'}</div>
<div class="break-all font-mono text-slate-800">{unfinishedMergeContext.repositoryPath || '—'}</div>
</div>
<div class="rounded border border-slate-200 bg-slate-50 p-2">
<div class="text-xs text-slate-500">{$t.git?.unfinished_merge?.branch || 'Current branch'}</div>
<div class="font-mono text-slate-800">{unfinishedMergeContext.currentBranch || '—'}</div>
</div>
{#if unfinishedMergeContext.mergeHead}
<div class="rounded border border-slate-200 bg-slate-50 p-2 md:col-span-2">
<div class="text-xs text-slate-500">MERGE_HEAD</div>
<div class="break-all font-mono text-slate-800">{unfinishedMergeContext.mergeHead}</div>
</div>
{/if}
</div>
{#if unfinishedMergeContext.nextSteps?.length}
<div class="mt-4">
<div class="mb-1 text-sm font-semibold text-slate-700">
{$t.git?.unfinished_merge?.next_steps || 'Recommended steps'}
</div>
<ol class="list-decimal space-y-1 pl-5 text-sm text-slate-700">
{#each unfinishedMergeContext.nextSteps as step}
<li>{step}</li>
{/each}
</ol>
</div>
{/if}
{#if unfinishedMergeContext.commands?.length}
<div class="mt-4">
<div class="mb-1 text-sm font-semibold text-slate-700">
{$t.git?.unfinished_merge?.manual_commands || 'Manual recovery commands'}
</div>
<pre class="overflow-auto rounded border border-slate-200 bg-slate-50 p-3 text-xs text-slate-800">{unfinishedMergeContext.commands.join('\n')}</pre>
</div>
{/if}
<div class="mt-5 flex flex-wrap justify-end gap-2">
<Button
variant="ghost"
onclick={loadMergeRecoveryState}
disabled={mergeRecoveryLoading}
isLoading={mergeRecoveryLoading}
>
{$t.common?.refresh || 'Refresh'}
</Button>
<Button
variant="secondary"
onclick={handleCopyUnfinishedMergeCommands}
disabled={copyingUnfinishedMergeCommands}
isLoading={copyingUnfinishedMergeCommands}
>
{$t.git?.unfinished_merge?.copy_commands || 'Copy commands'}
</Button>
<Button
variant="ghost"
onclick={handleOpenConflictResolver}
disabled={mergeRecoveryLoading || mergeResolveInProgress}
isLoading={mergeResolveInProgress}
>
{$t.git?.unfinished_merge?.open_resolver || 'Open conflict resolver'}
</Button>
<Button
variant="ghost"
onclick={handleAbortUnfinishedMerge}
disabled={mergeAbortInProgress}
isLoading={mergeAbortInProgress}
class="border border-red-300 text-red-700 hover:bg-red-50"
>
{$t.git?.unfinished_merge?.abort_merge || 'Abort merge'}
</Button>
<Button
onclick={handleContinueUnfinishedMerge}
disabled={mergeContinueInProgress}
isLoading={mergeContinueInProgress}
>
{$t.git?.unfinished_merge?.continue_merge || 'Continue merge'}
</Button>
<Button onclick={closeUnfinishedMergeDialog}>
{$t.common?.close || 'Close'}
</Button>
</div>
</div>
</div>
{/if}
</div>
{/if}
<ConflictResolver
conflicts={mergeConflicts}
bind:show={showConflictResolver}
on:resolve={handleResolveConflicts}
/>
<DeploymentModal
{dashboardId}
{envId}

View File

@@ -0,0 +1,43 @@
// [DEF:frontend.src.components.git.__tests__.git_manager_unfinished_merge_integration:Module]
// @TIER: STANDARD
// @SEMANTICS: git-manager, unfinished-merge, dialog, integration-test
// @PURPOSE: Protect unresolved-merge dialog contract in GitManager pull flow.
// @LAYER: UI Tests
// @RELATION: VERIFIES -> frontend/src/components/git/GitManager.svelte
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const COMPONENT_PATH = path.resolve(
process.cwd(),
'src/components/git/GitManager.svelte',
);
describe('GitManager unfinished merge dialog contract', () => {
it('keeps 409 unfinished-merge detection and WebUI dialog recovery flow in pull handler', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(source).toContain('Number(error?.status) !== 409');
expect(source).toContain("payload.error_code !== 'GIT_UNFINISHED_MERGE'");
expect(source).toContain('function openUnfinishedMergeDialogFromError(error)');
expect(source).toContain('showUnfinishedMergeDialog = true;');
expect(source).toContain('const handledByDialog = openUnfinishedMergeDialogFromError(e);');
expect(source).toContain('await loadMergeRecoveryState();');
});
it('renders unresolved-merge dialog details and web recovery actions', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(source).toContain('{#if showUnfinishedMergeDialog && unfinishedMergeContext}');
expect(source).toContain('unfinishedMergeContext.repositoryPath');
expect(source).toContain('unfinishedMergeContext.currentBranch');
expect(source).toContain('unfinishedMergeContext.commands.join');
expect(source).toContain('handleCopyUnfinishedMergeCommands');
expect(source).toContain('handleOpenConflictResolver');
expect(source).toContain('handleAbortUnfinishedMerge');
expect(source).toContain('handleContinueUnfinishedMerge');
expect(source).toContain('$t.git?.unfinished_merge?.copy_commands');
});
});
// [/DEF:frontend.src.components.git.__tests__.git_manager_unfinished_merge_integration:Module]

View File

@@ -15,11 +15,20 @@ const API_BASE_URL = '/api';
// @POST: Returned error contains message and status fields.
async function buildApiError(response) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
const detail = errorData?.detail;
const message = detail
? (
typeof detail === 'string'
? detail
: (typeof detail?.message === 'string' ? detail.message : JSON.stringify(detail))
)
: `API request failed with status ${response.status}`;
const error = new Error(message);
error.status = response.status;
/** @type {any} */ (error).status = response.status;
/** @type {any} */ (error).detail = detail;
if (detail && typeof detail === 'object' && detail.error_code) {
/** @type {any} */ (error).error_code = String(detail.error_code);
}
return error;
}
// [/DEF:buildApiError:Function]
@@ -54,7 +63,18 @@ function shouldSuppressApiErrorToast(endpoint, error) {
(error?.status === 400 || error?.status === 404) &&
/Repository for dashboard .* not found/i.test(String(error?.message || ''));
return isGitStatusEndpoint && isNoRepoError;
const isGitPullEndpoint =
typeof endpoint === 'string' &&
endpoint.startsWith('/git/repositories/') &&
endpoint.endsWith('/pull');
const isUnfinishedMergeError =
error?.status === 409 &&
(
String(error?.error_code || '') === 'GIT_UNFINISHED_MERGE' ||
String(error?.detail?.error_code || '') === 'GIT_UNFINISHED_MERGE'
);
return (isGitStatusEndpoint && isNoRepoError) || (isGitPullEndpoint && isUnfinishedMergeError);
}
// [/DEF:shouldSuppressApiErrorToast:Function]
@@ -130,7 +150,7 @@ async function fetchApiBlob(endpoint, options = {}) {
if (response.status === 202) {
const payload = await response.json().catch(() => ({ message: "Resource is being prepared" }));
const error = new Error(payload?.message || "Resource is being prepared");
error.status = 202;
/** @type {any} */ (error).status = 202;
throw error;
}
if (!response.ok) {

View File

@@ -295,6 +295,18 @@
"ahead_remote": "Ahead Remote",
"diverged": "Diverged",
"error": "Error"
},
"unfinished_merge": {
"title": "Repository has an unfinished merge",
"default_message": "An unfinished merge was detected. Resolve conflicts or abort merge manually, then retry Pull.",
"repository_path": "Repository path",
"branch": "Current branch",
"next_steps": "Recommended steps",
"manual_commands": "Manual recovery commands",
"copy_commands": "Copy commands",
"copy_success": "Commands copied to clipboard",
"copy_failed": "Failed to copy commands",
"copy_empty": "No commands to copy"
}
},
"dashboard": {

View File

@@ -294,6 +294,18 @@
"ahead_remote": "Опережает remote",
"diverged": "Расхождение",
"error": "Ошибка"
},
"unfinished_merge": {
"title": "Незавершённое слияние в репозитории",
"default_message": "В репозитории найдено незавершённое слияние. Завершите или отмените его вручную и повторите Pull.",
"repository_path": "Путь к репозиторию",
"branch": "Текущая ветка",
"next_steps": "Рекомендуемые шаги",
"manual_commands": "Команды для ручного восстановления",
"copy_commands": "Скопировать команды",
"copy_success": "Команды скопированы в буфер обмена",
"copy_failed": "Не удалось скопировать команды",
"copy_empty": "Команды для копирования отсутствуют"
}
},
"dashboard": {

View File

@@ -147,7 +147,7 @@ export const gitService = {
* @purpose Initializes or clones a Git repository for a dashboard.
* @pre Dashboard must exist and config_id must be valid.
* @post Repository is initialized on the backend.
* @param {number} dashboardId - ID of the dashboard.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @param {string} configId - ID of the Git config.
* @param {string} remoteUrl - URL of the remote repository.
* @returns {Promise<Object>} Initialization result.
@@ -178,7 +178,7 @@ export const gitService = {
* @purpose Retrieves the list of branches for a dashboard's repository.
* @pre Repository must be initialized.
* @post Returns a list of branches.
* @param {number} dashboardId - ID of the dashboard.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Array>} List of branches.
*/
async getBranches(dashboardRef, envId = null) {
@@ -272,6 +272,77 @@ export const gitService = {
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/pull', envId), 'POST');
},
/**
* [DEF:getMergeStatus:Function]
* @purpose Retrieves unfinished-merge status for repository.
* @pre Repository must exist.
* @post Returns merge status payload.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Object>} Merge status details.
*/
async getMergeStatus(dashboardRef, envId = null) {
console.log(`[getMergeStatus][Action] Fetching merge status for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/status', envId));
},
/**
* [DEF:getMergeConflicts:Function]
* @purpose Retrieves merge conflicts list for repository.
* @pre Unfinished merge should be in progress.
* @post Returns conflict files with mine/theirs previews.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Array>} List of conflict files.
*/
async getMergeConflicts(dashboardRef, envId = null) {
console.log(`[getMergeConflicts][Action] Fetching merge conflicts for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/conflicts', envId));
},
/**
* [DEF:resolveMergeConflicts:Function]
* @purpose Applies conflict resolution strategies and stages resolved files.
* @pre resolutions contains file_path/resolution entries.
* @post Conflicts are resolved and staged.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @param {Array} resolutions - Resolution entries.
* @returns {Promise<Object>} Resolve result.
*/
async resolveMergeConflicts(dashboardRef, resolutions, envId = null) {
console.log(`[resolveMergeConflicts][Action] Resolving ${Array.isArray(resolutions) ? resolutions.length : 0} conflicts for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/resolve', envId), 'POST', {
resolutions: Array.isArray(resolutions) ? resolutions : []
});
},
/**
* [DEF:abortMerge:Function]
* @purpose Aborts current unfinished merge.
* @pre Repository exists.
* @post Merge state is aborted or reported as absent.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @returns {Promise<Object>} Abort operation result.
*/
async abortMerge(dashboardRef, envId = null) {
console.log(`[abortMerge][Action] Aborting merge for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/abort', envId), 'POST');
},
/**
* [DEF:continueMerge:Function]
* @purpose Finalizes unfinished merge by creating merge commit.
* @pre All conflicts are resolved.
* @post Merge commit is created.
* @param {string|number} dashboardRef - Dashboard slug or id.
* @param {string} message - Optional commit message.
* @returns {Promise<Object>} Continue result.
*/
async continueMerge(dashboardRef, message = '', envId = null) {
console.log(`[continueMerge][Action] Continuing merge for dashboard ${dashboardRef}`);
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/merge/continue', envId), 'POST', {
message: String(message || '').trim() || null
});
},
/**
* [DEF:getEnvironments:Function]
* @purpose Retrieves available deployment environments.