Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/merging.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ forkpress branch history
forkpress branch tree --format json
```

In wp-admin, use the ForkPress branch switcher and choose **Show merge
history** to load the same source-to-target run list. Runs with conflicts can
jump directly into the conflict review queue.

Show recent runs, decisions, conflicts, and resolutions:

```bash
Expand Down
57 changes: 57 additions & 0 deletions tests/cow/branch_ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ function get_site_option($name, $default = false) {
if ($action === 'forkpress_branch_merge') {
forkpress_handle_branch_merge();
}
if ($action === 'forkpress_branch_history') {
forkpress_handle_branch_history();
}
if ($action === 'forkpress_branch_conflicts') {
forkpress_handle_branch_conflicts();
}
Expand Down Expand Up @@ -349,6 +352,42 @@ function decode_branch_ui_payload(array $result): array {
'branch merge admin action uses audited branch merge CLI path'
);

$history_json = json_encode([
'runs' => [
[
'id' => 42,
'source_branch' => 'feature',
'target_branch' => 'main',
'status' => 'completed_with_conflicts',
'decision_count' => 9,
'conflict_count' => 3,
'finished_at' => '2026-05-18 12:00:00',
],
],
], JSON_UNESCAPED_SLASHES);
$history = run_branch_ui_action(
['action' => 'forkpress_branch_history', 'limit' => '5'],
['main', 'feature'],
false,
true,
true,
['FORKPRESS_TEST_CLI_OUTPUT' => $history_json]
);
$history_payload = decode_branch_ui_payload($history);
assert_same($history['status'], 0, 'branch history admin action exits cleanly');
assert_same($history_payload['success'] ?? null, true, 'branch history admin action returns JSON success');
assert_same($history_payload['message'] ?? null, 'Loaded 1 merge history run.', 'branch history admin action reports loaded run count');
assert_same($history_payload['recordCount'] ?? null, 1, 'branch history admin action reports record count');
assert_same($history_payload['records'][0]['source_branch'] ?? null, 'feature', 'branch history admin action exposes source branch');
assert_same($history_payload['records'][0]['target_branch'] ?? null, 'main', 'branch history admin action exposes target branch');
assert_same($history_payload['historyCommand'] ?? null, 'forkpress branch history --limit 5 --format json', 'branch history admin action exposes the matching CLI command');
assert_same(count($history['argv']), 1, 'branch history admin action invokes ForkPress CLI once');
assert_same(
array_slice($history['argv'][0] ?? [], 1),
['branch', '--work-dir', $work_dir, 'history', '--limit', '5', '--format', 'json'],
'branch history admin action uses audited branch history CLI path'
);

$conflicted_merge_output = "forkpress: merged feature into main\\n run: 42\\n status: completed_with_conflicts\\n applied: yes\\n conflicts: 3\\n";
$conflicted_merge = run_branch_ui_action(
['action' => 'forkpress_branch_merge', 'source' => 'feature', 'target' => 'main'],
Expand Down Expand Up @@ -1002,6 +1041,18 @@ function decode_branch_ui_payload(array $result): array {
assert_same($invalid_crash_json_payload['success'] ?? null, false, 'branch conflict audit rejects invalid crash recovery JSON');
assert_same($invalid_crash_json_payload['message'] ?? null, 'ForkPress returned invalid crash recovery JSON.', 'branch conflict audit explains invalid crash recovery JSON');

$invalid_history_json = run_branch_ui_action(
['action' => 'forkpress_branch_history', 'limit' => '10'],
['main', 'feature'],
false,
true,
true,
['FORKPRESS_TEST_CLI_OUTPUT' => 'not-json']
);
$invalid_history_payload = decode_branch_ui_payload($invalid_history_json);
assert_same($invalid_history_payload['success'] ?? null, false, 'branch history rejects invalid CLI JSON');
assert_same($invalid_history_payload['message'] ?? null, 'ForkPress returned invalid merge history JSON.', 'branch history explains invalid CLI JSON');

$invalid_create = run_branch_ui_action(
['action' => 'forkpress_branch_create', 'branch' => 'feature branch', 'from' => 'feature'],
['main', 'feature']
Expand Down Expand Up @@ -1059,6 +1110,12 @@ function decode_branch_ui_payload(array $result): array {
$switcher_html = (string)($switcher_render_payload['html'] ?? '');
assert_true(str_contains($switcher_html, 'Open branch manager'), 'branch switcher links to the full branch manager page');
assert_true(str_contains($switcher_html, '/wp-admin/admin.php?page=forkpress-branches'), 'branch switcher uses the wp-admin branch manager URL');
assert_true(str_contains($switcher_html, 'forkpress_branch_history'), 'branch switcher renders branch history action');
assert_true(str_contains($switcher_html, 'nonce-forkpress_branch_history'), 'branch switcher renders branch history nonce');
assert_true(str_contains($switcher_html, 'Show merge history'), 'branch switcher renders branch history button text');
assert_true(str_contains($switcher_html, 'function fetchBranchHistory'), 'branch switcher renders branch history fetch handler');
assert_true(str_contains($switcher_html, 'function renderBranchHistory'), 'branch switcher renders branch history display handler');
assert_true(str_contains($switcher_html, 'Review conflicts'), 'branch switcher can jump from history runs into conflict review');
assert_true(str_contains($switcher_html, 'Create branch'), 'branch switcher renders branch create controls');
assert_true(str_contains($switcher_html, 'forkpress-conflict-list'), 'branch switcher renders conflict audit list container');
assert_true(str_contains($switcher_html, 'forkpress-conflict-summary'), 'branch switcher renders conflict summary container');
Expand Down
140 changes: 139 additions & 1 deletion wp-plugin/forkpress-wp.php
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ function forkpress_branch_run_cli(array $args): array {

function forkpress_branch_wants_json(): bool {
$action = $_REQUEST['action'] ?? '';
if (is_string($action) && in_array($action, ['forkpress_branch_create', 'forkpress_branch_merge', 'forkpress_branch_conflicts', 'forkpress_branch_restore_crash', 'forkpress_branch_revalidate_conflicts', 'forkpress_branch_review_conflict', 'forkpress_branch_resolve_conflict', 'forkpress_branch_apply_reviewed_conflicts', 'forkpress_branch_run_plugin_driver'], true)) {
if (is_string($action) && in_array($action, ['forkpress_branch_create', 'forkpress_branch_merge', 'forkpress_branch_history', 'forkpress_branch_conflicts', 'forkpress_branch_restore_crash', 'forkpress_branch_revalidate_conflicts', 'forkpress_branch_review_conflict', 'forkpress_branch_resolve_conflict', 'forkpress_branch_apply_reviewed_conflicts', 'forkpress_branch_run_plugin_driver'], true)) {
return true;
}

Expand Down Expand Up @@ -1111,6 +1111,17 @@ function forkpress_branch_conflict_audit_summary(array $report, int $run, array
];
}

function forkpress_branch_history_summary(array $report, int $limit): array {
$records = is_array($report['runs'] ?? null) ? array_values($report['runs']) : [];
return [
'records' => $records,
'recordCount' => count($records),
'limit' => $limit,
'historyCommand' => 'forkpress branch history --limit ' . $limit . ' --format json',
'audit' => $report,
];
}

function forkpress_branch_revalidate_merge_run(int $run): array {
[$code, $output] = forkpress_branch_run_cli(['merge-audit', '--revalidate', '--run', (string) $run, '--reviewer', 'wordpress-ui', '--format', 'json']);
if ($code !== 0) {
Expand Down Expand Up @@ -1239,6 +1250,41 @@ function forkpress_handle_branch_merge(): void {
}
add_action('admin_post_forkpress_branch_merge', 'forkpress_handle_branch_merge');

function forkpress_handle_branch_history(): void {
if (!forkpress_branch_can_manage()) {
forkpress_branch_finish_action(forkpress_branch_url(forkpress_current_branch() ?: 'main', '/wp-admin/'), 'error', 'You cannot inspect ForkPress merge history from this site.');
}
if (function_exists('check_admin_referer')) {
check_admin_referer('forkpress_branch_history');
}

$current = forkpress_current_branch() ?: 'main';
$limit = forkpress_branch_post_int('limit') ?? 10;
if ($limit < 1 || $limit > 50) {
forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Choose a merge history limit from 1 to 50.');
}

[$code, $output] = forkpress_branch_run_cli(['history', '--limit', (string) $limit, '--format', 'json']);
if ($code !== 0) {
forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', $output ?: 'ForkPress could not inspect merge history.');
}

$report = json_decode($output, true);
if (!is_array($report)) {
forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'ForkPress returned invalid merge history JSON.');
}

$summary = forkpress_branch_history_summary($report, $limit);
$count = (int)($summary['recordCount'] ?? 0);
forkpress_branch_finish_action(
forkpress_branch_url($current, '/wp-admin/'),
'notice',
$count > 0 ? 'Loaded ' . $count . ' merge history ' . ($count === 1 ? 'run.' : 'runs.') : 'No merge history found.',
$summary
);
}
add_action('admin_post_forkpress_branch_history', 'forkpress_handle_branch_history');

function forkpress_handle_branch_conflicts(): void {
if (!forkpress_branch_can_manage()) {
forkpress_branch_finish_action(forkpress_branch_url(forkpress_current_branch() ?: 'main', '/wp-admin/'), 'error', 'You cannot inspect ForkPress merge conflicts from this site.');
Expand Down Expand Up @@ -2115,6 +2161,7 @@ function forkpress_render_branch_switcher(): void {
'adminPageUrl' => forkpress_branch_admin_page_url(),
'createNonce' => function_exists('wp_create_nonce') ? wp_create_nonce('forkpress_branch_create') : '',
'mergeNonce' => function_exists('wp_create_nonce') ? wp_create_nonce('forkpress_branch_merge') : '',
'historyNonce' => function_exists('wp_create_nonce') ? wp_create_nonce('forkpress_branch_history') : '',
'auditNonce' => function_exists('wp_create_nonce') ? wp_create_nonce('forkpress_branch_conflicts') : '',
'restoreCrashNonce' => function_exists('wp_create_nonce') ? wp_create_nonce('forkpress_branch_restore_crash') : '',
'revalidateNonce' => function_exists('wp_create_nonce') ? wp_create_nonce('forkpress_branch_revalidate_conflicts') : '',
Expand Down Expand Up @@ -2507,6 +2554,87 @@ function renderConflictAudit(payload, fallbackMessage) {
}
}

function renderBranchHistory(payload) {
var records = Array.isArray(payload.records) ? payload.records : [];
showStatus('success', payload.message || 'Loaded merge history.');
clearConflictAudit();
conflictList.className = 'forkpress-conflict-list is-visible';

var heading = document.createElement('div');
heading.className = 'forkpress-conflict-heading';
heading.textContent = 'Recent merge history: ' + String(payload.recordCount || records.length) + ' runs';
conflictList.appendChild(heading);

records.slice(0, 10).forEach(function (run) {
var row = document.createElement('div');
row.className = 'forkpress-conflict-row';
var source = run && run.source_branch ? String(run.source_branch) : '?';
var target = run && run.target_branch ? String(run.target_branch) : '?';
appendConflictText(row, 'forkpress-conflict-title', '#' + String(run && run.id ? run.id : '') + ' ' + source + ' \u2192 ' + target);
appendConflictText(row, 'forkpress-conflict-meta', [
run && run.status ? 'status: ' + String(run.status) : '',
run && run.decision_count !== undefined ? 'decisions: ' + String(run.decision_count) : '',
run && run.conflict_count !== undefined ? 'conflicts: ' + String(run.conflict_count) : '',
run && run.finished_at ? 'finished: ' + String(run.finished_at) : ''
].filter(Boolean).join(' / '));
if (run && run.id && Number(run.conflict_count || 0) > 0) {
var button = document.createElement('button');
button.className = 'forkpress-switcher-button';
button.type = 'button';
button.textContent = 'Review conflicts';
button.addEventListener('click', function (runId) {
return function () {
fetchConflictAudit(runId, 'Loaded conflicts from merge history.');
};
}(run.id));
row.appendChild(button);
}
conflictList.appendChild(row);
});

if (records.length > 10) {
appendConflictText(conflictList, 'forkpress-conflict-meta', String(records.length - 10) + ' more runs in merge history.');
}
appendConflictText(conflictList, 'forkpress-conflict-command', payload.historyCommand || '');
}

function fetchBranchHistory(limit) {
if (!actions || !actions.historyNonce || !window.fetch || !window.FormData) {
return;
}
var body = new FormData();
body.append('action', 'forkpress_branch_history');
body.append('_wpnonce', actions.historyNonce);
body.append('limit', String(limit || 10));
showStatus('warning', 'Loading merge history...');
fetch(actions.url, {
method: 'POST',
body: body,
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-ForkPress-Async': '1'
}
}).then(function (response) {
return response.text().then(function (text) {
var payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (error) {
payload = null;
}
if (!response.ok || !payload || payload.success === false) {
throw new Error(payload && payload.message ? payload.message : (text || 'ForkPress merge history failed.'));
}
return payload;
});
}).then(function (payload) {
renderBranchHistory(payload);
}).catch(function (error) {
showStatus('error', error && error.message ? error.message : 'ForkPress merge history failed.');
});
}

function render() {
var query = input.value.toLowerCase();
var matches = branches.filter(function (branch) {
Expand Down Expand Up @@ -2956,6 +3084,16 @@ function addActions() {
adminLink.textContent = 'Open branch manager';
tools.appendChild(adminLink);
}
if (actions.historyNonce) {
var historyButton = document.createElement('button');
historyButton.className = 'forkpress-switcher-button';
historyButton.type = 'button';
historyButton.textContent = 'Show merge history';
historyButton.addEventListener('click', function () {
fetchBranchHistory(10);
});
tools.appendChild(historyButton);
}

var createForm = document.createElement('form');
createForm.className = 'forkpress-switcher-form';
Expand Down
Loading