git-shots-cli 0.6.2 → 0.8.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/dist/index.js +113 -14
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -44,6 +44,7 @@ function checkAuthError(res) {
|
|
|
44
44
|
|
|
45
45
|
// src/upload.ts
|
|
46
46
|
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
47
|
+
import { createHash } from "crypto";
|
|
47
48
|
import { resolve as resolve2, basename, dirname } from "path";
|
|
48
49
|
import { execSync } from "child_process";
|
|
49
50
|
import { glob } from "glob";
|
|
@@ -56,6 +57,25 @@ function chunk(arr, size) {
|
|
|
56
57
|
}
|
|
57
58
|
return chunks;
|
|
58
59
|
}
|
|
60
|
+
function computeFileHash(buffer) {
|
|
61
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
62
|
+
}
|
|
63
|
+
async function fetchServerHashes(config, branch) {
|
|
64
|
+
try {
|
|
65
|
+
const params = new URLSearchParams({ branch });
|
|
66
|
+
if (config.platform) params.set("platform", config.platform);
|
|
67
|
+
const url = `${config.server}/api/projects/${config.project}/hashes?${params}`;
|
|
68
|
+
const res = await fetch(url, {
|
|
69
|
+
headers: { ...authHeaders(config) }
|
|
70
|
+
});
|
|
71
|
+
checkAuthError(res);
|
|
72
|
+
if (!res.ok) return null;
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
return data.hashes;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
59
79
|
async function upload(config, options) {
|
|
60
80
|
const dir = resolve2(process.cwd(), config.directory);
|
|
61
81
|
if (!existsSync2(dir)) {
|
|
@@ -77,19 +97,82 @@ async function upload(config, options) {
|
|
|
77
97
|
}
|
|
78
98
|
const files = rawFiles.map((file) => {
|
|
79
99
|
const dirName = dirname(file);
|
|
100
|
+
const fileName = basename(file);
|
|
80
101
|
return {
|
|
81
|
-
fieldName: dirName !== "." ? `${dirName}/${
|
|
82
|
-
fileName
|
|
102
|
+
fieldName: dirName !== "." ? `${dirName}/${fileName}` : fileName,
|
|
103
|
+
fileName,
|
|
104
|
+
slug: fileName.replace(/\.png$/, ""),
|
|
83
105
|
fullPath: resolve2(dir, file)
|
|
84
106
|
};
|
|
85
107
|
});
|
|
86
|
-
const
|
|
87
|
-
|
|
108
|
+
const allSlugs = [.../* @__PURE__ */ new Set([...options.baseManifest ?? [], ...files.map((f) => f.slug)])];
|
|
109
|
+
if (options.force) {
|
|
110
|
+
console.log(chalk2.yellow("Force mode: bypassing content hash dedup"));
|
|
111
|
+
}
|
|
88
112
|
console.log(chalk2.dim(`Found ${files.length} screenshots`));
|
|
113
|
+
let filesToUpload = files;
|
|
114
|
+
let clientDedupSkipped = 0;
|
|
115
|
+
if (!options.force) {
|
|
116
|
+
const [branchHashes, mainHashes] = await Promise.all([
|
|
117
|
+
fetchServerHashes(config, branch),
|
|
118
|
+
branch !== "main" ? fetchServerHashes(config, "main") : Promise.resolve(null)
|
|
119
|
+
]);
|
|
120
|
+
if (branchHashes || mainHashes) {
|
|
121
|
+
console.log(chalk2.dim("Computing local hashes..."));
|
|
122
|
+
filesToUpload = files.filter((file) => {
|
|
123
|
+
file.buffer = readFileSync2(file.fullPath);
|
|
124
|
+
const localHash = computeFileHash(file.buffer);
|
|
125
|
+
const branchHash = branchHashes?.[file.slug];
|
|
126
|
+
const mainHash = mainHashes?.[file.slug];
|
|
127
|
+
if (branchHash && localHash === branchHash) return false;
|
|
128
|
+
if (mainHash && localHash === mainHash) return false;
|
|
129
|
+
return true;
|
|
130
|
+
});
|
|
131
|
+
clientDedupSkipped = files.length - filesToUpload.length;
|
|
132
|
+
if (clientDedupSkipped > 0) {
|
|
133
|
+
console.log(
|
|
134
|
+
chalk2.cyan(
|
|
135
|
+
`Client dedup: ${clientDedupSkipped} unchanged, ${filesToUpload.length} to upload`
|
|
136
|
+
)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
console.log(chalk2.dim("Hash endpoint unavailable, uploading all files"));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
89
143
|
const url = `${config.server}/api/upload`;
|
|
90
|
-
|
|
144
|
+
if (filesToUpload.length === 0) {
|
|
145
|
+
console.log(chalk2.dim("All files unchanged, sending manifest only"));
|
|
146
|
+
const formData = new FormData();
|
|
147
|
+
formData.append("project", config.project);
|
|
148
|
+
formData.append("branch", branch);
|
|
149
|
+
formData.append("gitSha", sha);
|
|
150
|
+
if (config.platform) formData.append("platform", config.platform);
|
|
151
|
+
formData.append("allSlugs", JSON.stringify(allSlugs));
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(url, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
body: formData,
|
|
156
|
+
headers: { Origin: config.server, ...authHeaders(config) }
|
|
157
|
+
});
|
|
158
|
+
checkAuthError(res);
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
console.error(chalk2.red(`Manifest update failed: ${JSON.stringify(data)}`));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error(chalk2.red(`Manifest request failed: ${err}`));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
console.log(`
|
|
169
|
+
${chalk2.dim(`${clientDedupSkipped} unchanged`)} (manifest updated)`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const batches = chunk(filesToUpload, BATCH_SIZE);
|
|
91
173
|
let totalUploaded = 0;
|
|
92
|
-
let
|
|
174
|
+
let serverSkipped = 0;
|
|
175
|
+
const totalSkippedScreens = [];
|
|
93
176
|
for (let i = 0; i < batches.length; i++) {
|
|
94
177
|
const batch = batches[i];
|
|
95
178
|
const isLastBatch = i === batches.length - 1;
|
|
@@ -98,11 +181,12 @@ async function upload(config, options) {
|
|
|
98
181
|
formData.append("branch", branch);
|
|
99
182
|
formData.append("gitSha", sha);
|
|
100
183
|
if (config.platform) formData.append("platform", config.platform);
|
|
184
|
+
if (options.force) formData.append("forceUpload", "true");
|
|
101
185
|
if (isLastBatch) {
|
|
102
186
|
formData.append("allSlugs", JSON.stringify(allSlugs));
|
|
103
187
|
}
|
|
104
188
|
for (const file of batch) {
|
|
105
|
-
const buffer = readFileSync2(file.fullPath);
|
|
189
|
+
const buffer = file.buffer ?? readFileSync2(file.fullPath);
|
|
106
190
|
const blob = new Blob([buffer], { type: "image/png" });
|
|
107
191
|
formData.append(file.fieldName, blob, file.fileName);
|
|
108
192
|
}
|
|
@@ -120,7 +204,8 @@ async function upload(config, options) {
|
|
|
120
204
|
process.exit(1);
|
|
121
205
|
}
|
|
122
206
|
totalUploaded += data.uploaded ?? 0;
|
|
123
|
-
|
|
207
|
+
serverSkipped += data.skipped ?? 0;
|
|
208
|
+
if (data.skippedScreens) totalSkippedScreens.push(...data.skippedScreens);
|
|
124
209
|
if (batches.length > 1) {
|
|
125
210
|
const parts2 = [];
|
|
126
211
|
if (data.uploaded > 0) parts2.push(chalk2.green(`${data.uploaded} uploaded`));
|
|
@@ -134,9 +219,22 @@ async function upload(config, options) {
|
|
|
134
219
|
}
|
|
135
220
|
const parts = [];
|
|
136
221
|
if (totalUploaded > 0) parts.push(chalk2.green(`${totalUploaded} uploaded`));
|
|
137
|
-
|
|
222
|
+
const totalUnchanged = clientDedupSkipped + serverSkipped;
|
|
223
|
+
if (totalUnchanged > 0) parts.push(chalk2.dim(`${totalUnchanged} unchanged`));
|
|
138
224
|
console.log(`
|
|
139
225
|
${parts.join(", ") || chalk2.dim("nothing to do")}`);
|
|
226
|
+
if (clientDedupSkipped > 0) {
|
|
227
|
+
console.log(chalk2.dim(` (${clientDedupSkipped} skipped client-side, ${serverSkipped} server-side)`));
|
|
228
|
+
}
|
|
229
|
+
if (totalSkippedScreens.length > 0) {
|
|
230
|
+
const MAX_DISPLAY = 20;
|
|
231
|
+
if (totalSkippedScreens.length <= MAX_DISPLAY) {
|
|
232
|
+
console.log(chalk2.dim(`Skipped: ${totalSkippedScreens.join(", ")}`));
|
|
233
|
+
} else {
|
|
234
|
+
const shown = totalSkippedScreens.slice(0, MAX_DISPLAY).join(", ");
|
|
235
|
+
console.log(chalk2.dim(`Skipped: ${shown} ... and ${totalSkippedScreens.length - MAX_DISPLAY} more`));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
140
238
|
}
|
|
141
239
|
|
|
142
240
|
// src/compare.ts
|
|
@@ -333,7 +431,7 @@ async function review(config, options) {
|
|
|
333
431
|
} catch {
|
|
334
432
|
}
|
|
335
433
|
console.log(chalk6.dim("Uploading screenshots..."));
|
|
336
|
-
await upload(config, { branch, sha, baseManifest });
|
|
434
|
+
await upload(config, { branch, sha, baseManifest, force: options.force });
|
|
337
435
|
console.log();
|
|
338
436
|
console.log(chalk6.dim("Creating review session..."));
|
|
339
437
|
const reviewUrl = `${config.server}/api/reviews`;
|
|
@@ -687,7 +785,7 @@ var require2 = createRequire(import.meta.url);
|
|
|
687
785
|
var { version } = require2("../package.json");
|
|
688
786
|
var program = new Command();
|
|
689
787
|
program.name("git-shots").description("CLI for git-shots visual regression platform").version(version);
|
|
690
|
-
program.command("upload").description("Upload screenshots to git-shots").option("-p, --project <slug>", "Project slug").option("-s, --server <url>", "Server URL").option("-d, --directory <path>", "Screenshots directory").option("-b, --branch <name>", "Git branch (auto-detected)").option("--sha <hash>", "Git SHA (auto-detected)").option("--platform <name>", "Platform tag (e.g., android, web)").action(async (options) => {
|
|
788
|
+
program.command("upload").description("Upload screenshots to git-shots").option("-p, --project <slug>", "Project slug").option("-s, --server <url>", "Server URL").option("-d, --directory <path>", "Screenshots directory").option("-b, --branch <name>", "Git branch (auto-detected)").option("--sha <hash>", "Git SHA (auto-detected)").option("--platform <name>", "Platform tag (e.g., android, web)").option("-f, --force", "Re-upload all files, bypassing content hash dedup").action(async (options) => {
|
|
691
789
|
const config = loadConfig();
|
|
692
790
|
if (options.project) config.project = options.project;
|
|
693
791
|
if (options.server) config.server = options.server;
|
|
@@ -697,7 +795,7 @@ program.command("upload").description("Upload screenshots to git-shots").option(
|
|
|
697
795
|
console.error("Error: project slug required. Use --project or .git-shots.json");
|
|
698
796
|
process.exit(1);
|
|
699
797
|
}
|
|
700
|
-
await upload(config, { branch: options.branch, sha: options.sha });
|
|
798
|
+
await upload(config, { branch: options.branch, sha: options.sha, force: options.force });
|
|
701
799
|
if (config.flows && config.flows.length > 0) {
|
|
702
800
|
await syncFlows(config);
|
|
703
801
|
}
|
|
@@ -734,7 +832,7 @@ program.command("pull-baselines").description("Download baseline screenshots fro
|
|
|
734
832
|
}
|
|
735
833
|
await pullBaselines(config, { branch: options.branch, output: options.output });
|
|
736
834
|
});
|
|
737
|
-
program.command("review").description("Upload screenshots, create review session, and poll for verdict").option("-p, --project <slug>", "Project slug").option("-s, --server <url>", "Server URL").option("-d, --directory <path>", "Screenshots directory").option("-b, --branch <name>", "Git branch (auto-detected)").option("--sha <hash>", "Git SHA (auto-detected)").option("--platform <name>", "Platform tag (e.g., android, web)").option("--open", "Open review URL in browser", true).option("--no-open", "Do not open review URL in browser").option("--poll", "Poll for verdict and exit with code", true).option("--no-poll", "Do not poll for verdict").option("--timeout <seconds>", "Polling timeout in seconds", parseInt, 300).option("--screens <patterns>", "Screen slug patterns to compare (comma-separated, supports *)").action(async (options) => {
|
|
835
|
+
program.command("review").description("Upload screenshots, create review session, and poll for verdict").option("-p, --project <slug>", "Project slug").option("-s, --server <url>", "Server URL").option("-d, --directory <path>", "Screenshots directory").option("-b, --branch <name>", "Git branch (auto-detected)").option("--sha <hash>", "Git SHA (auto-detected)").option("--platform <name>", "Platform tag (e.g., android, web)").option("--open", "Open review URL in browser", true).option("--no-open", "Do not open review URL in browser").option("--poll", "Poll for verdict and exit with code", true).option("--no-poll", "Do not poll for verdict").option("--timeout <seconds>", "Polling timeout in seconds", parseInt, 300).option("--screens <patterns>", "Screen slug patterns to compare (comma-separated, supports *)").option("-f, --force", "Re-upload all files, bypassing content hash dedup").action(async (options) => {
|
|
738
836
|
const config = loadConfig();
|
|
739
837
|
if (options.project) config.project = options.project;
|
|
740
838
|
if (options.server) config.server = options.server;
|
|
@@ -751,7 +849,8 @@ program.command("review").description("Upload screenshots, create review session
|
|
|
751
849
|
open: options.open,
|
|
752
850
|
poll: options.poll,
|
|
753
851
|
timeout: options.timeout,
|
|
754
|
-
screens
|
|
852
|
+
screens,
|
|
853
|
+
force: options.force
|
|
755
854
|
});
|
|
756
855
|
});
|
|
757
856
|
program.command("rename <old-slug> <new-slug>").description("Rename a screen slug (moves R2 objects server-side)").option("-p, --project <slug>", "Project slug").option("-s, --server <url>", "Server URL").option("--platform <name>", "Platform to disambiguate (e.g., android, web)").option("--category <value>", "Set new category (use empty string to clear)").action(async (oldSlug, newSlug, options) => {
|