emdash-emit 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +6 -0
- package/dist/cli/commands/conflicts.d.ts.map +1 -1
- package/dist/cli/commands/conflicts.js +5 -3
- package/dist/cli/commands/conflicts.js.map +1 -1
- package/dist/cli/commands/delete.d.ts.map +1 -1
- package/dist/cli/commands/delete.js +2 -0
- package/dist/cli/commands/delete.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +5 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/pull.d.ts.map +1 -1
- package/dist/cli/commands/pull.js +9 -1
- package/dist/cli/commands/pull.js.map +1 -1
- package/dist/cli/commands/push.d.ts.map +1 -1
- package/dist/cli/commands/push.js +5 -1
- package/dist/cli/commands/push.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +16 -2
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/context.d.ts.map +1 -1
- package/dist/cli/context.js +12 -1
- package/dist/cli/context.js.map +1 -1
- package/dist/db/cache.d.ts +10 -2
- package/dist/db/cache.d.ts.map +1 -1
- package/dist/db/cache.js +223 -102
- package/dist/db/cache.js.map +1 -1
- package/dist/mcp/heartbeat.d.ts +7 -1
- package/dist/mcp/heartbeat.d.ts.map +1 -1
- package/dist/mcp/heartbeat.js +5 -2
- package/dist/mcp/heartbeat.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +6 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/sync-conflicts.d.ts.map +1 -1
- package/dist/mcp/tools/sync-conflicts.js +28 -5
- package/dist/mcp/tools/sync-conflicts.js.map +1 -1
- package/dist/mcp/tools/sync-delete.d.ts.map +1 -1
- package/dist/mcp/tools/sync-delete.js +5 -0
- package/dist/mcp/tools/sync-delete.js.map +1 -1
- package/dist/mcp/tools/sync-doctor.d.ts.map +1 -1
- package/dist/mcp/tools/sync-doctor.js +5 -0
- package/dist/mcp/tools/sync-doctor.js.map +1 -1
- package/dist/mcp/tools/sync-pull.d.ts.map +1 -1
- package/dist/mcp/tools/sync-pull.js +8 -0
- package/dist/mcp/tools/sync-pull.js.map +1 -1
- package/dist/mcp/tools/sync-push.d.ts.map +1 -1
- package/dist/mcp/tools/sync-push.js +10 -0
- package/dist/mcp/tools/sync-push.js.map +1 -1
- package/dist/mcp/tools/sync-status.d.ts.map +1 -1
- package/dist/mcp/tools/sync-status.js +7 -1
- package/dist/mcp/tools/sync-status.js.map +1 -1
- package/dist/sync/conflicts.d.ts +1 -1
- package/dist/sync/conflicts.d.ts.map +1 -1
- package/dist/sync/conflicts.js +54 -4
- package/dist/sync/conflicts.js.map +1 -1
- package/dist/sync/delete.d.ts +1 -0
- package/dist/sync/delete.d.ts.map +1 -1
- package/dist/sync/delete.js +1 -1
- package/dist/sync/delete.js.map +1 -1
- package/dist/sync/doctor.d.ts +1 -0
- package/dist/sync/doctor.d.ts.map +1 -1
- package/dist/sync/doctor.js +84 -19
- package/dist/sync/doctor.js.map +1 -1
- package/dist/sync/merge.d.ts +11 -0
- package/dist/sync/merge.d.ts.map +1 -1
- package/dist/sync/merge.js +53 -6
- package/dist/sync/merge.js.map +1 -1
- package/dist/sync/orchestrator.d.ts +2 -2
- package/dist/sync/orchestrator.d.ts.map +1 -1
- package/dist/sync/orchestrator.js +5 -1
- package/dist/sync/orchestrator.js.map +1 -1
- package/dist/sync/pull.d.ts +11 -0
- package/dist/sync/pull.d.ts.map +1 -1
- package/dist/sync/pull.js +133 -16
- package/dist/sync/pull.js.map +1 -1
- package/dist/sync/push.d.ts +2 -0
- package/dist/sync/push.d.ts.map +1 -1
- package/dist/sync/push.js +237 -71
- package/dist/sync/push.js.map +1 -1
- package/dist/sync/status.d.ts +2 -0
- package/dist/sync/status.d.ts.map +1 -1
- package/dist/sync/status.js +4 -1
- package/dist/sync/status.js.map +1 -1
- package/dist/sync/taxonomies.d.ts +7 -0
- package/dist/sync/taxonomies.d.ts.map +1 -1
- package/dist/sync/taxonomies.js +28 -1
- package/dist/sync/taxonomies.js.map +1 -1
- package/dist/sync/terms.d.ts +5 -0
- package/dist/sync/terms.d.ts.map +1 -1
- package/dist/sync/terms.js +20 -3
- package/dist/sync/terms.js.map +1 -1
- package/package.json +1 -1
package/dist/sync/push.js
CHANGED
|
@@ -31,11 +31,11 @@ import { classifyCodeFenceLines } from '../client/portable-text.js';
|
|
|
31
31
|
import { computeArticleRelativePath, extractDatePrefixFromPath, parseMarkdown, serializeMarkdown, } from '../utils/frontmatter.js';
|
|
32
32
|
import { loadLocalTaxonomyMaps, loadLocalTaxonomyCandidates } from './taxonomies.js';
|
|
33
33
|
import { runPreflightChecks } from './preflight.js';
|
|
34
|
-
import { calculateHash, CONFLICTS_SUBDIR, isQuarantinedRelativePath } from './pull.js';
|
|
34
|
+
import { calculateHash, CONFLICTS_SUBDIR, isQuarantinedRelativePath, quarantineLocalCopy, } from './pull.js';
|
|
35
35
|
import { hybrid3WayMerge } from './merge.js';
|
|
36
36
|
import { syncRemoteBylines } from './bylines.js';
|
|
37
37
|
import { updateConfigSyncedVersion } from '../utils/config.js';
|
|
38
|
-
import { fetchAssignedTaxonomies } from './terms.js';
|
|
38
|
+
import { fetchAssignedTaxonomies, computeTaxonomyHash } from './terms.js';
|
|
39
39
|
import { fetchRemoteMediaDetail, resolveImageMimeType, toRootRelativeMediaUrl, uploadMediaFile, } from '../client/media.js';
|
|
40
40
|
import { promoteDraftMediaFile } from './media.js';
|
|
41
41
|
export class PushValidationError extends Error {
|
|
@@ -359,6 +359,19 @@ async function uploadAndRewriteDraftImages(body, draftImages, options, fetchFn)
|
|
|
359
359
|
const rewrittenBody = rewriteDraftImagesInBody(body, draftImages, uploadedByAbsPath);
|
|
360
360
|
return { body: rewrittenBody, uploadedByAbsPath };
|
|
361
361
|
}
|
|
362
|
+
function throwRemoteArticleDeleted(collection, id, targetFilePath, endpoint) {
|
|
363
|
+
throw new ApiError({
|
|
364
|
+
endpoint,
|
|
365
|
+
code: 'REMOTE_ARTICLE_DELETED',
|
|
366
|
+
status: 404,
|
|
367
|
+
message: `Article ${id} (${targetFilePath}) was deleted on the remote server.`,
|
|
368
|
+
suggestion: [
|
|
369
|
+
'1. Keep your edits and re-create as a new article: remove the `id:` field from frontmatter, then "emit push <file>".',
|
|
370
|
+
`2. Discard local edits and untrack: "emit pull ${id} --force" (local copy is preserved under posts/conflicts/)`,
|
|
371
|
+
`3. Delete with rescue backup: "emit delete ${targetFilePath} --discard-local-edits"`,
|
|
372
|
+
].join('\n '),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
362
375
|
/**
|
|
363
376
|
* Pushes a local markdown article to remote EmDash with full preflight guards,
|
|
364
377
|
* two-tier lock lease protection, and automatic 3-way merge on conflict.
|
|
@@ -446,8 +459,7 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
446
459
|
};
|
|
447
460
|
}
|
|
448
461
|
}
|
|
449
|
-
// 1.
|
|
450
|
-
// Guard against blind re-push when article is marked delete_pending (unconfirmed remote deletion)
|
|
462
|
+
// 1. Guard against blind re-push when article is marked delete_pending (G16)
|
|
451
463
|
if (syncState?.deletePending === 1 && !options.revive && !options.force) {
|
|
452
464
|
throw new ApiError({
|
|
453
465
|
endpoint: `/_emdash/api/content/${collection}/${id}`,
|
|
@@ -457,16 +469,8 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
457
469
|
suggestion: `To intentionally revive and push this article to remote, pass --revive (or --force): "emit push ${targetFilePath} --revive"`,
|
|
458
470
|
});
|
|
459
471
|
}
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
throw new ApiError({
|
|
463
|
-
endpoint: `/_emdash/api/content/${collection}/${id}`,
|
|
464
|
-
code: 'TAXONOMY_CONFLICT_PENDING',
|
|
465
|
-
status: 409,
|
|
466
|
-
message: `Article ${id} (${targetFilePath}) has an unresolved taxonomy conflict from a previous sync.`,
|
|
467
|
-
suggestion: `Pass --resolve-taxonomy=union|local|remote (or --force) to explicitly resolve and confirm: "emit push ${targetFilePath} --resolve-taxonomy=union"`,
|
|
468
|
-
});
|
|
469
|
-
}
|
|
472
|
+
// 2. Preflight Guards (including G6: UNRESOLVED_CONFLICT_MARKERS)
|
|
473
|
+
// Evaluated before taxonomy conflict pending so markers in markdown body are caught first
|
|
470
474
|
const preflight = runPreflightChecks(targetFilePath, parsed, {
|
|
471
475
|
allowPublish: options.allowPublish,
|
|
472
476
|
knownTaxonomies,
|
|
@@ -480,6 +484,31 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
480
484
|
}
|
|
481
485
|
// Update parsed with cleaned body (e.g. trimmed duplicate heading)
|
|
482
486
|
parsed.body = preflight.cleanedBody;
|
|
487
|
+
// 3. Guard against blind re-push when taxonomy conflict is still pending (G15)
|
|
488
|
+
const isTaxConflictPending = syncState?.taxonomyConflictPending !== undefined
|
|
489
|
+
? syncState.taxonomyConflictPending === 1
|
|
490
|
+
: syncState?.conflictPending === 1 && (syncState?.bodyConflictPending ?? 0) === 0;
|
|
491
|
+
if (isTaxConflictPending && !options.resolveTaxonomy && !options.force) {
|
|
492
|
+
throw new ApiError({
|
|
493
|
+
endpoint: `/_emdash/api/content/${collection}/${id}`,
|
|
494
|
+
code: 'TAXONOMY_CONFLICT_PENDING',
|
|
495
|
+
status: 409,
|
|
496
|
+
message: `Article ${id} (${targetFilePath}) has an unresolved taxonomy conflict from a previous sync.`,
|
|
497
|
+
suggestion: `Pass --resolve-taxonomy=union|local|remote (or --force) to explicitly resolve and confirm: "emit push ${targetFilePath} --resolve-taxonomy=union"`,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
// 4. Guard against blind re-push when previous taxonomy update failed (G17)
|
|
501
|
+
if (syncState?.taxonomyApplyPending === 1 &&
|
|
502
|
+
!options.allowUnappliedTaxonomies &&
|
|
503
|
+
!options.force) {
|
|
504
|
+
throw new ApiError({
|
|
505
|
+
endpoint: `/_emdash/api/content/${collection}/${id}`,
|
|
506
|
+
code: 'TAXONOMY_APPLY_PENDING',
|
|
507
|
+
status: 409,
|
|
508
|
+
message: `Article ${id} (${targetFilePath}) has an unconfirmed taxonomy assignment from a previous failed update.`,
|
|
509
|
+
suggestion: `Run "emit pull-taxonomies" to synchronize taxonomy definitions, then re-push (or pass --allow-unapplied-taxonomies / --force).`,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
483
512
|
const currentRev = syncState?.remoteRev || baseSnapshot?.rev || '';
|
|
484
513
|
const warnings = [...preflight.warnings];
|
|
485
514
|
// Load local bylines map for converting frontmatter byline slugs to ULIDs
|
|
@@ -508,8 +537,9 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
508
537
|
const isNewArticle = !syncState;
|
|
509
538
|
// Helper to synchronize taxonomy assignments
|
|
510
539
|
const syncArticleTaxonomies = async (articleId) => {
|
|
540
|
+
const failures = [];
|
|
511
541
|
if (!parsed.frontmatter.taxonomies)
|
|
512
|
-
return;
|
|
542
|
+
return failures;
|
|
513
543
|
const taxMaps = taxonomyMaps;
|
|
514
544
|
const updateTaxonomy = async (taxonomyName, termSlugsOrIds) => {
|
|
515
545
|
const slugMap = taxMaps.slugToId[taxonomyName] || {};
|
|
@@ -535,7 +565,7 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
535
565
|
: null;
|
|
536
566
|
const prevTerms = taxonomyName === 'category' ? baseFm?.taxonomies?.categories : baseFm?.taxonomies?.tags;
|
|
537
567
|
if (prevTerms && prevTerms.length > 0) {
|
|
538
|
-
if (!options.allowWipeTaxonomies && !options.force) {
|
|
568
|
+
if (!options.allowWipeTaxonomies && !options.force && !options.resolveTaxonomy) {
|
|
539
569
|
throw new ApiError({
|
|
540
570
|
endpoint: `/_emdash/api/content/${collection}/${articleId}/terms/${taxonomyName}`,
|
|
541
571
|
code: 'DESTRUCTIVE_TAXONOMY_WIPE',
|
|
@@ -558,6 +588,12 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
558
588
|
});
|
|
559
589
|
if (!termRes.ok) {
|
|
560
590
|
const errBody = await termRes.text().catch(() => '');
|
|
591
|
+
failures.push({
|
|
592
|
+
taxonomy: taxonomyName,
|
|
593
|
+
status: termRes.status,
|
|
594
|
+
error: errBody || termRes.statusText,
|
|
595
|
+
endpoint: termsUrl,
|
|
596
|
+
});
|
|
561
597
|
warnings.push(`Failed to update ${taxonomyName} terms: ${termRes.statusText} ${errBody}`);
|
|
562
598
|
}
|
|
563
599
|
};
|
|
@@ -567,6 +603,7 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
567
603
|
if (parsed.frontmatter.taxonomies.tags) {
|
|
568
604
|
await updateTaxonomy('tag', parsed.frontmatter.taxonomies.tags);
|
|
569
605
|
}
|
|
606
|
+
return failures;
|
|
570
607
|
};
|
|
571
608
|
if (isNewArticle) {
|
|
572
609
|
// [P2-P4] Upload/resolve standalone draft images and rewrite body
|
|
@@ -600,7 +637,8 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
600
637
|
// Update parsed frontmatter with the actual ID returned by EmDash
|
|
601
638
|
parsed.frontmatter.id = actualId;
|
|
602
639
|
// Sync taxonomies for newly created article
|
|
603
|
-
await syncArticleTaxonomies(actualId);
|
|
640
|
+
const taxFailures = await syncArticleTaxonomies(actualId);
|
|
641
|
+
const hasTaxFailures = taxFailures.length > 0;
|
|
604
642
|
const currentPrefix = extractDatePrefixFromPath(fullPath);
|
|
605
643
|
const dateInput = parsed.frontmatter.publishedAt || currentPrefix || createdItem.createdAt || null;
|
|
606
644
|
const newRelPath = computeArticleRelativePath(parsed.frontmatter.collection, parsed.frontmatter.status, actualId, parsed.frontmatter.title || parsed.frontmatter.slug, dateInput);
|
|
@@ -625,6 +663,9 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
625
663
|
}
|
|
626
664
|
// [P6] Promote uploaded draft images from media/drafts/ into media/ and commit media_state
|
|
627
665
|
finalizePromotedDraftImages(preflight.draftImages, uploadedByAbsPath, options, warnings);
|
|
666
|
+
const finalTaxHash = hasTaxFailures
|
|
667
|
+
? null
|
|
668
|
+
: computeTaxonomyHash(parsed.frontmatter.taxonomies?.categories, parsed.frontmatter.taxonomies?.tags);
|
|
628
669
|
// Update Base snapshot and Sync state
|
|
629
670
|
options.cache.saveBaseSnapshot({
|
|
630
671
|
id: actualId,
|
|
@@ -643,8 +684,23 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
643
684
|
slug: parsed.frontmatter.slug,
|
|
644
685
|
lastSyncedAt: Math.floor(Date.now() / 1000),
|
|
645
686
|
conflictPending: 0,
|
|
687
|
+
bodyConflictPending: 0,
|
|
688
|
+
taxonomyConflictPending: 0,
|
|
646
689
|
conflictCopyHash: null,
|
|
690
|
+
conflictCopyPath: null,
|
|
691
|
+
conflictAt: null,
|
|
692
|
+
remoteTaxonomyHash: finalTaxHash,
|
|
693
|
+
taxonomyApplyPending: hasTaxFailures ? 1 : 0,
|
|
647
694
|
});
|
|
695
|
+
if (hasTaxFailures) {
|
|
696
|
+
throw new ApiError({
|
|
697
|
+
endpoint: taxFailures[0].endpoint,
|
|
698
|
+
code: 'TAXONOMY_UPDATE_FAILED',
|
|
699
|
+
status: taxFailures[0].status || 400,
|
|
700
|
+
message: `Failed to update ${taxFailures.map((f) => f.taxonomy).join(', ')} terms on remote: ${taxFailures.map((f) => f.error).join('; ')}`,
|
|
701
|
+
suggestion: 'Run "emit pull-taxonomies" to update local definitions, verify taxonomy permissions, and retry push.',
|
|
702
|
+
});
|
|
703
|
+
}
|
|
648
704
|
return {
|
|
649
705
|
success: true,
|
|
650
706
|
id: actualId,
|
|
@@ -665,14 +721,66 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
665
721
|
let payload = localToApiPayload(parsed, currentRev, effectiveOptions);
|
|
666
722
|
warnings.push(...preserveOrResolveFeaturedImage(payload, parsed.frontmatter.featuredImage, options.cache, id, options.workspaceRoot));
|
|
667
723
|
const putUrl = new URL(`/_emdash/api/content/${collection}/${id}`, options.origin).toString();
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
724
|
+
// R1: Pre-PUT Taxonomy Audit to detect out-of-band remote taxonomy changes
|
|
725
|
+
let remoteTaxAudit = null;
|
|
726
|
+
let taxonomyDivergenceDetected = false;
|
|
727
|
+
const baseFm = baseSnapshot?.frontmatterYaml
|
|
728
|
+
? JSON.parse(baseSnapshot.frontmatterYaml)
|
|
729
|
+
: null;
|
|
730
|
+
const hasLocalTaxonomies = Boolean(parsed.frontmatter.taxonomies &&
|
|
731
|
+
(parsed.frontmatter.taxonomies.categories !== undefined ||
|
|
732
|
+
parsed.frontmatter.taxonomies.tags !== undefined));
|
|
733
|
+
const hasBaseTaxonomies = Boolean(baseFm?.taxonomies && (baseFm.taxonomies.categories || baseFm.taxonomies.tags));
|
|
734
|
+
if ((hasLocalTaxonomies || hasBaseTaxonomies) && !options.noTaxonomyAudit) {
|
|
735
|
+
remoteTaxAudit = await fetchAssignedTaxonomies({
|
|
736
|
+
origin: options.origin,
|
|
737
|
+
token: options.token,
|
|
738
|
+
collection,
|
|
739
|
+
id,
|
|
740
|
+
fetchFn,
|
|
741
|
+
});
|
|
742
|
+
if (remoteTaxAudit.categories === null && remoteTaxAudit.tags === null) {
|
|
743
|
+
warnings.push('⚠️ Remote taxonomy state unknown (could not audit remote terms).');
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
if (remoteTaxAudit.categories === null) {
|
|
747
|
+
warnings.push('⚠️ Remote category taxonomy state unknown (could not audit categories).');
|
|
748
|
+
}
|
|
749
|
+
if (remoteTaxAudit.tags === null) {
|
|
750
|
+
warnings.push('⚠️ Remote tag taxonomy state unknown (could not audit tags).');
|
|
751
|
+
}
|
|
752
|
+
const baselineTaxHash = syncState?.remoteTaxonomyHash ||
|
|
753
|
+
(baseFm?.taxonomies
|
|
754
|
+
? computeTaxonomyHash(baseFm.taxonomies.categories, baseFm.taxonomies.tags)
|
|
755
|
+
: null);
|
|
756
|
+
const currentRemoteCats = remoteTaxAudit.categories !== null
|
|
757
|
+
? remoteTaxAudit.categories
|
|
758
|
+
: baseFm?.taxonomies?.categories || [];
|
|
759
|
+
const currentRemoteTags = remoteTaxAudit.tags !== null ? remoteTaxAudit.tags : baseFm?.taxonomies?.tags || [];
|
|
760
|
+
const currentRemoteTaxHash = computeTaxonomyHash(currentRemoteCats, currentRemoteTags);
|
|
761
|
+
if (baselineTaxHash && currentRemoteTaxHash !== baselineTaxHash) {
|
|
762
|
+
taxonomyDivergenceDetected = true;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
let res = taxonomyDivergenceDetected
|
|
767
|
+
? {
|
|
768
|
+
status: 409,
|
|
769
|
+
ok: false,
|
|
770
|
+
json: async () => ({ error: { code: 'TAXONOMY_DIVERGENCE' } }),
|
|
771
|
+
text: async () => '',
|
|
772
|
+
}
|
|
773
|
+
: await fetchFn(putUrl, {
|
|
774
|
+
method: 'PUT',
|
|
775
|
+
headers: {
|
|
776
|
+
Authorization: `Bearer ${options.token}`,
|
|
777
|
+
'Content-Type': 'application/json',
|
|
778
|
+
},
|
|
779
|
+
body: JSON.stringify(payload),
|
|
780
|
+
});
|
|
781
|
+
if (!taxonomyDivergenceDetected && (res.status === 404 || res.status === 410)) {
|
|
782
|
+
throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
|
|
783
|
+
}
|
|
676
784
|
// 3. Handle 409 Conflict
|
|
677
785
|
if (res.status === 409) {
|
|
678
786
|
const errJson = (await res.json().catch(() => ({})));
|
|
@@ -686,7 +794,7 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
686
794
|
suggestion: 'Wait for the other user to finish editing or release their lock before retrying.',
|
|
687
795
|
});
|
|
688
796
|
}
|
|
689
|
-
// True revision mismatch conflict: fetch remote latest and run Hybrid 3-Way Merge
|
|
797
|
+
// True revision mismatch conflict or taxonomy divergence: fetch remote latest and run Hybrid 3-Way Merge
|
|
690
798
|
const getUrl = new URL(`/_emdash/api/content/${collection}/${id}`, options.origin).toString();
|
|
691
799
|
const getRes = await fetchFn(getUrl, {
|
|
692
800
|
method: 'GET',
|
|
@@ -696,6 +804,9 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
696
804
|
},
|
|
697
805
|
});
|
|
698
806
|
if (!getRes.ok) {
|
|
807
|
+
if (getRes.status === 404 || getRes.status === 410) {
|
|
808
|
+
throwRemoteArticleDeleted(collection, id, targetFilePath, getUrl);
|
|
809
|
+
}
|
|
699
810
|
throw new ApiError({
|
|
700
811
|
message: `Failed to fetch latest remote version during conflict resolution: ${getRes.statusText}`,
|
|
701
812
|
code: 'CONFLICT_REMOTE_FETCH_FAILED',
|
|
@@ -706,14 +817,15 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
706
817
|
}
|
|
707
818
|
const remoteItem = (await getRes.json());
|
|
708
819
|
const remoteParsed = apiResponseToLocal(remoteItem, options);
|
|
709
|
-
// Fetch remote taxonomy assignments
|
|
710
|
-
const remoteTax =
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
820
|
+
// Fetch remote taxonomy assignments (reuse remoteTaxAudit if available)
|
|
821
|
+
const remoteTax = remoteTaxAudit ||
|
|
822
|
+
(await fetchAssignedTaxonomies({
|
|
823
|
+
origin: options.origin,
|
|
824
|
+
token: options.token,
|
|
825
|
+
collection,
|
|
826
|
+
id,
|
|
827
|
+
fetchFn,
|
|
828
|
+
}));
|
|
717
829
|
if (!remoteParsed.frontmatter.taxonomies) {
|
|
718
830
|
remoteParsed.frontmatter.taxonomies = {};
|
|
719
831
|
}
|
|
@@ -745,10 +857,14 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
745
857
|
const mergeResult = hybrid3WayMerge(parsed, baseInfo, {
|
|
746
858
|
frontmatter: remoteParsed.frontmatter,
|
|
747
859
|
body: remoteParsed.body,
|
|
860
|
+
}, {
|
|
861
|
+
strategy: options.resolveTaxonomy,
|
|
748
862
|
});
|
|
749
863
|
warnings.push(...mergeResult.warnings);
|
|
750
|
-
// Check destructive taxonomy wipe guard during 3-way merge
|
|
751
|
-
if (
|
|
864
|
+
// Check destructive taxonomy wipe guard during 3-way merge (exempt if strategy is explicitly specified)
|
|
865
|
+
if (!options.resolveTaxonomy &&
|
|
866
|
+
mergeResult.wipeWarnings &&
|
|
867
|
+
mergeResult.wipeWarnings.length > 0) {
|
|
752
868
|
if (!options.allowWipeTaxonomies && !options.force) {
|
|
753
869
|
const details = mergeResult.wipeWarnings
|
|
754
870
|
.map((w) => `${w.name}: clearing ${w.remoteTerms.length} assignment(s)`)
|
|
@@ -773,30 +889,35 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
773
889
|
warnings.push(`🏷️ Merged remote taxonomy change(s) for ${item.name}: ${parts.join(' ')}`);
|
|
774
890
|
}
|
|
775
891
|
}
|
|
776
|
-
//
|
|
777
|
-
if (mergeResult.
|
|
778
|
-
|
|
779
|
-
const
|
|
780
|
-
if (
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
}
|
|
798
|
-
|
|
892
|
+
// Log strategy adoptions
|
|
893
|
+
if (mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0) {
|
|
894
|
+
for (const item of mergeResult.strategyAdoptions) {
|
|
895
|
+
const parts = [];
|
|
896
|
+
if (item.discardedAdded.length > 0)
|
|
897
|
+
parts.push(`+${item.discardedAdded.join(', ')}`);
|
|
898
|
+
if (item.discardedRemoved.length > 0)
|
|
899
|
+
parts.push(`-${item.discardedRemoved.join(', ')}`);
|
|
900
|
+
const discardedStr = parts.length > 0 ? `: discarded ${parts.join(' ')}` : '';
|
|
901
|
+
warnings.push(`🏷️ Resolved '${item.name}' with strategy '${item.side}'${discardedStr}.`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// Fallback post-merge resolution only if strategy was NOT specified and taxonomy conflicts occurred
|
|
905
|
+
if (!options.resolveTaxonomy &&
|
|
906
|
+
mergeResult.taxonomyConflicts &&
|
|
907
|
+
mergeResult.taxonomyConflicts.length > 0) {
|
|
908
|
+
if (options.force) {
|
|
909
|
+
const strategy = 'union';
|
|
910
|
+
const unionTaxonomies = {};
|
|
911
|
+
const allTaxNames = new Set([
|
|
912
|
+
...Object.keys(parsed.frontmatter.taxonomies || {}),
|
|
913
|
+
...Object.keys(remoteParsed.frontmatter.taxonomies || {}),
|
|
914
|
+
]);
|
|
915
|
+
for (const tName of allTaxNames) {
|
|
916
|
+
const lTerms = parsed.frontmatter.taxonomies?.[tName] || [];
|
|
917
|
+
const rTerms = remoteParsed.frontmatter.taxonomies?.[tName] || [];
|
|
918
|
+
unionTaxonomies[tName] = Array.from(new Set([...lTerms, ...rTerms]));
|
|
799
919
|
}
|
|
920
|
+
mergeResult.frontmatter.taxonomies = unionTaxonomies;
|
|
800
921
|
warnings.push(`Resolved taxonomy conflicts using strategy '${strategy}'.`);
|
|
801
922
|
}
|
|
802
923
|
else {
|
|
@@ -805,26 +926,36 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
805
926
|
}
|
|
806
927
|
}
|
|
807
928
|
if (mergeResult.hasConflict) {
|
|
808
|
-
const
|
|
809
|
-
|
|
929
|
+
const isTaxonomyConflict = Boolean(mergeResult.taxonomyConflicts && mergeResult.taxonomyConflicts.length > 0);
|
|
930
|
+
const hasBodyConflict = Boolean(mergeResult.body.includes('<<<<<<< LOCAL') || mergeResult.body.includes('<<<<<<< local'));
|
|
931
|
+
const isTaxonomyOnlyConflict = isTaxonomyConflict && !hasBodyConflict;
|
|
810
932
|
const conflictedMarkdown = serializeMarkdown(mergeResult.frontmatter, mergeResult.body, options.candidates);
|
|
933
|
+
let quarantineRel = null;
|
|
811
934
|
// Only write conflict markers into the article body if body actually has conflicts
|
|
812
935
|
if (!isTaxonomyOnlyConflict) {
|
|
936
|
+
const currentDisk = fs.readFileSync(fullPath, 'utf8');
|
|
937
|
+
quarantineRel = quarantineLocalCopy(options.workspaceRoot, collection, targetFilePath, currentDisk, { isForce: false });
|
|
813
938
|
fs.writeFileSync(fullPath, conflictedMarkdown, 'utf8');
|
|
814
939
|
}
|
|
815
940
|
const conflictedHash = calculateHash(conflictedMarkdown);
|
|
816
941
|
// Update sync state:
|
|
817
|
-
// Do NOT overwrite localHash
|
|
942
|
+
// Do NOT overwrite localHash with marker hash, so `status` remains clean when markers are cleaned up!
|
|
818
943
|
options.cache.saveSyncState({
|
|
819
944
|
id,
|
|
820
945
|
localFilePath: targetFilePath,
|
|
821
|
-
localHash:
|
|
946
|
+
localHash: syncState?.localHash || '',
|
|
822
947
|
remoteRev: remoteParsed.rev,
|
|
823
948
|
status: parsed.frontmatter.status,
|
|
824
949
|
slug: parsed.frontmatter.slug,
|
|
825
950
|
lastSyncedAt: Math.floor(Date.now() / 1000),
|
|
826
951
|
conflictPending: 1,
|
|
952
|
+
bodyConflictPending: hasBodyConflict ? 1 : 0,
|
|
953
|
+
taxonomyConflictPending: isTaxonomyConflict ? 1 : 0,
|
|
827
954
|
conflictCopyHash: conflictedHash,
|
|
955
|
+
conflictCopyPath: quarantineRel,
|
|
956
|
+
conflictAt: Math.floor(Date.now() / 1000),
|
|
957
|
+
remoteTaxonomyHash: computeTaxonomyHash(remoteParsed.frontmatter.taxonomies?.categories, remoteParsed.frontmatter.taxonomies?.tags),
|
|
958
|
+
taxonomyApplyPending: 0,
|
|
828
959
|
});
|
|
829
960
|
options.cache.saveBaseSnapshot({
|
|
830
961
|
id,
|
|
@@ -858,17 +989,23 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
858
989
|
body: JSON.stringify(payload),
|
|
859
990
|
});
|
|
860
991
|
if (!res.ok) {
|
|
992
|
+
if (res.status === 404 || res.status === 410) {
|
|
993
|
+
throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
|
|
994
|
+
}
|
|
861
995
|
const errText = await res.text().catch(() => '');
|
|
862
|
-
// Check if server rejected the merged taxonomy union (e.g. single category limit)
|
|
996
|
+
// Check if server rejected the merged taxonomy union or strategy (e.g. single category limit)
|
|
863
997
|
if ((res.status === 400 || res.status === 422) &&
|
|
864
|
-
mergeResult.autoMergedTaxonomies &&
|
|
865
|
-
|
|
998
|
+
((mergeResult.autoMergedTaxonomies && mergeResult.autoMergedTaxonomies.length > 0) ||
|
|
999
|
+
(mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0))) {
|
|
1000
|
+
const wasStrategy = mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0;
|
|
866
1001
|
throw new ApiError({
|
|
867
1002
|
message: `Remote server rejected the merged taxonomy union (HTTP ${res.status}): ${errText || res.statusText}`,
|
|
868
1003
|
code: 'TAXONOMY_UNION_REJECTED',
|
|
869
1004
|
status: res.status,
|
|
870
1005
|
endpoint: putUrl,
|
|
871
|
-
suggestion:
|
|
1006
|
+
suggestion: wasStrategy
|
|
1007
|
+
? 'The remote server rejected the taxonomy adopted by your strategy (e.g. single-category constraint). Switch strategy with "--resolve-taxonomy=remote" or edit the frontmatter manually before pushing.'
|
|
1008
|
+
: 'The remote server enforces constraints that reject combining local and remote taxonomy terms (e.g. single-category constraint). Resolve by explicitly choosing a side: "emit push <file> --resolve-taxonomy=local" (or remote), or edit the frontmatter manually.',
|
|
872
1009
|
});
|
|
873
1010
|
}
|
|
874
1011
|
throw new ApiError({
|
|
@@ -881,6 +1018,9 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
881
1018
|
}
|
|
882
1019
|
}
|
|
883
1020
|
if (!res.ok) {
|
|
1021
|
+
if (res.status === 404 || res.status === 410) {
|
|
1022
|
+
throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
|
|
1023
|
+
}
|
|
884
1024
|
const errText = await res.text().catch(() => '');
|
|
885
1025
|
throw new ApiError({
|
|
886
1026
|
message: `Push failed: ${res.statusText}${errText ? ` - ${errText}` : ''}`,
|
|
@@ -894,7 +1034,8 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
894
1034
|
const newRev = extractRev(updatedJson, currentRev);
|
|
895
1035
|
// 4. Post-push updates & Path tracking
|
|
896
1036
|
// Sync taxonomies on updated article
|
|
897
|
-
await syncArticleTaxonomies(id);
|
|
1037
|
+
const taxFailures = await syncArticleTaxonomies(id);
|
|
1038
|
+
const hasTaxFailures = taxFailures.length > 0;
|
|
898
1039
|
const currentPrefix = extractDatePrefixFromPath(fullPath);
|
|
899
1040
|
const existingSync = options.cache.getSyncState(parsed.frontmatter.id);
|
|
900
1041
|
const dateInput = parsed.frontmatter.publishedAt ||
|
|
@@ -932,11 +1073,21 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
932
1073
|
// [P6] Promote uploaded draft images from media/drafts/ into media/ and commit media_state
|
|
933
1074
|
finalizePromotedDraftImages(preflight.draftImages, uploadedByAbsPath, options, warnings);
|
|
934
1075
|
// Update Base snapshot
|
|
1076
|
+
const savedFrontmatter = { ...parsed.frontmatter };
|
|
1077
|
+
if (hasTaxFailures && baseFm?.taxonomies) {
|
|
1078
|
+
savedFrontmatter.taxonomies = { ...baseFm.taxonomies };
|
|
1079
|
+
}
|
|
1080
|
+
const finalTaxHash = hasTaxFailures
|
|
1081
|
+
? syncState?.remoteTaxonomyHash ||
|
|
1082
|
+
(baseFm?.taxonomies
|
|
1083
|
+
? computeTaxonomyHash(baseFm.taxonomies.categories, baseFm.taxonomies.tags)
|
|
1084
|
+
: null)
|
|
1085
|
+
: computeTaxonomyHash(parsed.frontmatter.taxonomies?.categories, parsed.frontmatter.taxonomies?.tags);
|
|
935
1086
|
options.cache.saveBaseSnapshot({
|
|
936
1087
|
id,
|
|
937
1088
|
rev: finalRev,
|
|
938
1089
|
bodyMarkdown: parsed.body,
|
|
939
|
-
frontmatterYaml: JSON.stringify(
|
|
1090
|
+
frontmatterYaml: JSON.stringify(savedFrontmatter),
|
|
940
1091
|
hash: newHash,
|
|
941
1092
|
savedAt: Math.floor(Date.now() / 1000),
|
|
942
1093
|
});
|
|
@@ -949,9 +1100,24 @@ export async function pushArticle(targetFilePath, options) {
|
|
|
949
1100
|
status: parsed.frontmatter.status,
|
|
950
1101
|
slug: parsed.frontmatter.slug,
|
|
951
1102
|
lastSyncedAt: Math.floor(Date.now() / 1000),
|
|
952
|
-
conflictPending: 0,
|
|
953
|
-
|
|
1103
|
+
conflictPending: hasTaxFailures ? (syncState?.conflictPending ?? 0) : 0,
|
|
1104
|
+
bodyConflictPending: hasTaxFailures ? (syncState?.bodyConflictPending ?? 0) : 0,
|
|
1105
|
+
taxonomyConflictPending: hasTaxFailures ? (syncState?.taxonomyConflictPending ?? 0) : 0,
|
|
1106
|
+
conflictCopyHash: hasTaxFailures ? syncState?.conflictCopyHash : null,
|
|
1107
|
+
conflictCopyPath: hasTaxFailures ? syncState?.conflictCopyPath : null,
|
|
1108
|
+
conflictAt: hasTaxFailures ? syncState?.conflictAt : null,
|
|
1109
|
+
remoteTaxonomyHash: finalTaxHash,
|
|
1110
|
+
taxonomyApplyPending: hasTaxFailures ? 1 : 0,
|
|
954
1111
|
});
|
|
1112
|
+
if (hasTaxFailures) {
|
|
1113
|
+
throw new ApiError({
|
|
1114
|
+
endpoint: taxFailures[0].endpoint,
|
|
1115
|
+
code: 'TAXONOMY_UPDATE_FAILED',
|
|
1116
|
+
status: taxFailures[0].status || 400,
|
|
1117
|
+
message: `Failed to update ${taxFailures.map((f) => f.taxonomy).join(', ')} terms on remote: ${taxFailures.map((f) => f.error).join('; ')}`,
|
|
1118
|
+
suggestion: 'Run "emit pull-taxonomies" to update local definitions, verify taxonomy permissions, and retry push.',
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
955
1121
|
updateConfigSyncedVersion(options.workspaceRoot);
|
|
956
1122
|
return {
|
|
957
1123
|
success: true,
|