@narumitw/pi-sync 0.50.2 → 0.51.0
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/README.md +4 -1
- package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
- package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
- package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
- package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
- package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
- package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
- package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
- package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
- package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
- package/dist/chunks/chunk-6546TS54.ts.map +7 -0
- package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
- package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
- package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
- package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
- package/dist/chunks/{chunk-4CHTZMEO.ts → chunk-7KHKWL3Q.ts} +37 -55
- package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
- package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
- package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
- package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
- package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
- package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
- package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
- package/dist/chunks/{chunk-G4RNQQZS.ts → chunk-E42GXD6D.ts} +13 -19
- package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
- package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
- package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
- package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
- package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
- package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
- package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
- package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
- package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
- package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
- package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
- package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
- package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
- package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
- package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
- package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
- package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
- package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
- package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
- package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
- package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
- package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
- package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
- package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
- package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
- package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
- package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
- package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
- package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
- package/dist/chunks/{chunk-KW7YQEZN.ts → chunk-UA7RXOIT.ts} +48 -100
- package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
- package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
- package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
- package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
- package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
- package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
- package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
- package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
- package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
- package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
- package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
- package/dist/chunks/{manager-ui-5KLKVMRC.ts → manager-ui-763NEKJY.ts} +124 -251
- package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
- package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
- package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
- package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
- package/dist/chunks/{setup-wizard-IUB6ESEU.ts → setup-wizard-YIEWGX32.ts} +12 -12
- package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
- package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
- package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
- package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
- package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
- package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
- package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
- package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
- package/dist/index.ts +49 -74
- package/dist/index.ts.map +3 -3
- package/docs/settings.md +9 -0
- package/package.json +61 -62
- package/src/backends/backend-factory.ts +15 -15
- package/src/backends/backend-identity.ts +31 -31
- package/src/backends/backend-types.ts +42 -42
- package/src/backends/git/git-backend.ts +714 -788
- package/src/backends/git/git-config.ts +104 -111
- package/src/backends/git/git-runner.ts +230 -247
- package/src/backends/git/git-storage.ts +228 -245
- package/src/backends/s3/s3-backend.ts +408 -441
- package/src/backends/s3/s3-client.ts +288 -333
- package/src/backends/sync-backend.ts +65 -65
- package/src/backends/webdav/webdav-backend.ts +542 -589
- package/src/backends/webdav/webdav-client.ts +292 -316
- package/src/backends/webdav/webdav-config.ts +57 -68
- package/src/commands/command-execution.ts +270 -289
- package/src/commands/command-handler.ts +160 -163
- package/src/commands/command-types.ts +13 -13
- package/src/commands/command.ts +190 -196
- package/src/commands/setup-completions.ts +1 -1
- package/src/paths.ts +47 -47
- package/src/settings/config-errors.ts +4 -5
- package/src/settings/config-file.ts +473 -506
- package/src/settings/config.ts +171 -174
- package/src/settings/settings-management.ts +191 -216
- package/src/settings/settings-store.ts +63 -77
- package/src/settings/settings-types.ts +78 -75
- package/src/settings/settings-validation.ts +328 -364
- package/src/snapshot/session-paths.ts +41 -55
- package/src/snapshot/snapshot-apply.ts +197 -229
- package/src/snapshot/snapshot-codec.ts +29 -31
- package/src/snapshot/snapshot-paths.ts +4 -6
- package/src/snapshot/snapshot-transaction.ts +161 -170
- package/src/snapshot/snapshot-types.ts +24 -24
- package/src/snapshot/snapshot.ts +310 -343
- package/src/state/json-file.ts +11 -11
- package/src/state/lock.ts +226 -242
- package/src/state/lockfile-fs.ts +20 -20
- package/src/state/operation-availability.ts +36 -41
- package/src/state/state-directory.ts +171 -173
- package/src/state/state-types.ts +15 -15
- package/src/state/sync-state-store.ts +23 -23
- package/src/sync/automatic-sync.ts +75 -80
- package/src/sync/remote-snapshot.ts +52 -60
- package/src/sync/setup-switch.ts +99 -114
- package/src/sync/signals.ts +3 -5
- package/src/sync/startup-check.ts +97 -111
- package/src/sync/sync-decision.ts +76 -80
- package/src/sync/sync-error-guidance.ts +41 -48
- package/src/sync/sync-errors.ts +10 -10
- package/src/sync/sync-inspection.ts +29 -33
- package/src/sync/sync-loaders.ts +28 -36
- package/src/sync/sync-mutations.ts +510 -622
- package/src/sync/sync-operations.ts +7 -7
- package/src/sync/sync-policy.ts +296 -326
- package/src/sync/sync-queries.ts +175 -194
- package/src/sync/sync-state.ts +74 -100
- package/src/sync-extension.ts +89 -101
- package/src/sync.ts +26 -26
- package/src/types.ts +37 -37
- package/src/ui/automatic-sync-summary.ts +1 -3
- package/src/ui/cancellable-operation.ts +92 -112
- package/src/ui/file-selection.ts +250 -274
- package/src/ui/manager-attention.ts +51 -63
- package/src/ui/manager-recovery.ts +48 -57
- package/src/ui/manager-result-dispatcher.ts +62 -76
- package/src/ui/manager-state.ts +200 -232
- package/src/ui/manager-ui.ts +264 -362
- package/src/ui/remote-selection-ui.ts +547 -588
- package/src/ui/secret-input.ts +198 -214
- package/src/ui/settings-ui.ts +175 -172
- package/src/ui/setup/git-ui.ts +232 -261
- package/src/ui/setup/s3-credentials-ui.ts +58 -65
- package/src/ui/setup/s3-ui.ts +171 -195
- package/src/ui/setup/setup-actions.ts +91 -101
- package/src/ui/setup/setup-location-ui.ts +121 -137
- package/src/ui/setup/setup-name-ui.ts +27 -27
- package/src/ui/setup/setup-prompts.ts +65 -65
- package/src/ui/setup/setup-review.ts +58 -59
- package/src/ui/setup/setup-switcher.ts +156 -160
- package/src/ui/setup/setup-wizard.ts +28 -28
- package/src/ui/setup/text-input.ts +54 -58
- package/src/ui/setup/webdav-ui.ts +246 -293
- package/src/ui/storage-connections-ui.ts +317 -369
- package/src/ui/sync-attention.ts +148 -164
- package/src/ui/sync-format.ts +110 -121
- package/src/ui/sync-resolution-ui.ts +147 -153
- package/src/ui/sync-setups-ui.ts +218 -238
- package/src/ui/sync-status.ts +17 -0
- package/src/ui/terminal-text.ts +2 -2
- package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
- package/dist/chunks/chunk-4CHTZMEO.ts.map +0 -7
- package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
- package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
- package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
- package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
- package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
- package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
- package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
- package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
- package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
- package/dist/chunks/chunk-G4RNQQZS.ts.map +0 -7
- package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
- package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
- package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
- package/dist/chunks/chunk-KW7YQEZN.ts.map +0 -7
- package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
- package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
- package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
- package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
- package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
- package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
- package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
- package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
- package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
- package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
- package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
- package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
- package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
- package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
- package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-FKIBM3GO.ts +0 -22
- package/dist/chunks/manager-ui-5KLKVMRC.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-D7RBRJNL.ts +0 -21
- package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
- package/dist/chunks/setup-wizard-IUB6ESEU.ts.map +0 -7
- package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
- package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
- package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
- /package/dist/chunks/{manager-result-dispatcher-FKIBM3GO.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-D7RBRJNL.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
- /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
- /package/dist/chunks/{sync-state-Q2FTXTW6.ts.map → sync-state-VHJUZ4YG.ts.map} +0 -0
|
@@ -7,35 +7,35 @@ import { stateDir } from "../../state/state-directory.js";
|
|
|
7
7
|
import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
|
|
8
8
|
import type { ResolvedGitBackend } from "../backend-types.js";
|
|
9
9
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
type BackendDiagnostic,
|
|
11
|
+
type ExpectedRemoteHead,
|
|
12
|
+
type PublishSnapshotOptions,
|
|
13
|
+
type PublishSnapshotResult,
|
|
14
|
+
type RemoteHead,
|
|
15
|
+
type RemoteHistoryEntry,
|
|
16
|
+
type SyncBackend,
|
|
17
|
+
SyncBackendConflictError,
|
|
18
|
+
SyncBackendPublicationOutcomeUnknownError,
|
|
19
19
|
} from "../sync-backend.js";
|
|
20
20
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
normalizeGitBranch,
|
|
22
|
+
normalizeGitDirectory,
|
|
23
|
+
normalizeGitRemote,
|
|
24
|
+
normalizeGitRemoteIdentity,
|
|
25
|
+
validateGitNamespace,
|
|
26
26
|
} from "./git-config.js";
|
|
27
27
|
import { GitCommandError, readGitBlobs, runGit } from "./git-runner.js";
|
|
28
28
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
GIT_MANIFEST_VERSION,
|
|
30
|
+
type GitManifest,
|
|
31
|
+
MAX_GIT_MANIFEST_BYTES,
|
|
32
|
+
MAX_GIT_TREE_OUTPUT_BYTES,
|
|
33
|
+
type PreparedGitFile,
|
|
34
|
+
parseGitTree,
|
|
35
|
+
prepareGitSnapshot,
|
|
36
|
+
requireGitManifest,
|
|
37
|
+
validateGitPublicationTree,
|
|
38
|
+
validateGitSnapshot,
|
|
39
39
|
} from "./git-storage.js";
|
|
40
40
|
|
|
41
41
|
const COMMAND_TIMEOUT_MS = 30_000;
|
|
@@ -43,817 +43,743 @@ const POST_COMMIT_TIMEOUT_MS = 45_000;
|
|
|
43
43
|
const gitCacheMutationQueues = new Map<string, Promise<void>>();
|
|
44
44
|
|
|
45
45
|
export interface GitBackendOptions {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
cacheRoot?: string;
|
|
47
|
+
allowLocalRemotes?: boolean;
|
|
48
|
+
commandTimeoutMs?: number;
|
|
49
|
+
postCommitTimeoutMs?: number;
|
|
50
|
+
/** Deterministic fault injection used only by the local backend test suite. */
|
|
51
|
+
afterPushForTest?: () => void | Promise<void>;
|
|
52
|
+
afterLsRemoteForTest?: () => void | Promise<void>;
|
|
53
|
+
afterPayloadWriteForTest?: () => void | Promise<void>;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export class GitSyncBackend implements SyncBackend {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
gitDir: this.cacheDir,
|
|
611
|
-
allowFileProtocol: this.allowLocalRemotes,
|
|
612
|
-
timeoutMs: options.timeoutMs ?? this.commandTimeoutMs,
|
|
613
|
-
...options,
|
|
614
|
-
}).catch((error) => {
|
|
615
|
-
throw this.redactedError(error);
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
private remoteRef() {
|
|
620
|
-
return `refs/heads/${this.config.destination.branch}`;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
private publicationPath() {
|
|
624
|
-
return this.config.destination.directory === "./" ? "" : this.config.destination.directory;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
private manifestPath() {
|
|
628
|
-
return posixJoin(this.publicationPath(), "manifest.json");
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
private filePath(filePath: string) {
|
|
632
|
-
return posixJoin(this.publicationPath(), "files", filePath);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
private async readPublication(commit: string, signal?: AbortSignal) {
|
|
636
|
-
const manifest = await this.readManifest(commit, signal);
|
|
637
|
-
const entries = await this.readPublicationTree(commit, signal);
|
|
638
|
-
const payloadEntries = validateGitPublicationTree(
|
|
639
|
-
entries,
|
|
640
|
-
manifest,
|
|
641
|
-
this.manifestPath(),
|
|
642
|
-
(filePath) => this.filePath(filePath),
|
|
643
|
-
);
|
|
644
|
-
return { manifest, payloadEntries };
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
private async readPublicationTree(commit: string, signal?: AbortSignal) {
|
|
648
|
-
const result = await this.git(["ls-tree", "-r", "-z", commit], {
|
|
649
|
-
signal,
|
|
650
|
-
maxOutputBytes: MAX_GIT_TREE_OUTPUT_BYTES,
|
|
651
|
-
});
|
|
652
|
-
return parseGitTree(result.stdout);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
private redactedError(error: unknown) {
|
|
656
|
-
if (error instanceof Error && error.name === "AbortError") return error;
|
|
657
|
-
return new Error(this.safeError(error));
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
private safeError(error: unknown) {
|
|
661
|
-
const raw =
|
|
662
|
-
error instanceof GitCommandError
|
|
663
|
-
? error.stderr || error.message
|
|
664
|
-
: error instanceof Error
|
|
665
|
-
? error.message
|
|
666
|
-
: String(error);
|
|
667
|
-
return redactGitError(raw, this.config.profile.remote, this.cacheDir);
|
|
668
|
-
}
|
|
57
|
+
readonly identity: string;
|
|
58
|
+
readonly destination: string;
|
|
59
|
+
readonly capability = "lease-protected" as const;
|
|
60
|
+
private readonly cacheRoot: string;
|
|
61
|
+
private readonly cacheDir: string;
|
|
62
|
+
private readonly allowLocalRemotes: boolean;
|
|
63
|
+
private readonly commandTimeoutMs: number;
|
|
64
|
+
private readonly postCommitTimeoutMs: number;
|
|
65
|
+
private readonly afterPushForTest?: () => void | Promise<void>;
|
|
66
|
+
private readonly afterLsRemoteForTest?: () => void | Promise<void>;
|
|
67
|
+
private readonly afterPayloadWriteForTest?: () => void | Promise<void>;
|
|
68
|
+
private cacheReady?: Promise<void>;
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly config: ResolvedGitBackend,
|
|
72
|
+
options: GitBackendOptions = {},
|
|
73
|
+
) {
|
|
74
|
+
assertGitDestination(config);
|
|
75
|
+
this.allowLocalRemotes = options.allowLocalRemotes === true;
|
|
76
|
+
if (!this.allowLocalRemotes) assertProductionRemote(config.profile.remote);
|
|
77
|
+
this.identity = gitBackendIdentity(config);
|
|
78
|
+
this.destination = gitDestination(config);
|
|
79
|
+
this.cacheRoot = path.resolve(options.cacheRoot ?? path.join(stateDir(), "git"));
|
|
80
|
+
this.cacheDir = path.join(this.cacheRoot, this.identity.slice("git:".length), "repository.git");
|
|
81
|
+
this.commandTimeoutMs = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
82
|
+
this.postCommitTimeoutMs = options.postCommitTimeoutMs ?? POST_COMMIT_TIMEOUT_MS;
|
|
83
|
+
this.afterPushForTest = options.afterPushForTest;
|
|
84
|
+
this.afterLsRemoteForTest = options.afterLsRemoteForTest;
|
|
85
|
+
this.afterPayloadWriteForTest = options.afterPayloadWriteForTest;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
sameRevision(left: string, right: string) {
|
|
89
|
+
return decodeRevision(left, this.identity) === decodeRevision(right, this.identity);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async readHead(signal?: AbortSignal): Promise<RemoteHead | undefined> {
|
|
93
|
+
const sha = await this.fetchRemoteHead(signal);
|
|
94
|
+
if (!sha) return undefined;
|
|
95
|
+
const { manifest } = await this.readPublication(sha, signal);
|
|
96
|
+
return remoteHead(sha, manifest, this.identity);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async readSnapshot(reference: string, signal?: AbortSignal): Promise<Snapshot> {
|
|
100
|
+
const head = await this.fetchRemoteHead(signal);
|
|
101
|
+
if (!head) throw new Error(`Git snapshot publication was not found: ${reference}`);
|
|
102
|
+
const commit = await this.resolveSnapshotReference(reference, head, signal);
|
|
103
|
+
try {
|
|
104
|
+
await this.git(["cat-file", "-e", `${commit}^{commit}`], { signal });
|
|
105
|
+
await this.git(["merge-base", "--is-ancestor", commit, head], { signal });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new Error(`Git snapshot publication was not found: ${reference}`, { cause: error });
|
|
108
|
+
}
|
|
109
|
+
const { manifest, payloadEntries } = await this.readPublication(commit, signal);
|
|
110
|
+
let blobs: Buffer[];
|
|
111
|
+
try {
|
|
112
|
+
blobs = await readGitBlobs(
|
|
113
|
+
payloadEntries.map((entry) => entry.object),
|
|
114
|
+
{
|
|
115
|
+
gitDir: this.cacheDir,
|
|
116
|
+
signal,
|
|
117
|
+
timeoutMs: this.commandTimeoutMs,
|
|
118
|
+
allowFileProtocol: this.allowLocalRemotes,
|
|
119
|
+
maxOutputBytes: manifest.files.reduce((total, file) => total + file.size, 0),
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof Error && /exceeds/u.test(error.message)) {
|
|
124
|
+
throw new Error("Git snapshot file content exceeds its manifest size.", { cause: error });
|
|
125
|
+
}
|
|
126
|
+
throw this.redactedError(error);
|
|
127
|
+
}
|
|
128
|
+
throwIfAborted(signal);
|
|
129
|
+
const files: SnapshotFile[] = manifest.files.map((file, index) => {
|
|
130
|
+
const content = blobs[index];
|
|
131
|
+
if (!content || content.byteLength !== file.size || sha256(content) !== file.sha256) {
|
|
132
|
+
throw new Error(`Git snapshot file checksum or size mismatch: ${file.path}`);
|
|
133
|
+
}
|
|
134
|
+
return { path: file.path, contentBase64: content.toString("base64"), sha256: file.sha256 };
|
|
135
|
+
});
|
|
136
|
+
const snapshot: Snapshot = {
|
|
137
|
+
version: manifest.snapshotVersion,
|
|
138
|
+
id: manifest.snapshotId,
|
|
139
|
+
createdAt: manifest.createdAt,
|
|
140
|
+
machine: manifest.machine,
|
|
141
|
+
profile: manifest.profile,
|
|
142
|
+
...(manifest.snapshotSyncSessions === undefined ? {} : { syncSessions: manifest.snapshotSyncSessions }),
|
|
143
|
+
...(manifest.selection === undefined ? {} : { selection: manifest.selection }),
|
|
144
|
+
files,
|
|
145
|
+
};
|
|
146
|
+
validateGitSnapshot(snapshot, manifest, this.config.destination.namespace);
|
|
147
|
+
return snapshot;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async publishSnapshot(
|
|
151
|
+
snapshot: Snapshot,
|
|
152
|
+
expected: ExpectedRemoteHead,
|
|
153
|
+
options: PublishSnapshotOptions = {},
|
|
154
|
+
): Promise<PublishSnapshotResult> {
|
|
155
|
+
throwIfAborted(options.signal);
|
|
156
|
+
const files = prepareGitSnapshot(snapshot, this.config.destination.namespace);
|
|
157
|
+
const observed = await this.fetchRemoteHead(options.signal);
|
|
158
|
+
if (!matchesExpected(observed, expected, this.identity)) {
|
|
159
|
+
throw new SyncBackendConflictError(
|
|
160
|
+
"Git remote changed while preparing publication. Run /sync status and retry.",
|
|
161
|
+
{ currentHead: observed ? await this.headForSha(observed, options.signal) : undefined },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
throwIfAborted(options.signal);
|
|
165
|
+
const manifest: GitManifest = {
|
|
166
|
+
version: GIT_MANIFEST_VERSION,
|
|
167
|
+
snapshotVersion: snapshot.version,
|
|
168
|
+
snapshotId: snapshot.id,
|
|
169
|
+
createdAt: snapshot.createdAt,
|
|
170
|
+
machine: snapshot.machine,
|
|
171
|
+
profile: snapshot.profile,
|
|
172
|
+
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
173
|
+
...(snapshot.syncSessions === undefined ? {} : { snapshotSyncSessions: snapshot.syncSessions }),
|
|
174
|
+
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
175
|
+
files: files.map(({ path: filePath, sha256: fileSha, size }) => ({
|
|
176
|
+
path: filePath,
|
|
177
|
+
sha256: fileSha,
|
|
178
|
+
size,
|
|
179
|
+
})),
|
|
180
|
+
};
|
|
181
|
+
let candidate: string;
|
|
182
|
+
try {
|
|
183
|
+
candidate = await this.createCommit(snapshot, files, manifest, observed, options.signal);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw this.redactedError(error);
|
|
186
|
+
}
|
|
187
|
+
throwIfAborted(options.signal);
|
|
188
|
+
options.onCommit?.();
|
|
189
|
+
|
|
190
|
+
const ref = this.remoteRef();
|
|
191
|
+
const lease = `--force-with-lease=${ref}:${observed ?? ""}`;
|
|
192
|
+
let pushError: unknown;
|
|
193
|
+
try {
|
|
194
|
+
await this.git(["push", "--porcelain", "--no-verify", lease, this.config.profile.remote, `${candidate}:${ref}`], {
|
|
195
|
+
timeoutMs: this.postCommitTimeoutMs,
|
|
196
|
+
});
|
|
197
|
+
await this.afterPushForTest?.();
|
|
198
|
+
} catch (error) {
|
|
199
|
+
pushError = error;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let current: string | undefined;
|
|
203
|
+
try {
|
|
204
|
+
current = await this.fetchRemoteHead(AbortSignal.timeout(this.postCommitTimeoutMs));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new SyncBackendPublicationOutcomeUnknownError(
|
|
207
|
+
`Git publication outcome is unknown: ${this.safeError(pushError ?? error)}`,
|
|
208
|
+
{ cause: pushError ?? error },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (current !== candidate) {
|
|
212
|
+
if (pushError && current === observed) {
|
|
213
|
+
throw new Error(`Git publication failed without updating the owned branch: ${this.safeError(pushError)}`, {
|
|
214
|
+
cause: pushError,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
throw new SyncBackendConflictError(
|
|
218
|
+
pushError
|
|
219
|
+
? `Git publication lease was rejected: ${this.safeError(pushError)}`
|
|
220
|
+
: "Git remote changed immediately after publication.",
|
|
221
|
+
{
|
|
222
|
+
phase: "after-commit",
|
|
223
|
+
currentHead: current ? await this.headForSha(current) : undefined,
|
|
224
|
+
candidateMayHaveBeenActive: true,
|
|
225
|
+
cause: pushError instanceof Error ? pushError : undefined,
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const head = await this.headForSha(candidate);
|
|
230
|
+
return { head, warnings: [] };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async listHistory(signal?: AbortSignal): Promise<RemoteHistoryEntry[]> {
|
|
234
|
+
const sha = await this.fetchRemoteHead(signal);
|
|
235
|
+
if (!sha) return [];
|
|
236
|
+
const result = await this.git(["rev-list", "--first-parent", "--reverse", "--max-count=100", sha], { signal });
|
|
237
|
+
const commits = result.stdout.toString("utf8").trim().split("\n").filter(Boolean);
|
|
238
|
+
const entries: RemoteHistoryEntry[] = [];
|
|
239
|
+
for (const commit of commits) {
|
|
240
|
+
const { manifest } = await this.readPublication(commit, signal);
|
|
241
|
+
entries.push({
|
|
242
|
+
snapshotRef: commit,
|
|
243
|
+
snapshotId: manifest.snapshotId,
|
|
244
|
+
createdAt: manifest.createdAt,
|
|
245
|
+
machine: manifest.machine,
|
|
246
|
+
syncSessions: manifest.syncSessions,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return entries;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]> {
|
|
253
|
+
const diagnostics: BackendDiagnostic[] = [
|
|
254
|
+
{
|
|
255
|
+
key: "git-scope",
|
|
256
|
+
level: "info",
|
|
257
|
+
message: "git check scope: read remote snapshots and inspect the local cache. Write access is not tested.",
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
try {
|
|
261
|
+
const version = await runGit(["--version"], {
|
|
262
|
+
signal,
|
|
263
|
+
timeoutMs: this.commandTimeoutMs,
|
|
264
|
+
});
|
|
265
|
+
const versionText = version.stdout.toString("utf8").trim();
|
|
266
|
+
const supported = isSupportedGitVersion(versionText);
|
|
267
|
+
diagnostics.push({
|
|
268
|
+
key: "git-version",
|
|
269
|
+
level: supported ? "info" : "error",
|
|
270
|
+
message: supported
|
|
271
|
+
? versionText
|
|
272
|
+
: `${versionText || "unknown Git version"}; pi-sync requires Git 2.30 or newer`,
|
|
273
|
+
});
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return [
|
|
276
|
+
{
|
|
277
|
+
key: "git-version",
|
|
278
|
+
level: "error",
|
|
279
|
+
message: syncErrorGuidance(new Error(this.safeError(error))),
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const head = await this.readHead(signal);
|
|
285
|
+
if (head) await this.readSnapshot(head.snapshotRef, signal);
|
|
286
|
+
diagnostics.push({
|
|
287
|
+
key: "git-remote",
|
|
288
|
+
level: "info",
|
|
289
|
+
message: head
|
|
290
|
+
? `git remote: reachable; owned branch ${this.config.destination.branch} is valid`
|
|
291
|
+
: `git remote: reachable; owned branch ${this.config.destination.branch} is not created yet`,
|
|
292
|
+
});
|
|
293
|
+
diagnostics.push({
|
|
294
|
+
key: "git-cache",
|
|
295
|
+
level: "info",
|
|
296
|
+
message: "git cache: private bare repository is healthy",
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
diagnostics.push({
|
|
300
|
+
key: "git-remote",
|
|
301
|
+
level: "error",
|
|
302
|
+
message: `git remote: ${syncErrorGuidance(new Error(this.safeError(error)))}`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return diagnostics;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private async resolveSnapshotReference(reference: string, head: string, signal?: AbortSignal) {
|
|
309
|
+
if (isCommitSha(reference) || /^[0-9a-f]{64}$/u.test(reference)) {
|
|
310
|
+
requireCommitSha(reference);
|
|
311
|
+
return reference;
|
|
312
|
+
}
|
|
313
|
+
if (!reference || reference.length > 512 || !/^[A-Za-z0-9._-]+$/u.test(reference)) {
|
|
314
|
+
throw new Error("Invalid Git publication reference.");
|
|
315
|
+
}
|
|
316
|
+
const result = await this.git(["rev-list", "--first-parent", "--max-count=100", head], {
|
|
317
|
+
signal,
|
|
318
|
+
});
|
|
319
|
+
const commits = result.stdout.toString("utf8").trim().split("\n").filter(Boolean);
|
|
320
|
+
const matches: string[] = [];
|
|
321
|
+
for (const commit of commits) {
|
|
322
|
+
const { manifest } = await this.readPublication(commit, signal);
|
|
323
|
+
if (manifest.snapshotId === reference) matches.push(commit);
|
|
324
|
+
}
|
|
325
|
+
if (matches.length === 0) {
|
|
326
|
+
throw new Error(`Git snapshot publication was not found: ${reference}`);
|
|
327
|
+
}
|
|
328
|
+
if (matches.length > 1) {
|
|
329
|
+
throw new Error(`Git snapshot id is ambiguous; use a commit reference from /sync history: ${reference}`);
|
|
330
|
+
}
|
|
331
|
+
return matches[0] as string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async headForSha(sha: string, signal?: AbortSignal) {
|
|
335
|
+
const { manifest } = await this.readPublication(sha, signal);
|
|
336
|
+
return remoteHead(sha, manifest, this.identity);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private async fetchRemoteHead(signal?: AbortSignal) {
|
|
340
|
+
await this.ensureCache(signal);
|
|
341
|
+
const result = await this.git(["ls-remote", "--refs", this.config.profile.remote, this.remoteRef()], { signal });
|
|
342
|
+
const line = result.stdout.toString("utf8").trim();
|
|
343
|
+
if (!line) return undefined;
|
|
344
|
+
const [sha, ref, ...extra] = line.split(/\s+/u);
|
|
345
|
+
if (extra.length > 0 || ref !== this.remoteRef() || !sha) {
|
|
346
|
+
throw new Error("Git remote returned a malformed owned-ref response.");
|
|
347
|
+
}
|
|
348
|
+
requireCommitSha(sha);
|
|
349
|
+
await this.afterLsRemoteForTest?.();
|
|
350
|
+
return withGitCacheMutation(
|
|
351
|
+
this.cacheDir,
|
|
352
|
+
async () => {
|
|
353
|
+
const localRef = `refs/pisync/fetch/${process.pid}-${randomUUID()}`;
|
|
354
|
+
try {
|
|
355
|
+
await this.git(
|
|
356
|
+
["fetch", "--no-tags", "--force", this.config.profile.remote, `${this.remoteRef()}:${localRef}`],
|
|
357
|
+
{ signal },
|
|
358
|
+
);
|
|
359
|
+
const fetched = (await this.git(["rev-parse", "--verify", localRef], { signal })).stdout
|
|
360
|
+
.toString("utf8")
|
|
361
|
+
.trim();
|
|
362
|
+
requireCommitSha(fetched);
|
|
363
|
+
return fetched;
|
|
364
|
+
} finally {
|
|
365
|
+
await this.git(["update-ref", "-d", localRef], { timeoutMs: 5_000 }).catch(() => undefined);
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
signal,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async readManifest(commit: string, signal?: AbortSignal): Promise<GitManifest> {
|
|
373
|
+
requireCommitSha(commit);
|
|
374
|
+
const bytes = await this.showFile(commit, this.manifestPath(), signal, MAX_GIT_MANIFEST_BYTES);
|
|
375
|
+
let parsed: unknown;
|
|
376
|
+
try {
|
|
377
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
378
|
+
} catch (error) {
|
|
379
|
+
throw new Error("Git publication manifest is malformed.", { cause: error });
|
|
380
|
+
}
|
|
381
|
+
return requireGitManifest(parsed);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private showFile(commit: string, filePath: string, signal?: AbortSignal, maxOutputBytes?: number) {
|
|
385
|
+
return this.git(["show", `${commit}:${filePath}`], { signal, maxOutputBytes }).then((result) => result.stdout);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private async createCommit(
|
|
389
|
+
snapshot: Snapshot,
|
|
390
|
+
files: PreparedGitFile[],
|
|
391
|
+
manifest: GitManifest,
|
|
392
|
+
parent: string | undefined,
|
|
393
|
+
signal?: AbortSignal,
|
|
394
|
+
) {
|
|
395
|
+
const manifestBytes = Buffer.from(`${JSON.stringify(manifest)}\n`, "utf8");
|
|
396
|
+
if (manifestBytes.byteLength > MAX_GIT_MANIFEST_BYTES) {
|
|
397
|
+
throw new Error(`Git publication manifest exceeds the ${MAX_GIT_MANIFEST_BYTES}-byte limit.`);
|
|
398
|
+
}
|
|
399
|
+
await this.ensureCache(signal);
|
|
400
|
+
const temporaryDirectory = await fs.mkdtemp(path.join(path.dirname(this.cacheDir), ".index-"));
|
|
401
|
+
const indexPath = path.join(temporaryDirectory, "index");
|
|
402
|
+
const payloadDirectory = path.join(temporaryDirectory, "payloads");
|
|
403
|
+
const env = { GIT_INDEX_FILE: indexPath };
|
|
404
|
+
try {
|
|
405
|
+
await fs.mkdir(payloadDirectory, { mode: 0o700 });
|
|
406
|
+
const uniqueFiles = [...new Map(files.map((file) => [file.sha256, file])).values()].sort((left, right) =>
|
|
407
|
+
left.sha256.localeCompare(right.sha256),
|
|
408
|
+
);
|
|
409
|
+
for (const file of uniqueFiles) {
|
|
410
|
+
throwIfAborted(signal);
|
|
411
|
+
await fs.writeFile(path.join(payloadDirectory, file.sha256), file.content, {
|
|
412
|
+
flag: "wx",
|
|
413
|
+
mode: 0o600,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
await this.afterPayloadWriteForTest?.();
|
|
417
|
+
throwIfAborted(signal);
|
|
418
|
+
const hashed = await this.git(["hash-object", "-w", "--no-filters", "--stdin-paths"], {
|
|
419
|
+
cwd: payloadDirectory,
|
|
420
|
+
input: uniqueFiles.map((file) => file.sha256).join("\n") + (uniqueFiles.length ? "\n" : ""),
|
|
421
|
+
signal,
|
|
422
|
+
maxOutputBytes: Math.max(1024, uniqueFiles.length * 64),
|
|
423
|
+
});
|
|
424
|
+
const objectIds = hashed.stdout.toString("utf8").trim().split("\n").filter(Boolean);
|
|
425
|
+
if (objectIds.length !== uniqueFiles.length || objectIds.some((id) => !isCommitSha(id))) {
|
|
426
|
+
throw new Error("Git hash-object returned a malformed payload response.");
|
|
427
|
+
}
|
|
428
|
+
const objectsBySha256 = new Map(uniqueFiles.map((file, index) => [file.sha256, objectIds[index] as string]));
|
|
429
|
+
const manifestBlob = (await this.git(["hash-object", "-w", "--stdin"], { input: manifestBytes, signal })).stdout
|
|
430
|
+
.toString("utf8")
|
|
431
|
+
.trim();
|
|
432
|
+
if (!isCommitSha(manifestBlob)) throw new Error("Git returned an invalid manifest blob id.");
|
|
433
|
+
await this.git(["read-tree", "--empty"], { env, signal });
|
|
434
|
+
const indexLines = [
|
|
435
|
+
`100644 ${manifestBlob}\t${this.manifestPath()}`,
|
|
436
|
+
...files.map((file) => {
|
|
437
|
+
const object = objectsBySha256.get(file.sha256);
|
|
438
|
+
if (!object) throw new Error("Git payload object is missing after hashing.");
|
|
439
|
+
return `100644 ${object}\t${this.filePath(file.path)}`;
|
|
440
|
+
}),
|
|
441
|
+
];
|
|
442
|
+
await this.git(["update-index", "-z", "--index-info"], {
|
|
443
|
+
env,
|
|
444
|
+
signal,
|
|
445
|
+
input: Buffer.from(`${indexLines.join("\0")}\0`, "utf8"),
|
|
446
|
+
});
|
|
447
|
+
const tree = (await this.git(["write-tree"], { env, signal })).stdout.toString("utf8").trim();
|
|
448
|
+
const date = Number.isNaN(Date.parse(snapshot.createdAt)) ? new Date().toISOString() : snapshot.createdAt;
|
|
449
|
+
const commit = await this.git(["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-F", "-"], {
|
|
450
|
+
signal,
|
|
451
|
+
input: `pi-sync snapshot ${snapshot.id}\n`,
|
|
452
|
+
env: {
|
|
453
|
+
GIT_AUTHOR_NAME: "pi-sync",
|
|
454
|
+
GIT_AUTHOR_EMAIL: "pi-sync@localhost",
|
|
455
|
+
GIT_COMMITTER_NAME: "pi-sync",
|
|
456
|
+
GIT_COMMITTER_EMAIL: "pi-sync@localhost",
|
|
457
|
+
GIT_AUTHOR_DATE: date,
|
|
458
|
+
GIT_COMMITTER_DATE: date,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
const sha = commit.stdout.toString("utf8").trim();
|
|
462
|
+
requireCommitSha(sha);
|
|
463
|
+
return sha;
|
|
464
|
+
} finally {
|
|
465
|
+
await fs.rm(temporaryDirectory, { recursive: true, force: true });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private ensureCache(signal?: AbortSignal) {
|
|
470
|
+
if (!this.cacheReady) {
|
|
471
|
+
const operation = withGitCacheMutation(this.cacheDir, () => this.initializeCache(signal), signal);
|
|
472
|
+
const wrapped = operation.catch((error) => {
|
|
473
|
+
if (this.cacheReady === wrapped) this.cacheReady = undefined;
|
|
474
|
+
throw this.redactedError(error);
|
|
475
|
+
});
|
|
476
|
+
this.cacheReady = wrapped;
|
|
477
|
+
}
|
|
478
|
+
return this.cacheReady;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private async initializeCache(signal?: AbortSignal) {
|
|
482
|
+
const version = await runGit(["--version"], {
|
|
483
|
+
signal,
|
|
484
|
+
timeoutMs: this.commandTimeoutMs,
|
|
485
|
+
});
|
|
486
|
+
const versionText = version.stdout.toString("utf8").trim();
|
|
487
|
+
if (!isSupportedGitVersion(versionText)) {
|
|
488
|
+
throw new Error(`${versionText || "Unknown Git version"}; pi-sync requires Git 2.30 or newer.`);
|
|
489
|
+
}
|
|
490
|
+
const parent = path.dirname(this.cacheDir);
|
|
491
|
+
const cacheParent = path.dirname(this.cacheRoot);
|
|
492
|
+
await assertNotSymlink(cacheParent, "Git cache parent");
|
|
493
|
+
await assertNotSymlink(this.cacheRoot, "Git cache root");
|
|
494
|
+
await fs.mkdir(this.cacheRoot, { recursive: true, mode: 0o700 });
|
|
495
|
+
await assertNotSymlink(cacheParent, "Git cache parent");
|
|
496
|
+
await assertNotSymlink(this.cacheRoot, "Git cache root");
|
|
497
|
+
await assertNotSymlink(parent, "Git cache identity directory");
|
|
498
|
+
await fs.mkdir(parent, { recursive: true, mode: 0o700 });
|
|
499
|
+
await assertNotSymlink(parent, "Git cache identity directory");
|
|
500
|
+
let recreate = false;
|
|
501
|
+
try {
|
|
502
|
+
const stat = await fs.lstat(this.cacheDir);
|
|
503
|
+
if (stat.isSymbolicLink()) throw new Error("Refusing symlinked Git cache.");
|
|
504
|
+
if (!stat.isDirectory()) recreate = true;
|
|
505
|
+
else {
|
|
506
|
+
try {
|
|
507
|
+
recreate = !(await this.cacheUsesSha1(signal));
|
|
508
|
+
} catch {
|
|
509
|
+
throwIfAborted(signal);
|
|
510
|
+
recreate = true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
515
|
+
}
|
|
516
|
+
throwIfAborted(signal);
|
|
517
|
+
if (recreate) await fs.rm(this.cacheDir, { recursive: true, force: true });
|
|
518
|
+
try {
|
|
519
|
+
await fs.access(this.cacheDir);
|
|
520
|
+
} catch {
|
|
521
|
+
try {
|
|
522
|
+
await runGit(["init", "--bare", "--object-format=sha1", this.cacheDir], {
|
|
523
|
+
signal,
|
|
524
|
+
timeoutMs: this.commandTimeoutMs,
|
|
525
|
+
allowFileProtocol: this.allowLocalRemotes,
|
|
526
|
+
});
|
|
527
|
+
} catch (initError) {
|
|
528
|
+
const concurrent = await this.cacheUsesSha1(signal).catch(() => false);
|
|
529
|
+
if (!concurrent) throw initError;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (process.platform !== "win32") await fs.chmod(parent, 0o700);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private async cacheUsesSha1(signal?: AbortSignal) {
|
|
536
|
+
const result = await this.git(["rev-parse", "--is-bare-repository", "--show-object-format"], {
|
|
537
|
+
signal,
|
|
538
|
+
});
|
|
539
|
+
return result.stdout.toString("utf8").trim() === "true\nsha1";
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private git(
|
|
543
|
+
args: string[],
|
|
544
|
+
options: {
|
|
545
|
+
cwd?: string;
|
|
546
|
+
input?: Buffer | string;
|
|
547
|
+
env?: NodeJS.ProcessEnv;
|
|
548
|
+
signal?: AbortSignal;
|
|
549
|
+
timeoutMs?: number;
|
|
550
|
+
maxOutputBytes?: number;
|
|
551
|
+
} = {},
|
|
552
|
+
) {
|
|
553
|
+
return runGit(args, {
|
|
554
|
+
gitDir: this.cacheDir,
|
|
555
|
+
allowFileProtocol: this.allowLocalRemotes,
|
|
556
|
+
timeoutMs: options.timeoutMs ?? this.commandTimeoutMs,
|
|
557
|
+
...options,
|
|
558
|
+
}).catch((error) => {
|
|
559
|
+
throw this.redactedError(error);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private remoteRef() {
|
|
564
|
+
return `refs/heads/${this.config.destination.branch}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private publicationPath() {
|
|
568
|
+
return this.config.destination.directory === "./" ? "" : this.config.destination.directory;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private manifestPath() {
|
|
572
|
+
return posixJoin(this.publicationPath(), "manifest.json");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private filePath(filePath: string) {
|
|
576
|
+
return posixJoin(this.publicationPath(), "files", filePath);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private async readPublication(commit: string, signal?: AbortSignal) {
|
|
580
|
+
const manifest = await this.readManifest(commit, signal);
|
|
581
|
+
const entries = await this.readPublicationTree(commit, signal);
|
|
582
|
+
const payloadEntries = validateGitPublicationTree(entries, manifest, this.manifestPath(), (filePath) =>
|
|
583
|
+
this.filePath(filePath),
|
|
584
|
+
);
|
|
585
|
+
return { manifest, payloadEntries };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private async readPublicationTree(commit: string, signal?: AbortSignal) {
|
|
589
|
+
const result = await this.git(["ls-tree", "-r", "-z", commit], {
|
|
590
|
+
signal,
|
|
591
|
+
maxOutputBytes: MAX_GIT_TREE_OUTPUT_BYTES,
|
|
592
|
+
});
|
|
593
|
+
return parseGitTree(result.stdout);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private redactedError(error: unknown) {
|
|
597
|
+
if (error instanceof Error && error.name === "AbortError") return error;
|
|
598
|
+
return new Error(this.safeError(error));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private safeError(error: unknown) {
|
|
602
|
+
const raw =
|
|
603
|
+
error instanceof GitCommandError
|
|
604
|
+
? error.stderr || error.message
|
|
605
|
+
: error instanceof Error
|
|
606
|
+
? error.message
|
|
607
|
+
: String(error);
|
|
608
|
+
return redactGitError(raw, this.config.profile.remote, this.cacheDir);
|
|
609
|
+
}
|
|
669
610
|
}
|
|
670
611
|
|
|
671
612
|
export function gitBackendIdentity(config: ResolvedGitBackend) {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
config.destination.branch,
|
|
681
|
-
config.destination.directory,
|
|
682
|
-
]);
|
|
683
|
-
return `git:${sha256(Buffer.from(canonical))}`;
|
|
613
|
+
let remoteIdentity: string;
|
|
614
|
+
try {
|
|
615
|
+
remoteIdentity = normalizeGitRemoteIdentity(config.profile.remote);
|
|
616
|
+
} catch {
|
|
617
|
+
remoteIdentity = config.profile.remote;
|
|
618
|
+
}
|
|
619
|
+
const canonical = JSON.stringify([remoteIdentity, config.destination.branch, config.destination.directory]);
|
|
620
|
+
return `git:${sha256(Buffer.from(canonical))}`;
|
|
684
621
|
}
|
|
685
622
|
|
|
686
623
|
function gitDestination(config: ResolvedGitBackend) {
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
624
|
+
let host = "Git remote";
|
|
625
|
+
const remote = config.profile.remote;
|
|
626
|
+
if (remote.includes("://")) {
|
|
627
|
+
try {
|
|
628
|
+
host = new URL(remote).host;
|
|
629
|
+
} catch {
|
|
630
|
+
host = "Git remote";
|
|
631
|
+
}
|
|
632
|
+
} else {
|
|
633
|
+
const match = /^(?:[^@]+@)?(?<host>\[[^\]]+\]|[^:]+):/u.exec(remote);
|
|
634
|
+
if (match?.groups?.host) host = match.groups.host;
|
|
635
|
+
}
|
|
636
|
+
return `${host} · ${config.destination.branch}:${config.destination.directory}`;
|
|
700
637
|
}
|
|
701
638
|
|
|
702
639
|
function remoteHead(sha: string, manifest: GitManifest, identity: string): RemoteHead {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
640
|
+
return {
|
|
641
|
+
snapshotRef: sha,
|
|
642
|
+
snapshotId: manifest.snapshotId,
|
|
643
|
+
revision: `${identity}:${sha}`,
|
|
644
|
+
createdAt: manifest.createdAt,
|
|
645
|
+
machine: manifest.machine,
|
|
646
|
+
syncSessions: manifest.syncSessions,
|
|
647
|
+
...(manifest.selection === undefined ? {} : { selection: manifest.selection }),
|
|
648
|
+
};
|
|
712
649
|
}
|
|
713
650
|
|
|
714
|
-
function matchesExpected(
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
return current === decodeRevision(expected.revision, identity);
|
|
722
|
-
} catch {
|
|
723
|
-
return false;
|
|
724
|
-
}
|
|
651
|
+
function matchesExpected(current: string | undefined, expected: ExpectedRemoteHead, identity: string) {
|
|
652
|
+
if (expected.kind === "missing") return current === undefined;
|
|
653
|
+
try {
|
|
654
|
+
return current === decodeRevision(expected.revision, identity);
|
|
655
|
+
} catch {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
725
658
|
}
|
|
726
659
|
|
|
727
660
|
function decodeRevision(revision: string, identity: string) {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
661
|
+
const prefix = `${identity}:`;
|
|
662
|
+
const sha = revision.startsWith(prefix) ? revision.slice(prefix.length) : "";
|
|
663
|
+
if (!/^[0-9a-f]{40}$/u.test(sha)) throw new Error("Invalid Git remote revision.");
|
|
664
|
+
return sha;
|
|
732
665
|
}
|
|
733
666
|
|
|
734
667
|
function isCommitSha(value: string) {
|
|
735
|
-
|
|
668
|
+
return /^[0-9a-f]{40}$/u.test(value);
|
|
736
669
|
}
|
|
737
670
|
|
|
738
671
|
function requireCommitSha(value: string) {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
672
|
+
if (/^[0-9a-f]{64}$/u.test(value)) {
|
|
673
|
+
throw new Error("Unsupported Git SHA-256 repository; pi-sync currently requires SHA-1 refs.");
|
|
674
|
+
}
|
|
675
|
+
if (!/^[0-9a-f]{40}$/u.test(value)) throw new Error("Invalid Git publication reference.");
|
|
743
676
|
}
|
|
744
677
|
|
|
745
678
|
export function isSupportedGitVersion(value: string) {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
679
|
+
const match = /git version (\d+)\.(\d+)/u.exec(value);
|
|
680
|
+
if (!match) return false;
|
|
681
|
+
const major = Number(match[1]);
|
|
682
|
+
const minor = Number(match[2]);
|
|
683
|
+
return major > 2 || (major === 2 && minor >= 30);
|
|
751
684
|
}
|
|
752
685
|
|
|
753
686
|
function assertGitDestination(config: ResolvedGitBackend) {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
687
|
+
try {
|
|
688
|
+
if (
|
|
689
|
+
normalizeGitBranch(config.destination.branch) !== config.destination.branch ||
|
|
690
|
+
normalizeGitDirectory(config.destination.directory) !== config.destination.directory
|
|
691
|
+
) {
|
|
692
|
+
throw new Error("Git storage location is not normalized.");
|
|
693
|
+
}
|
|
694
|
+
validateGitNamespace(config.destination.namespace);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
throw new Error("Invalid Git storage location.", { cause: error });
|
|
697
|
+
}
|
|
765
698
|
}
|
|
766
699
|
|
|
767
700
|
function assertProductionRemote(remote: string) {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
throw new Error("Invalid or non-normalized Git remote.");
|
|
701
|
+
let normalized: string | undefined;
|
|
702
|
+
try {
|
|
703
|
+
normalized = normalizeGitRemote(remote);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
throw new Error(error instanceof Error ? error.message : "Invalid Git remote.", {
|
|
706
|
+
cause: error,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
if (!normalized || normalized !== remote) throw new Error("Invalid or non-normalized Git remote.");
|
|
778
710
|
}
|
|
779
711
|
|
|
780
|
-
async function withGitCacheMutation<T>(
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
)
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
throw error;
|
|
818
|
-
} finally {
|
|
819
|
-
signal.removeEventListener("abort", onAbort);
|
|
820
|
-
}
|
|
712
|
+
async function withGitCacheMutation<T>(cacheDir: string, run: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
713
|
+
const previous = gitCacheMutationQueues.get(cacheDir) ?? Promise.resolve();
|
|
714
|
+
let started = false;
|
|
715
|
+
const operation = previous
|
|
716
|
+
.catch(() => undefined)
|
|
717
|
+
.then(() => {
|
|
718
|
+
throwIfAborted(signal);
|
|
719
|
+
started = true;
|
|
720
|
+
return run();
|
|
721
|
+
});
|
|
722
|
+
const tail = operation.then(
|
|
723
|
+
() => undefined,
|
|
724
|
+
() => undefined,
|
|
725
|
+
);
|
|
726
|
+
gitCacheMutationQueues.set(cacheDir, tail);
|
|
727
|
+
void tail.then(() => {
|
|
728
|
+
if (gitCacheMutationQueues.get(cacheDir) === tail) gitCacheMutationQueues.delete(cacheDir);
|
|
729
|
+
});
|
|
730
|
+
if (!signal) return operation;
|
|
731
|
+
throwIfAborted(signal);
|
|
732
|
+
let rejectAbort: ((reason: unknown) => void) | undefined;
|
|
733
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
734
|
+
rejectAbort = reject;
|
|
735
|
+
});
|
|
736
|
+
const onAbort = () => rejectAbort?.(abortReason(signal));
|
|
737
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
738
|
+
if (signal.aborted) onAbort();
|
|
739
|
+
try {
|
|
740
|
+
return await Promise.race([operation, aborted]);
|
|
741
|
+
} catch (error) {
|
|
742
|
+
// A queued job can cancel immediately, but a running job owns a child/cache
|
|
743
|
+
// mutation. Drain its bounded cleanup before releasing outer sync guards.
|
|
744
|
+
if (started) await tail;
|
|
745
|
+
throw error;
|
|
746
|
+
} finally {
|
|
747
|
+
signal.removeEventListener("abort", onAbort);
|
|
748
|
+
}
|
|
821
749
|
}
|
|
822
750
|
|
|
823
751
|
async function assertNotSymlink(target: string, label: string) {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
752
|
+
try {
|
|
753
|
+
const stat = await fs.lstat(target);
|
|
754
|
+
if (stat.isSymbolicLink()) throw new Error(`Refusing symlinked ${label}.`);
|
|
755
|
+
} catch (error) {
|
|
756
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
757
|
+
}
|
|
830
758
|
}
|
|
831
759
|
|
|
832
760
|
function redactGitError(value: string, remote: string, cacheDir: string) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
761
|
+
return (
|
|
762
|
+
value
|
|
763
|
+
.replaceAll(remote, "<git-remote>")
|
|
764
|
+
.replaceAll(cacheDir, "<git-cache>")
|
|
765
|
+
.replace(/https:\/\/[^/@\s]+@/gu, "https://<credentials>@")
|
|
766
|
+
.replace(/\b(password|token|authorization)=\S+/giu, "$1=<redacted>")
|
|
767
|
+
.replace(/\bBearer\s+\S+/giu, "Bearer <redacted>")
|
|
768
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: sanitize untrusted process output.
|
|
769
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
|
770
|
+
.trim()
|
|
771
|
+
.slice(0, 4096)
|
|
772
|
+
);
|
|
845
773
|
}
|
|
846
774
|
|
|
847
775
|
function sha256(value: Buffer) {
|
|
848
|
-
|
|
776
|
+
return createHash("sha256").update(value).digest("hex");
|
|
849
777
|
}
|
|
850
778
|
|
|
851
779
|
function abortReason(signal: AbortSignal) {
|
|
852
|
-
|
|
853
|
-
? signal.reason
|
|
854
|
-
: new DOMException("The operation was aborted", "AbortError");
|
|
780
|
+
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
855
781
|
}
|
|
856
782
|
|
|
857
783
|
function throwIfAborted(signal?: AbortSignal) {
|
|
858
|
-
|
|
784
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
859
785
|
}
|