@indigoai-us/hq-cloud 6.2.0 → 6.2.2

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.
@@ -28,6 +28,13 @@ import { spawnSync } from "child_process";
28
28
  import * as fs from "fs";
29
29
  import * as os from "os";
30
30
  import * as path from "path";
31
+ import {
32
+ readJournal,
33
+ writeJournal,
34
+ hashFile,
35
+ updateEntry,
36
+ PERSONAL_VAULT_JOURNAL_SLUG,
37
+ } from "../journal.js";
31
38
 
32
39
  export interface RunRescueResult {
33
40
  status: number;
@@ -792,7 +799,7 @@ function doRescue(
792
799
  }
793
800
  const overlay = run(
794
801
  "rsync",
795
- ["-a", ...overlayProtect, ...rsyncExcludes, srcDir + "/", hqRoot + "/"],
802
+ ["-ai", ...overlayProtect, ...rsyncExcludes, srcDir + "/", hqRoot + "/"],
796
803
  { env },
797
804
  );
798
805
  if (overlay.status !== 0) {
@@ -818,6 +825,68 @@ function doRescue(
818
825
  }
819
826
  }
820
827
 
828
+ // --- Reconcile the sync-journal baseline for re-laid files ---
829
+ // The overlay rewrote scaffold files from upstream, but their personal-vault
830
+ // sync-journal entries still carry the PRE-rescue hash. The next sync would
831
+ // then read localHash != journal.hash ("local changed") and — when the vault
832
+ // also moved — mint a false `.conflict-*` mirror (the root cause of the
833
+ // conflict-file litter). Re-stamp the baseline to the freshly laid-down
834
+ // content so `localChanged` is false: the common single-machine case
835
+ // converges silently, and a genuinely-divergent vault degrades to a clean
836
+ // pull instead of a conflict mirror.
837
+ //
838
+ // Scoped STRICTLY to the paths rsync reported transferring (`-i` itemize) —
839
+ // never a blanket journal rewrite — so a user's unsynced pending edit (which
840
+ // the walk already rescued to personal/ rather than overlaying) is never
841
+ // silently marked as synced. Best-effort: a reconcile failure must never
842
+ // fail the rescue.
843
+ try {
844
+ const relaid: string[] = [];
845
+ for (const line of overlay.stdout.split("\n")) {
846
+ // itemize line: 11-char change code, space, path. 2nd col `f` = file.
847
+ const m = /^[<>ch.]f\S{9} (.+)$/.exec(line);
848
+ if (m) relaid.push(m[1]);
849
+ }
850
+ if (relaid.length > 0) {
851
+ const journal = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
852
+ let restamped = 0;
853
+ for (const rel of relaid) {
854
+ const prev = journal.files[rel];
855
+ if (!prev) continue; // only re-stamp paths already in the synced baseline
856
+ const abs = path.join(hqRoot, rel);
857
+ let st: fs.Stats;
858
+ try {
859
+ st = fs.lstatSync(abs);
860
+ } catch {
861
+ continue;
862
+ }
863
+ if (st.isSymbolicLink() || !st.isFile()) continue;
864
+ updateEntry(
865
+ journal,
866
+ rel,
867
+ hashFile(abs),
868
+ st.size,
869
+ prev.direction,
870
+ prev.remoteEtag,
871
+ st.mtimeMs,
872
+ );
873
+ restamped++;
874
+ }
875
+ if (restamped > 0) {
876
+ writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, journal);
877
+ out(
878
+ `==> Reconciled sync-journal baseline for ${restamped} re-laid file(s)\n`,
879
+ );
880
+ }
881
+ }
882
+ } catch (e) {
883
+ out(
884
+ ` (sync-journal reconcile skipped: ${
885
+ e instanceof Error ? e.message : String(e)
886
+ })\n`,
887
+ );
888
+ }
889
+
821
890
  // --- Stamp sync-point provenance into core/core.yaml ---
822
891
  if (cfg.narrowPaths.length === 0 && isFileFollow(coreYaml)) {
823
892
  const nowUtc = utcStamp(new Date(), "colon");
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Regression for the rescue → sync-journal stale-baseline bug.
3
+ *
4
+ * Root cause: the rescue overlay rewrites scaffold files from upstream but
5
+ * never re-stamps the personal-vault sync journal. The journal keeps the
6
+ * PRE-rescue hash, so the next sync reads localHash != journal.hash ("local
7
+ * changed") and — when the vault also moved — mints a false `.conflict-*`
8
+ * mirror. `runRescue` now re-stamps the journal baseline for the files the
9
+ * overlay actually re-laid (scoped to rsync `-i` itemize output, so a user's
10
+ * pending edit is never touched).
11
+ *
12
+ * This test seeds a journal whose entry for an overlaid file carries the OLD
13
+ * hash, runs a real (non-dry-run) rescue, and asserts the entry was re-stamped
14
+ * to the freshly laid-down content — i.e. `localChanged` would now be false.
15
+ */
16
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
17
+ import { execFileSync } from "child_process";
18
+ import * as fs from "fs";
19
+ import * as os from "os";
20
+ import * as path from "path";
21
+ import { runRescue } from "./rescue-core.js";
22
+ import {
23
+ hashFile,
24
+ readJournal,
25
+ writeJournal,
26
+ PERSONAL_VAULT_JOURNAL_SLUG,
27
+ } from "../journal.js";
28
+
29
+ function hasGit(): boolean {
30
+ try {
31
+ execFileSync("git", ["--version"], { stdio: "ignore" });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ const gitAvailable = hasGit();
38
+
39
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
40
+ let stdout = "";
41
+ const origOut = process.stdout.write.bind(process.stdout);
42
+ const origErr = process.stderr.write.bind(process.stderr);
43
+ process.stdout.write = ((c: unknown) => ((stdout += String(c)), true)) as typeof process.stdout.write;
44
+ process.stderr.write = (() => true) as typeof process.stderr.write;
45
+ let status: number;
46
+ try {
47
+ status = runRescue(argv, { env }).status;
48
+ } finally {
49
+ process.stdout.write = origOut;
50
+ process.stderr.write = origErr;
51
+ }
52
+ return { status, stdout };
53
+ }
54
+
55
+ describe.skipIf(!gitAvailable)("rescue re-stamps sync-journal baseline for re-laid files", () => {
56
+ let workDir: string, upstream: string, hqRoot: string, stateDir: string, floorSha: string;
57
+ let env: NodeJS.ProcessEnv;
58
+ let savedStateDir: string | undefined;
59
+
60
+ const git = (cwd: string, ...args: string[]) =>
61
+ execFileSync("git", args, {
62
+ cwd,
63
+ stdio: ["ignore", "pipe", "pipe"],
64
+ env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
65
+ }).toString().trim();
66
+
67
+ beforeAll(() => {
68
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-journal-"));
69
+
70
+ // upstream: floor (doc.md=v1), then HEAD advances doc.md -> v2.
71
+ upstream = path.join(workDir, "upstream");
72
+ fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
73
+ git(workDir, "init", "-b", "main", "upstream");
74
+ fs.writeFileSync(path.join(upstream, "core/doc.md"), "v1\n");
75
+ git(upstream, "add", "-A");
76
+ git(upstream, "commit", "-m", "floor");
77
+ floorSha = git(upstream, "rev-parse", "HEAD");
78
+ fs.writeFileSync(path.join(upstream, "core/doc.md"), "v2\n");
79
+ git(upstream, "add", "-A");
80
+ git(upstream, "commit", "-m", "head");
81
+
82
+ // local HQ root: doc.md == floor (v1) -> rescue overlays it to HEAD (v2).
83
+ hqRoot = path.join(workDir, "hq");
84
+ fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
85
+ fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
86
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
87
+ const docAbs = path.join(hqRoot, "core/doc.md");
88
+ fs.writeFileSync(docAbs, "v1\n");
89
+
90
+ // state dir + seeded personal-vault journal carrying the STALE (v1) hash.
91
+ stateDir = path.join(workDir, "state");
92
+ fs.mkdirSync(stateDir, { recursive: true });
93
+ savedStateDir = process.env.HQ_STATE_DIR;
94
+ process.env.HQ_STATE_DIR = stateDir; // getStateDir() reads process.env
95
+ const staleHash = hashFile(docAbs); // hash of v1
96
+ writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
97
+ version: "2",
98
+ lastSync: new Date(0).toISOString(),
99
+ files: {
100
+ "core/doc.md": {
101
+ hash: staleHash,
102
+ size: fs.statSync(docAbs).size,
103
+ syncedAt: new Date(0).toISOString(),
104
+ direction: "down",
105
+ remoteEtag: "seed-etag",
106
+ mtimeMs: fs.statSync(docAbs).mtimeMs,
107
+ },
108
+ },
109
+ pulls: [],
110
+ } as never);
111
+
112
+ // git shim: redirect `git clone <github-url>` to the local fixture.
113
+ const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
114
+ const shimDir = path.join(workDir, "shim");
115
+ fs.mkdirSync(shimDir, { recursive: true });
116
+ fs.writeFileSync(
117
+ path.join(shimDir, "git"),
118
+ `#!/usr/bin/env bash
119
+ if [ "$1" = "clone" ]; then
120
+ args=(); for a in "$@"; do case "$a" in https://github.com/*) a=${JSON.stringify(upstream)} ;; esac; args+=("$a"); done
121
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
122
+ fi
123
+ exec ${JSON.stringify(realGit)} "$@"
124
+ `,
125
+ { mode: 0o755 },
126
+ );
127
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}`, HQ_STATE_DIR: stateDir };
128
+ });
129
+
130
+ afterAll(() => {
131
+ if (savedStateDir === undefined) delete process.env.HQ_STATE_DIR;
132
+ else process.env.HQ_STATE_DIR = savedStateDir;
133
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
134
+ });
135
+
136
+ it("re-stamps the journal so the overlaid file is no longer seen as locally changed", () => {
137
+ const docAbs = path.join(hqRoot, "core/doc.md");
138
+ const staleHash = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"].hash;
139
+
140
+ const r = runRescueCapture(
141
+ ["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
142
+ env,
143
+ );
144
+ expect(r.status, r.stdout).toBe(0);
145
+
146
+ // overlay actually re-laid the file (v1 -> v2)
147
+ expect(fs.readFileSync(docAbs, "utf-8")).toBe("v2\n");
148
+
149
+ // journal entry was re-stamped to the NEW content's hash
150
+ const entry = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"];
151
+ expect(entry.hash).toBe(hashFile(docAbs)); // == hash(v2): localChanged would be FALSE
152
+ expect(entry.hash).not.toBe(staleHash); // proves it changed from the stale v1 baseline
153
+ expect(entry.remoteEtag).toBe("seed-etag"); // remote side untouched -> clean push/converge, not conflict
154
+ expect(r.stdout).toContain("Reconciled sync-journal baseline");
155
+ });
156
+ });
@@ -39,7 +39,7 @@ vi.mock("readline", () => ({
39
39
 
40
40
  import * as readline from "readline";
41
41
  import { share, _testing as shareTesting } from "./share.js";
42
- import { deleteRemoteFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
42
+ import { deleteRemoteFile, downloadFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
43
43
  import type { EntityContext } from "../types.js";
44
44
 
45
45
  const mockConfig: VaultServiceConfig = {
@@ -482,6 +482,110 @@ describe("share", () => {
482
482
  expect(result.filesUploaded).toBe(0);
483
483
  });
484
484
 
485
+ it("fresh-install multipart collision: byte-identical remote is NOT a conflict (reconciled, no mirror)", async () => {
486
+ // Root cause of "HQ installs with conflicts": on a first push the
487
+ // journal is empty, so the fresh-collision branch decides conflict-vs-
488
+ // identical from the remote etag alone. A multipart remote etag
489
+ // (\`<md5>-<partCount>\`) is NOT a content hash, so the OLD code assumed
490
+ // a collision for ANY multipart object — minting a false conflict for
491
+ // the most common fresh-install case: re-pushing a byte-identical
492
+ // core/ scaffold file whose remote copy happened to be multipart-
493
+ // uploaded. The fix fetches the remote bytes once and compares content
494
+ // hashes; identical content reconciles (journal re-stamped, PUT
495
+ // skipped) instead of producing a conflict + \`.conflict-*\` mirror.
496
+ const companyRoot = path.join(tmpDir, "companies", "acme");
497
+ fs.mkdirSync(companyRoot, { recursive: true });
498
+ const testFile = path.join(companyRoot, "core-scaffold.md");
499
+ const scaffoldBytes = "identical-scaffold-content-shipped-to-every-user";
500
+ fs.writeFileSync(testFile, scaffoldBytes);
501
+
502
+ // Remote exists with a MULTIPART etag and NO journal entry (first push).
503
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
504
+ lastModified: new Date(),
505
+ etag: '"d41d8cd98f00b204e9800998ecf8427e-2"',
506
+ size: scaffoldBytes.length,
507
+ });
508
+ // The convergence probe downloads the remote bytes — which are
509
+ // byte-identical to local — so the content hashes match.
510
+ vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
511
+ fs.writeFileSync(dest as string, scaffoldBytes);
512
+ return undefined as never;
513
+ });
514
+
515
+ const events: unknown[] = [];
516
+ const result = await share({
517
+ paths: [testFile],
518
+ company: "acme",
519
+ vaultConfig: mockConfig,
520
+ hqRoot: tmpDir,
521
+ onConflict: "keep",
522
+ onEvent: (e) => events.push(e),
523
+ });
524
+
525
+ // No conflict, no upload (bytes already there), counted as a skip.
526
+ expect(result.conflictPaths).toEqual([]);
527
+ expect(result.filesUploaded).toBe(0);
528
+ expect(result.filesSkipped).toBe(1);
529
+ const conflicts = events.filter(
530
+ (e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
531
+ );
532
+ expect(conflicts).toHaveLength(0);
533
+ const reconciled = events.filter(
534
+ (e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "reconciled",
535
+ );
536
+ expect(reconciled).toHaveLength(1);
537
+ expect(reconciled[0]).toMatchObject({ path: "core-scaffold.md", direction: "push" });
538
+ // No conflict-mirror litter on disk.
539
+ const litter = fs
540
+ .readdirSync(companyRoot)
541
+ .filter((n) => n.includes(".conflict-"));
542
+ expect(litter).toEqual([]);
543
+ // Local file untouched.
544
+ expect(fs.readFileSync(testFile, "utf-8")).toBe(scaffoldBytes);
545
+ });
546
+
547
+ it("fresh-install multipart collision: genuinely different remote IS a conflict (Bug #7 preserved)", async () => {
548
+ // The convergence probe must not weaken Bug #7 protection: a multipart
549
+ // remote whose CONTENT actually differs from local is still a real
550
+ // fresh collision and must surface a conflict (so a peer's content
551
+ // isn't silently clobbered).
552
+ const companyRoot = path.join(tmpDir, "companies", "acme");
553
+ fs.mkdirSync(companyRoot, { recursive: true });
554
+ const testFile = path.join(companyRoot, "diverged-multipart.md");
555
+ fs.writeFileSync(testFile, "my-local-version");
556
+
557
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
558
+ lastModified: new Date(),
559
+ etag: '"0123456789abcdef0123456789abcdef-3"',
560
+ size: 32,
561
+ });
562
+ // The probe (and the subsequent keep-mirror download) return DIFFERENT
563
+ // remote bytes.
564
+ vi.mocked(downloadFile).mockImplementation(async (_ctx, _key, dest) => {
565
+ fs.writeFileSync(dest as string, "a-peers-different-version");
566
+ return undefined as never;
567
+ });
568
+
569
+ const events: unknown[] = [];
570
+ const result = await share({
571
+ paths: [testFile],
572
+ company: "acme",
573
+ vaultConfig: mockConfig,
574
+ hqRoot: tmpDir,
575
+ onConflict: "keep",
576
+ onEvent: (e) => events.push(e),
577
+ });
578
+
579
+ expect(result.conflictPaths).toEqual(["diverged-multipart.md"]);
580
+ expect(result.filesUploaded).toBe(0);
581
+ const conflicts = events.filter(
582
+ (e) => typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
583
+ );
584
+ expect(conflicts).toHaveLength(1);
585
+ // Local file untouched under keep.
586
+ expect(fs.readFileSync(testFile, "utf-8")).toBe("my-local-version");
587
+ });
588
+
485
589
  it("uploads (no conflict) when only the local side changed since last sync", async () => {
486
590
  // Regression for hq-cloud#<conflict-detection>: a local edit to a file
487
591
  // that exists on S3 used to trigger a push conflict because the
package/src/cli/share.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import * as fs from "fs";
9
+ import * as os from "os";
9
10
  import * as path from "path";
10
11
  import type { EntityContext, VaultServiceConfig, SyncJournal } from "../types.js";
11
12
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
@@ -45,6 +46,59 @@ import {
45
46
  } from "../lib/conflict-file.js";
46
47
  import { appendConflictEntry } from "../lib/conflict-index.js";
47
48
 
49
+ /**
50
+ * Push-side fresh-collision convergence probe.
51
+ *
52
+ * For a first push (no journal entry) where the remote object already
53
+ * exists AND was multipart-uploaded, the remote etag (`<md5>-<partCount>`)
54
+ * is not a content hash, so we cannot tell "byte-identical" from "genuine
55
+ * divergence" without looking at the bytes. Fetch the remote object once to
56
+ * a throwaway temp file, hash it the same symlink-aware way the planner
57
+ * hashed local, and report whether the two contents DIFFER.
58
+ *
59
+ * Returns `true` on a genuine difference (a real fresh collision) and
60
+ * `false` when the bytes are identical. Fails safe to `true` on any
61
+ * fetch/hash error — a false positive merely prompts the operator, while a
62
+ * false negative could silently clobber a peer's content. Mirrors the
63
+ * pull-side convergence guard in `sync.ts`.
64
+ *
65
+ * The temp file lives in the OS temp dir (never under hqRoot) so it can
66
+ * never round-trip to S3, and is removed in a `finally` regardless of
67
+ * outcome. The name is derived from the relative path so concurrent probes
68
+ * for distinct files never collide on disk.
69
+ */
70
+ async function remoteContentDiffers(
71
+ ctx: EntityContext,
72
+ relativePath: string,
73
+ localHash: string,
74
+ hqRoot: string,
75
+ ): Promise<boolean> {
76
+ const probeKey = crypto
77
+ .createHash("sha256")
78
+ .update(relativePath)
79
+ .digest("hex")
80
+ .slice(0, 16);
81
+ const probePath = path.join(
82
+ os.tmpdir(),
83
+ `hq-conflict-probe-${readShortMachineId(hqRoot)}-${probeKey}.tmp`,
84
+ );
85
+ try {
86
+ await downloadFile(ctx, relativePath, probePath);
87
+ const remoteHash = fs.lstatSync(probePath).isSymbolicLink()
88
+ ? hashSymlinkTarget(fs.readlinkSync(probePath))
89
+ : hashFile(probePath);
90
+ return remoteHash !== localHash;
91
+ } catch {
92
+ return true;
93
+ } finally {
94
+ try {
95
+ fs.rmSync(probePath, { force: true });
96
+ } catch {
97
+ /* best-effort cleanup; a stray temp probe is harmless */
98
+ }
99
+ }
100
+ }
101
+
48
102
  /**
49
103
  * Local-only ephemeral artifacts: conflict-mirror files written by the pull
50
104
  * leg whenever a 3-way merge keeps local AND wants to preserve the remote
@@ -940,12 +994,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
940
994
  const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
941
995
 
942
996
  let isFreshCollision = false;
997
+ let multipartConverged = false;
943
998
  if (!journalEntry && item.kind === "file") {
944
999
  // Single-part S3 PUT etag is MD5 of the body. Multipart uploads
945
- // produce \`<md5>-<partCount>\`; we treat any non-single-part etag
946
- // as ambiguous and DO classify as a conflict (safer for the
947
- // first-time path — false positives prompt the operator, false
948
- // negatives lose data). Symlink records (\`kind: "symlink"\`)
1000
+ // produce \`<md5>-<partCount>\`. Symlink records (\`kind: "symlink"\`)
949
1001
  // skip the check entirely — the wire body shape (\`hq-symlink:\`
950
1002
  // prefix + target) isn't a pure byte mirror and would mis-
951
1003
  // classify; symlink overwrites are rare and an audit pass after
@@ -962,13 +1014,55 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
962
1014
  // path which is idempotent (S3 will overwrite with identical
963
1015
  // content + carry our metadata). Cheap, no behavior change.
964
1016
  } else {
965
- // Multipart object pre-exists with unknown body shape — assume
966
- // collision rather than risk a silent overwrite. The operator
967
- // can resolve via the standard conflict prompt.
968
- isFreshCollision = true;
1017
+ // Multipart remote etag is \`<md5>-<partCount>\`, NOT a usable
1018
+ // content hash, so unlike the single-part branch — we cannot
1019
+ // decide collision-vs-identical from the etag alone. The old
1020
+ // behavior assumed a collision here, which minted a FALSE
1021
+ // conflict for the most common fresh-install case: re-pushing a
1022
+ // byte-identical \`core/\` scaffold file whose remote copy happened
1023
+ // to be multipart-uploaded. Every fresh install that hit an
1024
+ // already-populated bucket therefore came up "with conflicts".
1025
+ //
1026
+ // Instead, fetch the remote bytes once and compare content
1027
+ // hashes directly — the same convergence guard the pull side
1028
+ // uses (sync.ts). Identical content is NOT a conflict. On any
1029
+ // fetch/hash failure we fail safe to "conflict" (false positives
1030
+ // prompt the operator; false negatives risk clobbering a peer).
1031
+ const remoteDiffers = await remoteContentDiffers(
1032
+ ctx,
1033
+ relativePath,
1034
+ localHash,
1035
+ hqRoot,
1036
+ );
1037
+ if (remoteDiffers) {
1038
+ isFreshCollision = true;
1039
+ } else {
1040
+ // Byte-identical multipart object already present. Seed the
1041
+ // journal baseline from the remote so the next sync sees no
1042
+ // change on either side, and skip the redundant PUT —
1043
+ // re-uploading would needlessly rewrite remote and churn its
1044
+ // etag from multipart to single-part.
1045
+ multipartConverged = true;
1046
+ }
969
1047
  }
970
1048
  }
971
1049
 
1050
+ if (multipartConverged) {
1051
+ const lstat = fs.lstatSync(absolutePath);
1052
+ updateEntry(
1053
+ journal,
1054
+ relativePath,
1055
+ localHash,
1056
+ lstat.size,
1057
+ "up",
1058
+ remoteMeta.etag,
1059
+ lstat.mtimeMs,
1060
+ );
1061
+ emit({ type: "reconciled", path: relativePath, direction: "push" });
1062
+ filesSkipped++;
1063
+ return;
1064
+ }
1065
+
972
1066
  if ((localChanged && remoteChanged) || isFreshCollision) {
973
1067
  conflictPaths.push(relativePath);
974
1068
 
package/src/cli/sync.ts CHANGED
@@ -149,7 +149,7 @@ export type SyncProgressEvent =
149
149
  */
150
150
  type: "reconciled";
151
151
  path: string;
152
- direction: "pull";
152
+ direction: "pull" | "push";
153
153
  }
154
154
  | {
155
155
  type: "new-files";