@indigoai-us/hq-cloud 6.14.4 → 6.14.6
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/dist/cli/rescue-clone-diagnostics.test.d.ts +2 -0
- package/dist/cli/rescue-clone-diagnostics.test.d.ts.map +1 -0
- package/dist/cli/rescue-clone-diagnostics.test.js +101 -0
- package/dist/cli/rescue-clone-diagnostics.test.js.map +1 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +28 -6
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.js +26 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +87 -0
- package/dist/cli/share.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/rescue-clone-diagnostics.test.ts +120 -0
- package/src/cli/rescue-core.ts +32 -8
- package/src/cli/share.test.ts +101 -0
- package/src/cli/share.ts +32 -36
package/package.json
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { runRescue } from "./rescue-core.js";
|
|
6
|
+
|
|
7
|
+
function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
8
|
+
let stdout = "";
|
|
9
|
+
let stderr = "";
|
|
10
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
11
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
12
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
13
|
+
stdout += String(chunk);
|
|
14
|
+
return true;
|
|
15
|
+
}) as typeof process.stdout.write;
|
|
16
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
17
|
+
stderr += String(chunk);
|
|
18
|
+
return true;
|
|
19
|
+
}) as typeof process.stderr.write;
|
|
20
|
+
let status: number;
|
|
21
|
+
try {
|
|
22
|
+
status = runRescue(argv, { env }).status;
|
|
23
|
+
} finally {
|
|
24
|
+
process.stdout.write = origOut;
|
|
25
|
+
process.stderr.write = origErr;
|
|
26
|
+
}
|
|
27
|
+
return { status, stdout, stderr };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("rescue clone diagnostics", () => {
|
|
31
|
+
let workDir: string;
|
|
32
|
+
let hqRoot: string;
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-clone-diagnostics-"));
|
|
36
|
+
hqRoot = path.join(workDir, "hq");
|
|
37
|
+
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
38
|
+
fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function envWithGit(name: string, script: string): NodeJS.ProcessEnv {
|
|
46
|
+
const shimDir = path.join(workDir, name);
|
|
47
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
48
|
+
fs.writeFileSync(path.join(shimDir, "git"), script, { mode: 0o755 });
|
|
49
|
+
return {
|
|
50
|
+
...process.env,
|
|
51
|
+
GH_TOKEN: "ghp_clone_secret_123",
|
|
52
|
+
PATH: `${shimDir}:${process.env.PATH ?? ""}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rescueArgs(extra: string[] = []): string[] {
|
|
57
|
+
return [
|
|
58
|
+
"--hq-root", hqRoot,
|
|
59
|
+
"--source", "test/repo",
|
|
60
|
+
"--ref", "main",
|
|
61
|
+
"--dry-run",
|
|
62
|
+
"--yes",
|
|
63
|
+
"--no-backup",
|
|
64
|
+
...extra,
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
it("surfaces redacted stderr when the full-history clone fails", () => {
|
|
69
|
+
const env = envWithGit(
|
|
70
|
+
"full-history",
|
|
71
|
+
`#!/usr/bin/env bash
|
|
72
|
+
printf 'fatal: clone args: %s\n' "$*" >&2
|
|
73
|
+
printf 'mirror: https://user:url-secret@example.com/repo.git\n' >&2
|
|
74
|
+
printf 'Authorization: Bearer header-secret\n' >&2
|
|
75
|
+
exit 42
|
|
76
|
+
`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const r = runRescueCapture(rescueArgs(), env);
|
|
80
|
+
|
|
81
|
+
expect(r.status).toBe(5);
|
|
82
|
+
expect(r.stderr).toContain("fatal: clone args:");
|
|
83
|
+
expect(r.stderr).toContain("https://***@github.com/test/repo.git");
|
|
84
|
+
expect(r.stderr).toContain("mirror: https://***@example.com/repo.git");
|
|
85
|
+
expect(r.stderr).toContain("Authorization: ***");
|
|
86
|
+
expect(r.stderr).toContain("error: clone failed");
|
|
87
|
+
expect(r.stderr.indexOf("fatal: clone args:")).toBeLessThan(
|
|
88
|
+
r.stderr.indexOf("error: clone failed"),
|
|
89
|
+
);
|
|
90
|
+
expect(r.stderr).not.toContain("ghp_clone_secret_123");
|
|
91
|
+
expect(r.stderr).not.toContain("url-secret");
|
|
92
|
+
expect(r.stderr).not.toContain("header-secret");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("surfaces stderr from the shallow attempt and stdout from the failed fallback", () => {
|
|
96
|
+
const env = envWithGit(
|
|
97
|
+
"shallow-fallback",
|
|
98
|
+
`#!/usr/bin/env bash
|
|
99
|
+
if [ "$2" = "--depth" ]; then
|
|
100
|
+
printf 'fatal: shallow clone diagnostic\n' >&2
|
|
101
|
+
exit 41
|
|
102
|
+
fi
|
|
103
|
+
printf 'fatal: fallback clone diagnostic for %s\n' "$*"
|
|
104
|
+
exit 42
|
|
105
|
+
`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const r = runRescueCapture(rescueArgs(["--no-history-check"]), env);
|
|
109
|
+
|
|
110
|
+
expect(r.status).toBe(5);
|
|
111
|
+
expect(r.stderr).toContain("fatal: shallow clone diagnostic");
|
|
112
|
+
expect(r.stderr).toContain("fatal: fallback clone diagnostic");
|
|
113
|
+
expect(r.stderr).toContain("https://***@github.com/test/repo.git");
|
|
114
|
+
expect(r.stderr).toContain("error: clone failed");
|
|
115
|
+
expect(r.stderr.indexOf("fatal: fallback clone diagnostic")).toBeLessThan(
|
|
116
|
+
r.stderr.indexOf("error: clone failed"),
|
|
117
|
+
);
|
|
118
|
+
expect(r.stderr).not.toContain("ghp_clone_secret_123");
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/cli/rescue-core.ts
CHANGED
|
@@ -105,6 +105,14 @@ function run(
|
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
function redactGitDiagnostic(output: string, ghToken: string): string {
|
|
109
|
+
let redacted = output;
|
|
110
|
+
if (ghToken) redacted = redacted.replaceAll(ghToken, "***");
|
|
111
|
+
return redacted
|
|
112
|
+
.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, "$1***@")
|
|
113
|
+
.replace(/(authorization\s*:\s*)[^\r\n]*/gi, "$1***");
|
|
114
|
+
}
|
|
115
|
+
|
|
108
116
|
/** `command -v <bin>` — is a binary on PATH? */
|
|
109
117
|
function hasCmd(bin: string, env: NodeJS.ProcessEnv): boolean {
|
|
110
118
|
const r = spawnSync(bin, ["--version"], { env, stdio: "ignore" });
|
|
@@ -551,29 +559,45 @@ function doRescue(
|
|
|
551
559
|
cloneUrlDisplay = cloneUrl;
|
|
552
560
|
}
|
|
553
561
|
|
|
562
|
+
const reportGitFailure = (result: ReturnType<typeof run>) => {
|
|
563
|
+
const diagnostic = result.stderr.trim() || result.stdout.trim();
|
|
564
|
+
if (diagnostic) err(`${redactGitDiagnostic(diagnostic, ghToken)}\n`);
|
|
565
|
+
};
|
|
566
|
+
|
|
554
567
|
out("\n");
|
|
555
568
|
if (cfg.historyCheck) {
|
|
556
569
|
out(`==> Cloning ${cloneUrlDisplay} @${cfg.ref} (full history, blob:none filter) ...\n`);
|
|
557
|
-
|
|
570
|
+
const cloneResult = run("git", ["clone", "--filter=blob:none", cloneUrl, srcDir], { env });
|
|
571
|
+
if (cloneResult.status !== 0) {
|
|
572
|
+
reportGitFailure(cloneResult);
|
|
558
573
|
err("error: clone failed\n");
|
|
559
574
|
throw new ExitError(5);
|
|
560
575
|
}
|
|
561
|
-
|
|
576
|
+
const checkoutResult = run("git", ["checkout", cfg.ref], { cwd: srcDir, env });
|
|
577
|
+
if (checkoutResult.status !== 0) {
|
|
578
|
+
reportGitFailure(checkoutResult);
|
|
562
579
|
err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
|
|
563
580
|
throw new ExitError(5);
|
|
564
581
|
}
|
|
565
582
|
} else {
|
|
566
583
|
out(`==> Cloning ${cloneUrlDisplay} @${cfg.ref} (shallow) ...\n`);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
584
|
+
const shallowCloneResult = run(
|
|
585
|
+
"git",
|
|
586
|
+
["clone", "--depth", "1", "--branch", cfg.ref, cloneUrl, srcDir],
|
|
587
|
+
{ env },
|
|
588
|
+
);
|
|
589
|
+
if (shallowCloneResult.status !== 0) {
|
|
590
|
+
reportGitFailure(shallowCloneResult);
|
|
571
591
|
out(" (shallow branch clone failed; trying full clone + checkout)\n");
|
|
572
|
-
|
|
592
|
+
const cloneResult = run("git", ["clone", cloneUrl, srcDir], { env });
|
|
593
|
+
if (cloneResult.status !== 0) {
|
|
594
|
+
reportGitFailure(cloneResult);
|
|
573
595
|
err("error: clone failed\n");
|
|
574
596
|
throw new ExitError(5);
|
|
575
597
|
}
|
|
576
|
-
|
|
598
|
+
const checkoutResult = run("git", ["checkout", cfg.ref], { cwd: srcDir, env });
|
|
599
|
+
if (checkoutResult.status !== 0) {
|
|
600
|
+
reportGitFailure(checkoutResult);
|
|
577
601
|
err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
|
|
578
602
|
throw new ExitError(5);
|
|
579
603
|
}
|
package/src/cli/share.test.ts
CHANGED
|
@@ -595,6 +595,60 @@ describe("share", () => {
|
|
|
595
595
|
});
|
|
596
596
|
});
|
|
597
597
|
|
|
598
|
+
it("cloud-authoritative: never pushes a stale local ontology/.last-run over the server value (one-sided-change clobber guard)", async () => {
|
|
599
|
+
// Regression for the gardener watermark clobber. A second HQ root sharing this
|
|
600
|
+
// machine's sync journal can leave the local mirror holding a STALE value A while
|
|
601
|
+
// the shared journal has already been advanced to the gardener's value B (etag EB).
|
|
602
|
+
// On this root's push leg that reads as localChanged (A != journal B) &&
|
|
603
|
+
// !remoteChanged (remote etag EB == journal remoteEtag EB). Previously that
|
|
604
|
+
// one-sided combination slipped past the CONFLICT-scoped isCloudAuthoritative
|
|
605
|
+
// fence and PUT the stale local value back over the server's — rewinding it. The
|
|
606
|
+
// fence now runs at the TOP of processUploadItem, so any ontology/ (server-owned)
|
|
607
|
+
// path is skipped before HEAD/upload regardless of the divergence shape.
|
|
608
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
609
|
+
fs.mkdirSync(path.join(companyRoot, "ontology"), { recursive: true });
|
|
610
|
+
const testFile = path.join(companyRoot, "ontology", ".last-run");
|
|
611
|
+
fs.writeFileSync(testFile, "1782275168000"); // stale value A
|
|
612
|
+
|
|
613
|
+
// The journal is already advanced to the gardener's value B (a sibling HQ root's
|
|
614
|
+
// pull stamped the shared journal), so localChanged=true. The fence skips the file
|
|
615
|
+
// BEFORE any HEAD/upload, so we deliberately queue NO headRemoteFile response and
|
|
616
|
+
// assert it is never consulted below — the skip is cheap, and a leftover
|
|
617
|
+
// mockResolvedValueOnce would otherwise leak into a later test's HEAD queue.
|
|
618
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
619
|
+
fs.writeFileSync(
|
|
620
|
+
journalPath,
|
|
621
|
+
JSON.stringify({
|
|
622
|
+
version: "1",
|
|
623
|
+
lastSync: new Date().toISOString(),
|
|
624
|
+
files: {
|
|
625
|
+
"ontology/.last-run": {
|
|
626
|
+
hash: "advanced-value-B-hash", // != hash(A) → localChanged=true
|
|
627
|
+
size: 13,
|
|
628
|
+
syncedAt: new Date().toISOString(),
|
|
629
|
+
direction: "up",
|
|
630
|
+
remoteEtag: "eb-advanced-etag", // == remote → remoteChanged=false
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
}),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const result = await share({
|
|
637
|
+
paths: [testFile],
|
|
638
|
+
company: "acme",
|
|
639
|
+
vaultConfig: mockConfig,
|
|
640
|
+
hqRoot: tmpDir,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Server-owned file: push is skipped BEFORE HEAD, the stale local value is NEVER
|
|
644
|
+
// uploaded (the pull leg refreshes local from cloud instead), and it is not a conflict.
|
|
645
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
646
|
+
expect(headRemoteFile).not.toHaveBeenCalled();
|
|
647
|
+
expect(result.filesUploaded).toBe(0);
|
|
648
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
649
|
+
expect(result.conflictPaths).toEqual([]);
|
|
650
|
+
});
|
|
651
|
+
|
|
598
652
|
it("first-time-upload-with-cloud-collision: emits conflict + writes mirror under --on-conflict keep (Bug #7)", async () => {
|
|
599
653
|
// Bug #7 (data-loss class) from the 5.33.0 deep test: when a file has
|
|
600
654
|
// NO prior journal entry (fresh upload from this machine) but the
|
|
@@ -684,6 +738,53 @@ describe("share", () => {
|
|
|
684
738
|
expect(result.filesUploaded).toBe(0);
|
|
685
739
|
});
|
|
686
740
|
|
|
741
|
+
it("fresh-install SSE-KMS etag: byte-identical remote is reconciled without a conflict", async () => {
|
|
742
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
743
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
744
|
+
const testFile = path.join(companyRoot, "kms-scaffold.md");
|
|
745
|
+
const scaffoldBytes = "identical-content-encrypted-with-sse-kms";
|
|
746
|
+
fs.writeFileSync(testFile, scaffoldBytes);
|
|
747
|
+
|
|
748
|
+
// A 32-hex SSE-KMS ETag is not the MD5 of the plaintext object.
|
|
749
|
+
vi.mocked(headRemoteFile).mockResolvedValueOnce({
|
|
750
|
+
lastModified: new Date(),
|
|
751
|
+
etag: '"288bfa91684ae21f9ae27c061254d412"',
|
|
752
|
+
size: scaffoldBytes.length,
|
|
753
|
+
});
|
|
754
|
+
vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
|
|
755
|
+
fs.writeFileSync(dest as string, scaffoldBytes);
|
|
756
|
+
return undefined as never;
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
const events: unknown[] = [];
|
|
760
|
+
const result = await share({
|
|
761
|
+
paths: [testFile],
|
|
762
|
+
company: "acme",
|
|
763
|
+
vaultConfig: mockConfig,
|
|
764
|
+
hqRoot: tmpDir,
|
|
765
|
+
onConflict: "keep",
|
|
766
|
+
onEvent: (e) => events.push(e),
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
expect(result.conflictPaths).toEqual([]);
|
|
770
|
+
expect(result.filesUploaded).toBe(0);
|
|
771
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
772
|
+
expect(events).toContainEqual({
|
|
773
|
+
type: "reconciled",
|
|
774
|
+
path: "kms-scaffold.md",
|
|
775
|
+
direction: "push",
|
|
776
|
+
});
|
|
777
|
+
expect(fs.readdirSync(companyRoot).filter((name) => name.includes(".conflict-"))).toEqual([]);
|
|
778
|
+
|
|
779
|
+
const journal = JSON.parse(
|
|
780
|
+
fs.readFileSync(path.join(stateDir, "sync-journal.acme.json"), "utf-8"),
|
|
781
|
+
) as { files: Record<string, { direction: string; remoteEtag?: string }> };
|
|
782
|
+
expect(journal.files["kms-scaffold.md"]).toMatchObject({
|
|
783
|
+
direction: "up",
|
|
784
|
+
remoteEtag: "288bfa91684ae21f9ae27c061254d412",
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
687
788
|
it("fresh-install multipart collision: byte-identical remote is NOT a conflict (reconciled, no mirror)", async () => {
|
|
688
789
|
// Root cause of "HQ installs with conflicts": on a first push the
|
|
689
790
|
// journal is empty, so the fresh-collision branch decides conflict-vs-
|
package/src/cli/share.ts
CHANGED
|
@@ -70,11 +70,11 @@ import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
|
70
70
|
* Push-side fresh-collision convergence probe.
|
|
71
71
|
*
|
|
72
72
|
* For a first push (no journal entry) where the remote object already
|
|
73
|
-
* exists
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
73
|
+
* exists, its etag is a version token rather than a reliable content hash,
|
|
74
|
+
* so we cannot tell "byte-identical" from "genuine divergence" without
|
|
75
|
+
* looking at the bytes. Fetch the remote object once to a throwaway temp
|
|
76
|
+
* file, hash it the same symlink-aware way the planner hashed local, and
|
|
77
|
+
* report whether the two contents DIFFER.
|
|
78
78
|
*
|
|
79
79
|
* Returns `true` on a genuine difference (a real fresh collision) and
|
|
80
80
|
* `false` when the bytes are identical. Fails safe to `true` on any
|
|
@@ -1232,6 +1232,19 @@ async function executeUploads(
|
|
|
1232
1232
|
if (aborted) return;
|
|
1233
1233
|
const { absolutePath, relativePath, localHash } = item;
|
|
1234
1234
|
|
|
1235
|
+
// Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
|
|
1236
|
+
// ontology/ signals/ sources/) are PULL-WINS and server-owned. The push leg must
|
|
1237
|
+
// NEVER upload the local copy over them — not just on a two-sided conflict, but on
|
|
1238
|
+
// ANY divergence, including a one-sided local change. A stale local mirror (e.g. a
|
|
1239
|
+
// second HQ root that shares this machine's sync journal) would otherwise read as
|
|
1240
|
+
// `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
|
|
1241
|
+
// rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
|
|
1242
|
+
if (isCloudAuthoritative(relativePath)) {
|
|
1243
|
+
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1244
|
+
counters.filesSkipped++;
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1235
1248
|
if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
|
|
1236
1249
|
run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
|
|
1237
1250
|
}
|
|
@@ -1252,34 +1265,24 @@ async function executeUploads(
|
|
|
1252
1265
|
const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
|
|
1253
1266
|
|
|
1254
1267
|
let isFreshCollision = false;
|
|
1255
|
-
let
|
|
1268
|
+
let freshContentConverged = false;
|
|
1256
1269
|
if (!journalEntry && item.kind === "symlink") {
|
|
1257
1270
|
isFreshCollision = true;
|
|
1258
1271
|
} else if (!journalEntry && item.kind === "file") {
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1272
|
+
const remoteDiffers = await remoteContentDiffers(
|
|
1273
|
+
run.ctx,
|
|
1274
|
+
relativePath,
|
|
1275
|
+
localHash,
|
|
1276
|
+
run.hqRoot,
|
|
1277
|
+
);
|
|
1278
|
+
if (remoteDiffers) {
|
|
1279
|
+
isFreshCollision = true;
|
|
1267
1280
|
} else {
|
|
1268
|
-
|
|
1269
|
-
run.ctx,
|
|
1270
|
-
relativePath,
|
|
1271
|
-
localHash,
|
|
1272
|
-
run.hqRoot,
|
|
1273
|
-
);
|
|
1274
|
-
if (remoteDiffers) {
|
|
1275
|
-
isFreshCollision = true;
|
|
1276
|
-
} else {
|
|
1277
|
-
multipartConverged = true;
|
|
1278
|
-
}
|
|
1281
|
+
freshContentConverged = true;
|
|
1279
1282
|
}
|
|
1280
1283
|
}
|
|
1281
1284
|
|
|
1282
|
-
if (
|
|
1285
|
+
if (freshContentConverged) {
|
|
1283
1286
|
const lstat = fs.lstatSync(absolutePath);
|
|
1284
1287
|
updateEntry(
|
|
1285
1288
|
run.journal,
|
|
@@ -1296,16 +1299,9 @@ async function executeUploads(
|
|
|
1296
1299
|
}
|
|
1297
1300
|
|
|
1298
1301
|
if ((localChanged && remoteChanged) || isFreshCollision) {
|
|
1299
|
-
// Cloud-authoritative paths
|
|
1300
|
-
//
|
|
1301
|
-
//
|
|
1302
|
-
// mirror — the pull leg overwrites local from cloud. Declining here is
|
|
1303
|
-
// what stops the per-sync conflict-mirror loop on these files.
|
|
1304
|
-
if (isCloudAuthoritative(relativePath)) {
|
|
1305
|
-
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1306
|
-
counters.filesSkipped++;
|
|
1307
|
-
return;
|
|
1308
|
-
}
|
|
1302
|
+
// Cloud-authoritative paths are already skipped at the top of
|
|
1303
|
+
// processUploadItem (before HEAD), so anything reaching here is a genuine
|
|
1304
|
+
// conflict on a client-owned file.
|
|
1309
1305
|
conflictPaths.push(relativePath);
|
|
1310
1306
|
|
|
1311
1307
|
const resolution = await resolveConflictSerialized({
|