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.
Files changed (2) hide show
  1. package/dist/index.js +113 -14
  2. 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}/${basename(file)}` : basename(file),
82
- fileName: basename(file),
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 localSlugs = files.map((f) => f.fileName.replace(/\.png$/, ""));
87
- const allSlugs = [.../* @__PURE__ */ new Set([...options.baseManifest ?? [], ...localSlugs])];
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
- const batches = chunk(files, BATCH_SIZE);
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 totalSkipped = 0;
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
- totalSkipped += data.skipped ?? 0;
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
- if (totalSkipped > 0) parts.push(chalk2.dim(`${totalSkipped} unchanged`));
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-shots-cli",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "CLI for git-shots visual regression platform",
5
5
  "type": "module",
6
6
  "bin": {