@buildinternet/uploads 0.50.1 → 0.52.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/client.d.ts CHANGED
@@ -326,6 +326,22 @@ export interface PromoteSkip {
326
326
  export interface PromoteBranchAttachmentsResult {
327
327
  promoted: string[];
328
328
  skipped: PromoteSkip[];
329
+ /** Branch-name lineage the sweep covered, current name first (issue #920).
330
+ * Present only when the branch was renamed at least once. */
331
+ lineage?: string[];
332
+ }
333
+ /** `POST /v1/workspaces/:workspace/github/branch-rename` request (server contract, issue #920). */
334
+ export interface RegisterBranchRenameOptions {
335
+ repo: string;
336
+ /** Previous branch name (`git branch -m <from> <to>`). */
337
+ from: string;
338
+ /** Name the branch was renamed to. */
339
+ to: string;
340
+ }
341
+ /** `POST /v1/workspaces/:workspace/github/branch-rename` response. `recorded:
342
+ * false` means the pair was already known (or the server has no such route). */
343
+ export interface RegisterBranchRenameResult {
344
+ recorded: boolean;
329
345
  }
330
346
  /**
331
347
  * `POST /v1/workspaces/:workspace/github/attach` request/response (server
@@ -758,6 +774,15 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
758
774
  * that doesn't have this route yet, as "nothing promoted").
759
775
  */
760
776
  promoteBranchAttachments(opts: PromoteBranchAttachmentsOptions): Promise<PromoteBranchAttachmentsResult>;
777
+ /**
778
+ * Record one `git branch -m` step so promote sweeps the branch's whole
779
+ * name lineage (issue #920). Degrade-safe on the one failure that is
780
+ * expected in the wild: a 404 from an older/self-hosted worker without
781
+ * this route collapses to `{ recorded: false }` — nothing recorded, same
782
+ * as `promoteBranchAttachments`' callers treat a missing route. Other
783
+ * failures throw; the CLI's `registerRenamesBestEffort` swallows those.
784
+ */
785
+ registerBranchRename(opts: RegisterBranchRenameOptions): Promise<RegisterBranchRenameResult>;
761
786
  /**
762
787
  * Attach an already-uploaded object to a PR/issue via a server-side copy
763
788
  * (issue #702) — see `AttachExistingOptions`. Throws `UploadsError` on
package/dist/client.js CHANGED
@@ -724,6 +724,27 @@ export function createUploadsClient(config) {
724
724
  headers: { "Content-Type": "application/json" },
725
725
  });
726
726
  },
727
+ /**
728
+ * Record one `git branch -m` step so promote sweeps the branch's whole
729
+ * name lineage (issue #920). Degrade-safe on the one failure that is
730
+ * expected in the wild: a 404 from an older/self-hosted worker without
731
+ * this route collapses to `{ recorded: false }` — nothing recorded, same
732
+ * as `promoteBranchAttachments`' callers treat a missing route. Other
733
+ * failures throw; the CLI's `registerRenamesBestEffort` swallows those.
734
+ */
735
+ async registerBranchRename(opts) {
736
+ try {
737
+ return await request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/branch-rename`, {
738
+ body: new TextEncoder().encode(JSON.stringify(opts)),
739
+ headers: { "Content-Type": "application/json" },
740
+ });
741
+ }
742
+ catch (err) {
743
+ if (err instanceof UploadsError && err.status === 404)
744
+ return { recorded: false };
745
+ throw err;
746
+ }
747
+ },
727
748
  /**
728
749
  * Attach an already-uploaded object to a PR/issue via a server-side copy
729
750
  * (issue #702) — see `AttachExistingOptions`. Throws `UploadsError` on
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { extractDashValue, flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
- import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, writeReplacedNote, resolveGhPrefixSafe, resolveAutoPrTarget, resolvePutNudgeContext, putNudgeText, autoPrNoteText, } from "../commands.js";
5
+ import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, registerRenamesBestEffort, writeReplacedNote, resolveGhPrefixSafe, resolveAutoPrTarget, resolvePutNudgeContext, putNudgeText, autoPrNoteText, } from "../commands.js";
6
6
  import { resolvePutDefaults } from "../config.js";
7
7
  import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
8
8
  import { resolvePutPrefix } from "../destinations.js";
@@ -351,7 +351,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
351
351
  // maps to exactly one open PR behaves as if --pr <n> had been passed —
352
352
  // stable key + managed comment sync — instead of the #469 auto-staging
353
353
  // default below. Mirrors put's #700 handling exactly (resolveAutoPrTarget).
354
- const autoPrTarget = ghTarget || branchArg !== undefined
354
+ const autoPrMatch = ghTarget || branchArg !== undefined
355
355
  ? undefined
356
356
  : resolveAutoPrTarget({
357
357
  ghTarget,
@@ -365,6 +365,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
365
365
  repoArg: flagString(parsed.flags, "--repo") ?? putDefaults.repo,
366
366
  run,
367
367
  });
368
+ const autoPrTarget = autoPrMatch?.target;
368
369
  const effectiveGhTarget = ghTarget ?? autoPrTarget;
369
370
  // Auto branch staging (issue #469 lever 1): mirrors bare `put`'s auto-staging
370
371
  // (issue #403). When no --branch/--pr/--issue/--key/--ref/--prefix/--destination
@@ -428,9 +429,23 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
428
429
  if (effectiveGhTarget) {
429
430
  metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(effectiveGhTarget, run) };
430
431
  validateMetaMap(metadata);
432
+ // The #700 auto-PR match suppresses staging, so the staging branch below
433
+ // never runs — but this capture still comes from a branch that may have
434
+ // been renamed while files were staged under the old name. Register the
435
+ // lineage here too (issue #920), reusing the branch the match already
436
+ // resolved. Only for the auto-PR match: an explicit --pr/--issue names
437
+ // no branch of its own.
438
+ if (autoPrMatch && !dryRun && !noUpload) {
439
+ await registerRenamesBestEffort(ctx.client, run, autoPrMatch.target.repo, autoPrMatch.branch);
440
+ }
431
441
  }
432
442
  else if (stagingTarget !== undefined) {
433
443
  metadata = mergeStagingMeta(withFacts, stagingTarget);
444
+ // Same rename registration as `put`/`attach --branch` staging (issue
445
+ // #920): best-effort, never fails the capture.
446
+ if (!dryRun && !noUpload) {
447
+ await registerRenamesBestEffort(ctx.client, run, stagingTarget.repo, stagingTarget.branch);
448
+ }
434
449
  }
435
450
  else if (Object.keys(withFacts).length > 0) {
436
451
  validateMetaMap(withFacts);
@@ -394,6 +394,23 @@ export declare function uploadPuts(opts: {
394
394
  width?: number;
395
395
  concurrency?: number;
396
396
  }): Promise<UploadBatchResult<PutUploadItem>>;
397
+ /**
398
+ * Best-effort branch-rename registration (issue #920): reads this branch's
399
+ * reflog for `git branch -m` steps and tells the server about each one, so a
400
+ * later promote sweeps the branch's whole name lineage instead of only its
401
+ * current name. No-op when the reflog has no rename (the common case) and
402
+ * when the client predates the route. Every failure is swallowed — this runs
403
+ * alongside staging and promote, and must never fail either. Set
404
+ * `UPLOADS_DEBUG=1` to see what was skipped.
405
+ *
406
+ * `opts.explicit` marks a branch the user named themselves (`--from-branch`
407
+ * / the MCP `fromBranch` argument): that is the manual escape hatch (PR
408
+ * #919), and its lineage belongs to a different name than the one we are
409
+ * standing on, so nothing is registered for it.
410
+ */
411
+ export declare function registerRenamesBestEffort(client: UploadsClient, run: CommandRunner, repo: string, branch: string, opts?: {
412
+ explicit?: boolean;
413
+ }): Promise<void>;
397
414
  export declare function runAttach(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
398
415
  /**
399
416
  * One source of truth for the "staged, but not going to auto-attach" advisory
@@ -465,6 +482,11 @@ export declare function resolvePutNudgeContext(opts: {
465
482
  branch: string;
466
483
  pr: number | undefined;
467
484
  } | undefined;
485
+ /** A #700 auto-PR match: the PR the current branch maps to, plus that branch. */
486
+ export interface AutoPrMatch {
487
+ target: GhTarget;
488
+ branch: string;
489
+ }
468
490
  /**
469
491
  * Auto-PR context (issue #700): when a bare put/screenshot has no explicit
470
492
  * destination flag at all (`--pr`/`--issue`/`--key`/`--ref`/`--prefix`/
@@ -481,6 +503,10 @@ export declare function resolvePutNudgeContext(opts: {
481
503
  * default branch, or with `--no-git`; any failure (not a repo, detached
482
504
  * HEAD, gh missing/unauthenticated/timed out, no open PR) degrades to
483
505
  * undefined so the caller falls back to its normal staging/dated behavior.
506
+ *
507
+ * The match carries the `branch` it was resolved from, so callers that need
508
+ * the current branch (the #920 rename registration) reuse it instead of
509
+ * spawning `git rev-parse` a second time.
484
510
  */
485
511
  export declare function resolveAutoPrTarget(opts: {
486
512
  ghTarget: GhTarget | undefined;
@@ -495,7 +521,7 @@ export declare function resolveAutoPrTarget(opts: {
495
521
  noAutoPr: boolean;
496
522
  repoArg: string | undefined;
497
523
  run: CommandRunner;
498
- }): GhTarget | undefined;
524
+ }): AutoPrMatch | undefined;
499
525
  /**
500
526
  * Bare-put branch-staging trigger (issue #403): put on a non-default git
501
527
  * branch stages to the branch prefix by default — the branch becomes the
package/dist/commands.js CHANGED
@@ -4,7 +4,7 @@ import { mapBounded } from "./async.js";
4
4
  import { createUploadsClient, } from "./client.js";
5
5
  import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
6
6
  import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
7
- import { buildUploadMarkdown } from "./embed.js";
7
+ import { buildUploadMarkdown, fileKindFromName } from "./embed.js";
8
8
  import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
9
9
  import { urlForGithubEmbed } from "./public-urls.js";
10
10
  import { UploadsError } from "./errors.js";
@@ -15,7 +15,7 @@ import { parseMetaFlags, validateMetaMap } from "./metadata.js";
15
15
  import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
16
16
  import { mergeSidecarMeta } from "./sidecar.js";
17
17
  import { ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, ghBranchKeyPrefix, ghKeyPrefix, ghPrivateKeyPrefix, ghPrivateBranchKeyPrefix, ghMetadataFromTarget, parseGhKey, parseGhPrivateKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
18
- import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, hasLinkCandidate, extractCandidateUrls, fetchAdoptionCandidateText, } from "./github-gh.js";
18
+ import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveCurrentBranchSafe, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, hasLinkCandidate, extractCandidateUrls, fetchAdoptionCandidateText, renameLineageFromReflog, } from "./github-gh.js";
19
19
  import { deriveRepoFromGit, deriveRepoSlugFromGit } from "./keys.js";
20
20
  import { noProjectContextNudge } from "./project-context-nudge.js";
21
21
  import { resolvePutPrefix } from "./destinations.js";
@@ -110,6 +110,10 @@ Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
110
110
  high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
111
111
  when they are already smaller, animated, or not an image. Use --no-optimize to
112
112
  upload as-is, or --keep-exif when image metadata matters for the discussion.
113
+ Non-media files (PDF, zip, gzip, logs, JSON, CSV, markdown) upload as-is and
114
+ show up in the managed comment as links. HTML and SVG are rejected. Uploads are
115
+ public, so scrub secrets and tokens out of logs, JSON, and other text before
116
+ uploading.
113
117
 
114
118
  Optional --frame wraps the image in a device/browser chrome before optimize
115
119
  (default off). See: uploads put --help frames
@@ -421,13 +425,23 @@ export async function prepareImageForUpload(bytes, filename, opts) {
421
425
  return { ...optimized, frame: frameMeta };
422
426
  }
423
427
  /**
424
- * Merge an image's own EXIF-derived facts under any explicit metadata.
428
+ * Merge an image's own EXIF-derived facts under any explicit metadata, when
429
+ * the caller asked for them and the filename could plausibly be an image —
430
+ * only an `image/*` extension, or none at all (an extension-less
431
+ * screenshot), is worth an `imageFactsFromBytes` probe
432
+ * (`sharp(bytes).metadata()` under the hood); a known non-image extension (a
433
+ * 25 MB zip, a `.log`, a PDF…) skips it rather than paying a sharp call that
434
+ * can only come back empty.
435
+ *
425
436
  * Best-effort by contract: `imageFactsFromBytes` never rejects, and a full key
426
437
  * budget drops the derived pairs rather than failing the upload. Returns the
427
438
  * input untouched (including `undefined`) when there is nothing to add, so a
428
439
  * metadata-free upload stays metadata-free.
429
440
  */
430
- async function mergeImageFacts(bytes, metadata) {
441
+ async function maybeDeriveImageFacts(enabled, sourceName, bytes, metadata) {
442
+ const kind = fileKindFromName(sourceName);
443
+ if (!enabled || (kind !== "image" && kind !== "unknown"))
444
+ return metadata;
431
445
  const facts = await imageFactsFromBytes(bytes);
432
446
  if (Object.keys(facts).length === 0)
433
447
  return metadata;
@@ -444,9 +458,7 @@ async function mergeImageFacts(bytes, metadata) {
444
458
  */
445
459
  export async function uploadPreparedImage(client, bytes, sourceName, opts) {
446
460
  // Read EXIF from the original bytes before the optimizer strips it.
447
- const metadata = opts.deriveImageFacts
448
- ? await mergeImageFacts(bytes, opts.metadata)
449
- : opts.metadata;
461
+ const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, opts.metadata);
450
462
  const prepared = await prepareImageForUpload(bytes, sourceName, {
451
463
  frameId: opts.frame.frameId,
452
464
  frameUrl: opts.frame.frameUrl,
@@ -809,11 +821,13 @@ takes effect with zero files and cannot combine with --branch/--issue/
809
821
  --no-promote. Promotion never applies to issues. Staged files stay findable
810
822
  with "uploads find gh.branch=<branch>" either way.
811
823
 
812
- If the branch was renamed or deleted before the PR opened, pass
813
- "--from-branch <old-name>" with "--pr <num>". With no file arguments, this
814
- promotes the stale branch prefix and refreshes the managed comment. With file
815
- or existing-key arguments, it promotes the stale prefix before the normal
816
- attach flow.
824
+ A branch renamed with "git branch -m" is followed automatically: the rename
825
+ is read from the branch reflog and registered, so promotion sweeps the older
826
+ names too. That needs one uploads run after the rename. If the branch was
827
+ renamed without one, or was deleted, pass "--from-branch <old-name>" with
828
+ "--pr <num>". With no file arguments, this promotes the stale branch prefix
829
+ and refreshes the managed comment. With file or existing-key arguments, it
830
+ promotes the stale prefix before the normal attach flow.
817
831
 
818
832
  Options:
819
833
  --pr <num> Attach to this pull request
@@ -953,9 +967,7 @@ async function uploadAttachmentBatch(opts) {
953
967
  const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
954
968
  // Same EXIF promotion uploadPreparedImage does; attach keeps its own
955
969
  // per-file tail (it builds keys differently), so it opts in here too.
956
- const metadata = opts.deriveImageFacts
957
- ? await mergeImageFacts(bytes, baseMetadata)
958
- : baseMetadata;
970
+ const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, baseMetadata);
959
971
  const prepared = await prepareImageForUpload(bytes, sourceName, {
960
972
  ...opts.frame,
961
973
  optimize: opts.optimize,
@@ -1174,6 +1186,45 @@ export async function uploadPuts(opts) {
1174
1186
  }
1175
1187
  return { uploads, failures, firstError, sentMetadata };
1176
1188
  }
1189
+ /**
1190
+ * Best-effort branch-rename registration (issue #920): reads this branch's
1191
+ * reflog for `git branch -m` steps and tells the server about each one, so a
1192
+ * later promote sweeps the branch's whole name lineage instead of only its
1193
+ * current name. No-op when the reflog has no rename (the common case) and
1194
+ * when the client predates the route. Every failure is swallowed — this runs
1195
+ * alongside staging and promote, and must never fail either. Set
1196
+ * `UPLOADS_DEBUG=1` to see what was skipped.
1197
+ *
1198
+ * `opts.explicit` marks a branch the user named themselves (`--from-branch`
1199
+ * / the MCP `fromBranch` argument): that is the manual escape hatch (PR
1200
+ * #919), and its lineage belongs to a different name than the one we are
1201
+ * standing on, so nothing is registered for it.
1202
+ */
1203
+ export async function registerRenamesBestEffort(client, run, repo, branch, opts = {}) {
1204
+ if (opts.explicit)
1205
+ return;
1206
+ let lineage;
1207
+ try {
1208
+ lineage = renameLineageFromReflog(run, branch);
1209
+ }
1210
+ catch {
1211
+ return;
1212
+ }
1213
+ if (lineage.length === 0)
1214
+ return;
1215
+ for (const step of lineage) {
1216
+ try {
1217
+ await client.registerBranchRename({ repo, from: step.from, to: step.to });
1218
+ }
1219
+ catch (err) {
1220
+ if (process.env.UPLOADS_DEBUG === "1") {
1221
+ const detail = err instanceof Error ? err.message : String(err);
1222
+ process.stderr.write(`debug: could not register branch rename ${step.from} -> ${step.to}: ${detail}\n`);
1223
+ }
1224
+ return; // one failure means the route is unavailable; don't retry the rest
1225
+ }
1226
+ }
1227
+ }
1177
1228
  /**
1178
1229
  * Best-effort call to `POST /v1/workspaces/:workspace/github/promote` (server contract,
1179
1230
  * PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
@@ -1189,11 +1240,20 @@ async function attemptPromoteBranch(client, target, branch) {
1189
1240
  return undefined;
1190
1241
  }
1191
1242
  }
1192
- /** Human-mode note for a promotion that actually promoted something. */
1243
+ /**
1244
+ * Human-mode note(s) for a promotion that actually promoted something. When
1245
+ * the server-side sweep followed a rename it adds a second line naming the
1246
+ * older branch names (issue #920): `lineage` is current-name-first, so the
1247
+ * older names are its tail.
1248
+ */
1193
1249
  function promotionNote(promotion, branch) {
1194
1250
  const n = promotion.promoted.length;
1195
1251
  const branchSuffix = branch ? ` from branch ${branch}` : "";
1196
- return `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
1252
+ const promoted = `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
1253
+ const older = (promotion.lineage ?? []).slice(1);
1254
+ if (older.length === 0)
1255
+ return promoted;
1256
+ return `${promoted}>> followed rename from ${older.join(", ")}\n`;
1197
1257
  }
1198
1258
  export async function runAttach(ctx, args, help = false, run = execRunner) {
1199
1259
  const parsed = parseCommandArgs(args);
@@ -1344,16 +1404,11 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1344
1404
  let promotion;
1345
1405
  let promotedBranch;
1346
1406
  if (target.kind === "pull" && !parsed.flags.has("--no-promote")) {
1347
- promotedBranch = fromBranch;
1348
- if (promotedBranch === undefined) {
1349
- try {
1350
- promotedBranch = resolveCurrentBranch(run);
1351
- }
1352
- catch {
1353
- promotedBranch = undefined;
1354
- }
1355
- }
1407
+ promotedBranch = fromBranch ?? resolveCurrentBranchSafe(run);
1356
1408
  if (promotedBranch !== undefined) {
1409
+ await registerRenamesBestEffort(ctx.client, run, target.repo, promotedBranch, {
1410
+ explicit: fromBranch !== undefined,
1411
+ });
1357
1412
  promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
1358
1413
  }
1359
1414
  }
@@ -1449,6 +1504,9 @@ async function runAttachBranch(ctx, parsed, branch, run) {
1449
1504
  process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"} (staged for branch ${branch})\n`);
1450
1505
  }
1451
1506
  const target = { repo, branch };
1507
+ // Register any `git branch -m` steps behind this name (issue #920) so the
1508
+ // promote that runs when the PR opens sweeps the older names too.
1509
+ await registerRenamesBestEffort(ctx.client, run, repo, branch);
1452
1510
  const { uploads, failures, firstError } = await uploadBranchAttachments({
1453
1511
  client: ctx.client,
1454
1512
  target,
@@ -1567,7 +1625,11 @@ async function runAttachPromoteOnly(ctx, parsed, run) {
1567
1625
  if (target.kind !== "pull") {
1568
1626
  throw new UsageError("--from-branch only promotes into a pull request");
1569
1627
  }
1570
- const branch = flagString(parsed.flags, "--from-branch") ?? resolveCurrentBranch(run);
1628
+ const explicitFromBranch = flagString(parsed.flags, "--from-branch");
1629
+ const branch = explicitFromBranch ?? resolveCurrentBranch(run);
1630
+ await registerRenamesBestEffort(ctx.client, run, target.repo, branch, {
1631
+ explicit: explicitFromBranch !== undefined,
1632
+ });
1571
1633
  const promotion = await attemptPromoteBranch(ctx.client, target, branch);
1572
1634
  let comment;
1573
1635
  let commentError;
@@ -1656,13 +1718,9 @@ export function resolvePutNudgeContext(opts) {
1656
1718
  try {
1657
1719
  if (deriveRepoFromGit(run) === undefined)
1658
1720
  return undefined; // not a (usable) git repo
1659
- let branch;
1660
- try {
1661
- branch = resolveCurrentBranch(run);
1662
- }
1663
- catch {
1721
+ const branch = resolveCurrentBranchSafe(run);
1722
+ if (branch === undefined)
1664
1723
  return undefined; // detached HEAD, or git unavailable
1665
- }
1666
1724
  const defaultBranch = resolveDefaultBranch(run);
1667
1725
  const onDefaultBranch = defaultBranch
1668
1726
  ? branch === defaultBranch
@@ -1704,6 +1762,10 @@ export function resolvePutNudgeContext(opts) {
1704
1762
  * default branch, or with `--no-git`; any failure (not a repo, detached
1705
1763
  * HEAD, gh missing/unauthenticated/timed out, no open PR) degrades to
1706
1764
  * undefined so the caller falls back to its normal staging/dated behavior.
1765
+ *
1766
+ * The match carries the `branch` it was resolved from, so callers that need
1767
+ * the current branch (the #920 rename registration) reuse it instead of
1768
+ * spawning `git rev-parse` a second time.
1707
1769
  */
1708
1770
  export function resolveAutoPrTarget(opts) {
1709
1771
  const { ghTarget, keyHint, refArg, prefixArg, destinationArg, branchArg, noGit, noAutoPr, repoArg, run, } = opts;
@@ -1716,13 +1778,9 @@ export function resolveAutoPrTarget(opts) {
1716
1778
  try {
1717
1779
  if (deriveRepoFromGit(run) === undefined)
1718
1780
  return undefined; // not a (usable) git repo
1719
- let branch;
1720
- try {
1721
- branch = resolveCurrentBranch(run);
1722
- }
1723
- catch {
1781
+ const branch = resolveCurrentBranchSafe(run);
1782
+ if (branch === undefined)
1724
1783
  return undefined; // detached HEAD, or git unavailable
1725
- }
1726
1784
  const defaultBranch = resolveDefaultBranch(run);
1727
1785
  const onDefaultBranch = defaultBranch
1728
1786
  ? branch === defaultBranch
@@ -1733,7 +1791,7 @@ export function resolveAutoPrTarget(opts) {
1733
1791
  // this must never be felt as a hang.
1734
1792
  const timed = run === execRunner ? timedExecRunner(PUT_NUDGE_GH_TIMEOUT_MS) : run;
1735
1793
  const repo = resolveRepo(repoArg, timed);
1736
- return resolveCurrentPullRequest(repo, timed);
1794
+ return { target: resolveCurrentPullRequest(repo, timed), branch };
1737
1795
  }
1738
1796
  catch {
1739
1797
  return undefined; // gh/git unavailable, no open PR, or repo unresolvable
@@ -1766,13 +1824,9 @@ export function resolvePutStagingTarget(opts) {
1766
1824
  try {
1767
1825
  if (deriveRepoFromGit(run) === undefined)
1768
1826
  return undefined; // not a (usable) git repo
1769
- let branch;
1770
- try {
1771
- branch = resolveCurrentBranch(run);
1772
- }
1773
- catch {
1827
+ const branch = resolveCurrentBranchSafe(run);
1828
+ if (branch === undefined)
1774
1829
  return undefined; // detached HEAD, or git unavailable
1775
- }
1776
1830
  const defaultBranch = resolveDefaultBranch(run);
1777
1831
  const onDefaultBranch = defaultBranch
1778
1832
  ? branch === defaultBranch
@@ -1993,7 +2047,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1993
2047
  // resolveAutoPrTarget. Supersedes both the #403 staging default and the
1994
2048
  // #393 nudge for this case; computed before the gh.* metadata resolution
1995
2049
  // below since it takes over that resolution entirely.
1996
- const autoPrTarget = ghTarget
2050
+ const autoPrMatch = ghTarget
1997
2051
  ? undefined
1998
2052
  : resolveAutoPrTarget({
1999
2053
  ghTarget,
@@ -2006,6 +2060,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
2006
2060
  repoArg: flagString(parsed.flags, "--repo") ?? defaults.repo,
2007
2061
  run,
2008
2062
  });
2063
+ const autoPrTarget = autoPrMatch?.target;
2009
2064
  const effectiveGhTarget = ghTarget ?? autoPrTarget;
2010
2065
  // Comment sync runs by default with --pr/--issue (matches `attach`); opt
2011
2066
  // out with --no-comment. --comment is accepted as a redundant no-op for
@@ -2137,9 +2192,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
2137
2192
  validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
2138
2193
  metadata = merged;
2139
2194
  attachedRef = merged["gh.ref"];
2195
+ // Auto-PR (#700) suppresses staging, so the staging branch below never
2196
+ // runs — but this put still comes from a branch that may have been
2197
+ // renamed while files were staged under the old name. Register the
2198
+ // lineage here too (issue #920), reusing the branch the match already
2199
+ // resolved. Only for the auto-PR match: an explicit --pr/--issue names
2200
+ // no branch of its own.
2201
+ if (autoPrMatch && !dryRun) {
2202
+ await registerRenamesBestEffort(ctx.client, run, autoPrMatch.target.repo, autoPrMatch.branch);
2203
+ }
2140
2204
  }
2141
2205
  else if (stagingTarget) {
2142
2206
  metadata = mergeStagingMeta(userMeta, stagingTarget);
2207
+ // Branch staging: register any rename behind this name (issue #920) so
2208
+ // the PR-time promote finds files staged under the older names too.
2209
+ if (!dryRun) {
2210
+ await registerRenamesBestEffort(ctx.client, run, stagingTarget.repo, stagingTarget.branch);
2211
+ }
2143
2212
  }
2144
2213
  else {
2145
2214
  // gh.* additionally needs git, which the shared derived gate ignores.
@@ -47,6 +47,14 @@ export declare function parseGhPrivateKey(key: string): {
47
47
  kind: GhTargetKind;
48
48
  num: number;
49
49
  } | undefined;
50
+ export declare function inferContentType(filename: string): string;
51
+ /**
52
+ * Coarse family a filename belongs to, for the callers that only branch on
53
+ * "is this an image / a video / something else" — `unknown` when the
54
+ * extension maps to nothing (an extension-less screenshot, say), which those
55
+ * callers treat as "might be an image, probe it".
56
+ */
57
+ export declare function fileKindFromName(filename: string): "image" | "video" | "file" | "unknown";
50
58
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
51
59
  export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
52
60
  /**
@@ -69,27 +69,55 @@ export function parseGhPrivateKey(key) {
69
69
  return { prefixId, kind: kind, num: Number(num) };
70
70
  }
71
71
  /** GitHub-embed helper (content type). Copied from packages/uploads/src/embed.ts. */
72
- function inferContentType(filename) {
72
+ const CONTENT_TYPE_BY_EXTENSION = {
73
+ png: "image/png",
74
+ jpg: "image/jpeg",
75
+ jpeg: "image/jpeg",
76
+ gif: "image/gif",
77
+ webp: "image/webp",
78
+ avif: "image/avif",
79
+ svg: "image/svg+xml",
80
+ mp4: "video/mp4",
81
+ webm: "video/webm",
82
+ mov: "video/quicktime",
83
+ pdf: "application/pdf",
84
+ zip: "application/zip",
85
+ gz: "application/gzip",
86
+ tgz: "application/gzip",
87
+ txt: "text/plain",
88
+ text: "text/plain",
89
+ log: "text/plain",
90
+ jsonl: "text/plain",
91
+ ndjson: "text/plain",
92
+ yaml: "text/plain",
93
+ yml: "text/plain",
94
+ md: "text/markdown",
95
+ markdown: "text/markdown",
96
+ csv: "text/csv",
97
+ json: "application/json",
98
+ xml: "application/xml",
99
+ };
100
+ export function inferContentType(filename) {
73
101
  const ext = filename.includes(".")
74
102
  ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase()
75
103
  : "";
76
- switch (ext) {
77
- case "png":
78
- return "image/png";
79
- case "jpg":
80
- case "jpeg":
81
- return "image/jpeg";
82
- case "gif":
83
- return "image/gif";
84
- case "webp":
85
- return "image/webp";
86
- case "svg":
87
- return "image/svg+xml";
88
- case "mp4":
89
- return "video/mp4";
90
- default:
91
- return "application/octet-stream";
92
- }
104
+ return CONTENT_TYPE_BY_EXTENSION[ext] ?? "application/octet-stream";
105
+ }
106
+ /**
107
+ * Coarse family a filename belongs to, for the callers that only branch on
108
+ * "is this an image / a video / something else" — `unknown` when the
109
+ * extension maps to nothing (an extension-less screenshot, say), which those
110
+ * callers treat as "might be an image, probe it".
111
+ */
112
+ export function fileKindFromName(filename) {
113
+ const type = inferContentType(filename);
114
+ if (type === "application/octet-stream")
115
+ return "unknown";
116
+ if (type.startsWith("image/"))
117
+ return "image";
118
+ if (type.startsWith("video/"))
119
+ return "video";
120
+ return "file";
93
121
  }
94
122
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
95
123
  export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
package/dist/embed.d.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  /** GitHub-embed helpers (content type + markdown). */
2
- export declare function inferContentType(filename: string): string;
2
+ /**
3
+ * The filename→type map and its two readers live in
4
+ * packages/comment-render/src/index.ts (inlined here as
5
+ * `comment-render.generated.ts`), so the CLI and the managed-comment renderer
6
+ * cannot drift apart. Re-exported from this module because every existing
7
+ * caller imports them from `./embed.js`. That map mirrors the upload table in
8
+ * apps/api/src/guards.ts; keep the two in step. `svg` maps to
9
+ * `image/svg+xml` here same as any other type (issue #929): the server no
10
+ * longer rejects it outright — it accepts SVG only on a storage lane
11
+ * verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
12
+ * so whether a given upload actually lands depends on that lane's state,
13
+ * not on anything this map decides.
14
+ */
15
+ export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
3
16
  export declare function buildMarkdown(url: string, opts: {
4
17
  alt: string;
5
18
  width?: number;
package/dist/embed.js CHANGED
@@ -1,26 +1,18 @@
1
1
  /** GitHub-embed helpers (content type + markdown). */
2
- export function inferContentType(filename) {
3
- const ext = filename.includes(".")
4
- ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase()
5
- : "";
6
- switch (ext) {
7
- case "png":
8
- return "image/png";
9
- case "jpg":
10
- case "jpeg":
11
- return "image/jpeg";
12
- case "gif":
13
- return "image/gif";
14
- case "webp":
15
- return "image/webp";
16
- case "svg":
17
- return "image/svg+xml";
18
- case "mp4":
19
- return "video/mp4";
20
- default:
21
- return "application/octet-stream";
22
- }
23
- }
2
+ /**
3
+ * The filename→type map and its two readers live in
4
+ * packages/comment-render/src/index.ts (inlined here as
5
+ * `comment-render.generated.ts`), so the CLI and the managed-comment renderer
6
+ * cannot drift apart. Re-exported from this module because every existing
7
+ * caller imports them from `./embed.js`. That map mirrors the upload table in
8
+ * apps/api/src/guards.ts; keep the two in step. `svg` maps to
9
+ * `image/svg+xml` here same as any other type (issue #929): the server no
10
+ * longer rejects it outright — it accepts SVG only on a storage lane
11
+ * verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
12
+ * so whether a given upload actually lands depends on that lane's state,
13
+ * not on anything this map decides.
14
+ */
15
+ export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
24
16
  export function buildMarkdown(url, opts) {
25
17
  if (opts.width) {
26
18
  const alt = opts.alt.replace(/"/g, "&quot;");
@@ -19,6 +19,31 @@ export declare function resolveRepo(explicit: string | undefined, run?: CommandR
19
19
  export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
20
20
  /** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
21
21
  export declare function resolveCurrentBranch(run?: CommandRunner): string;
22
+ /**
23
+ * `resolveCurrentBranch` for best-effort callers: undefined instead of a
24
+ * throw when the branch can't be determined (detached HEAD, not a git repo,
25
+ * git unavailable). Use it wherever a missing branch means "skip this step",
26
+ * not "fail the command".
27
+ */
28
+ export declare function resolveCurrentBranchSafe(run?: CommandRunner): string | undefined;
29
+ /** One `git branch -m` step read out of a branch's reflog: `from` was renamed to `to`. */
30
+ export interface BranchRenameStep {
31
+ from: string;
32
+ to: string;
33
+ }
34
+ /**
35
+ * Rename lineage for `branch`, read from its own reflog (issue #920). `git
36
+ * branch -m old new` writes `Branch: renamed refs/heads/old to
37
+ * refs/heads/new` into the NEW branch's reflog, and chained renames
38
+ * accumulate there, newest entry first — so the parsed pairs are reversed
39
+ * into oldest-first order, which is the order the server wants them
40
+ * registered.
41
+ *
42
+ * Best-effort by construction: any git failure (not a repo, no reflog for
43
+ * the branch, git missing) and any non-rename reflog line yield an empty
44
+ * array. Callers must treat "no lineage" as the normal case.
45
+ */
46
+ export declare function renameLineageFromReflog(run?: CommandRunner, branch?: string): BranchRenameStep[];
22
47
  /**
23
48
  * Best-effort default-branch name via the local `origin/HEAD` ref (no
24
49
  * network call — just reads the ref git already cached from the last
package/dist/github-gh.js CHANGED
@@ -114,6 +114,55 @@ export function resolveCurrentBranch(run = execRunner) {
114
114
  }
115
115
  return branch;
116
116
  }
117
+ /**
118
+ * `resolveCurrentBranch` for best-effort callers: undefined instead of a
119
+ * throw when the branch can't be determined (detached HEAD, not a git repo,
120
+ * git unavailable). Use it wherever a missing branch means "skip this step",
121
+ * not "fail the command".
122
+ */
123
+ export function resolveCurrentBranchSafe(run = execRunner) {
124
+ try {
125
+ return resolveCurrentBranch(run);
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ /**
132
+ * Rename lineage for `branch`, read from its own reflog (issue #920). `git
133
+ * branch -m old new` writes `Branch: renamed refs/heads/old to
134
+ * refs/heads/new` into the NEW branch's reflog, and chained renames
135
+ * accumulate there, newest entry first — so the parsed pairs are reversed
136
+ * into oldest-first order, which is the order the server wants them
137
+ * registered.
138
+ *
139
+ * Best-effort by construction: any git failure (not a repo, no reflog for
140
+ * the branch, git missing) and any non-rename reflog line yield an empty
141
+ * array. Callers must treat "no lineage" as the normal case.
142
+ */
143
+ export function renameLineageFromReflog(run = execRunner, branch) {
144
+ if (!branch)
145
+ return [];
146
+ let out;
147
+ try {
148
+ out = run("git", ["reflog", "show", "--format=%gs", `refs/heads/${branch}`]);
149
+ }
150
+ catch {
151
+ return []; // no reflog, not a repo, git unavailable — nothing to follow
152
+ }
153
+ const steps = [];
154
+ for (const line of out.split("\n")) {
155
+ const match = /^Branch: renamed refs\/heads\/(.+) to refs\/heads\/(.+)$/.exec(line.trim());
156
+ if (!match)
157
+ continue;
158
+ const from = match[1].trim();
159
+ const to = match[2].trim();
160
+ if (!from || !to || from === to)
161
+ continue;
162
+ steps.push({ from, to });
163
+ }
164
+ return steps.reverse(); // reflog is newest-first; register oldest-first
165
+ }
117
166
  /**
118
167
  * Best-effort default-branch name via the local `origin/HEAD` ref (no
119
168
  * network call — just reads the ref git already cached from the last
package/dist/mcp/tools.js CHANGED
@@ -1,5 +1,5 @@
1
- import { createUploadsClient } from "../client.js";
2
- import { buildDoctorReport, ghListPrefixes, ghMergedList, makeGhTarget, mergeStagingMeta, resolveAutoPrTarget, resolveGhPrefixSafe, resolvePutNudgeContext, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, autoPrNoteText, putNudgeText, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
1
+ import { createUploadsClient, } from "../client.js";
2
+ import { buildDoctorReport, ghListPrefixes, ghMergedList, makeGhTarget, mergeStagingMeta, resolveAutoPrTarget, resolveGhPrefixSafe, resolvePutNudgeContext, registerRenamesBestEffort, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, autoPrNoteText, putNudgeText, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
3
3
  import { resolveFrameId } from "../frame.js";
4
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
@@ -9,7 +9,7 @@ import { safeCaptureFacts } from "../capture-facts.js";
9
9
  import { deriveRepoSlugFromGit } from "../keys.js";
10
10
  import { validateMetaMap } from "../metadata.js";
11
11
  import { mergeDerivedMeta } from "../metadata-vocab.js";
12
- import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
12
+ import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentBranchSafe, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
13
13
  import { appProp, canonicalMetaFromArgs, METADATA_PATH_CUE, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
14
14
  import { batchFailureMessage, mcpDestroyPublic, mcpNoAuth, mcpOAuthAny, mcpOAuthDelete, mcpOAuthRead, mcpOAuthWrite, mcpRead, mcpWriteInternal, mcpWritePublic, stdioOutputSchemas, withOutputSchemas, ToolBatchError, } from "./server.js";
15
15
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
@@ -131,6 +131,25 @@ export function createUploadsMcpTools(opts) {
131
131
  }
132
132
  return { comment, commentError };
133
133
  };
134
+ /**
135
+ * Best-effort branch promote for the stdio `attach` tool (CLI `attach --pr`
136
+ * parity, issue #920). Registers any `git branch -m` steps first so the
137
+ * sweep follows renames, then promotes. Never throws: a failure surfaces as
138
+ * `promoteError` so the uploads themselves still return.
139
+ */
140
+ const attemptPromote = async (client, target, branch) => {
141
+ try {
142
+ const promotion = await client.promoteBranchAttachments({
143
+ repo: target.repo,
144
+ num: target.num,
145
+ branch,
146
+ });
147
+ return { promotion };
148
+ }
149
+ catch (err) {
150
+ return { promoteError: err instanceof Error ? err.message : String(err) };
151
+ }
152
+ };
134
153
  const tools = [
135
154
  {
136
155
  name: "gallery_create",
@@ -273,7 +292,7 @@ export function createUploadsMcpTools(opts) {
273
292
  title: "Upload file",
274
293
  annotations: mcpDestroyPublic,
275
294
  securitySchemes: mcpOAuthWrite,
276
- description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public.",
295
+ description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public. Accepts images (PNG, JPEG, GIF, WebP, AVIF), video (MP4, WebM, MOV), PDF, zip, gzip, and text (plain, markdown, CSV, JSON). SVG and XML are accepted only on storage lanes verified to serve them sandboxed (see the workspace's storage settings); HTML is rejected.",
277
296
  inputSchema: {
278
297
  type: "object",
279
298
  properties: {
@@ -466,7 +485,7 @@ export function createUploadsMcpTools(opts) {
466
485
  // had been passed (stable key + managed comment sync) instead of
467
486
  // the #403 staging default below. Never throws — see
468
487
  // resolveAutoPrTarget.
469
- const autoPrTarget = target
488
+ const autoPrTarget = (target
470
489
  ? undefined
471
490
  : resolveAutoPrTarget({
472
491
  ghTarget: target,
@@ -478,7 +497,7 @@ export function createUploadsMcpTools(opts) {
478
497
  noAutoPr,
479
498
  repoArg: optString(args, "repo") ?? defaults.repo,
480
499
  run,
481
- });
500
+ }))?.target;
482
501
  const effectiveTarget = target ?? autoPrTarget;
483
502
  // Bare-put branch staging (issue #403): local stdio MCP put mirrors
484
503
  // the CLI default — no pr/issue/key/ref/prefix/destination, not
@@ -858,7 +877,7 @@ export function createUploadsMcpTools(opts) {
858
877
  // behaves as if `pr` had been passed (stable key + managed comment
859
878
  // sync) instead of the #469 auto-staging default below. Never
860
879
  // throws — see resolveAutoPrTarget.
861
- const autoPrTarget = target
880
+ const autoPrTarget = (target
862
881
  ? undefined
863
882
  : resolveAutoPrTarget({
864
883
  ghTarget: target,
@@ -870,7 +889,7 @@ export function createUploadsMcpTools(opts) {
870
889
  noAutoPr,
871
890
  repoArg: optString(args, "repo") ?? defaults.repo,
872
891
  run,
873
- });
892
+ }))?.target;
874
893
  const effectiveTarget = target ?? autoPrTarget;
875
894
  // Auto branch staging (issue #469 lever 1): mirrors the CLI screenshot
876
895
  // command and the put tool above (issue #403) — no pr/issue/key/ref/
@@ -1064,7 +1083,7 @@ export function createUploadsMcpTools(opts) {
1064
1083
  title: "Attach to GitHub",
1065
1084
  annotations: mcpDestroyPublic,
1066
1085
  securitySchemes: mcpOAuthWrite,
1067
- description: "Upload one or more files as stable PR/issue attachments (in parallel) and maintain a managed GitHub comment. Returns `uploads` and `failures` (one bad file does not abort the batch). Each success has `url`, `embedUrl`, and `markdown` (prefer embedUrl for GitHub). With no pr/issue, targets the current branch PR. Attachments are public and keys are predictable; upload only non-sensitive media.",
1086
+ description: "Upload one or more files as stable PR/issue attachments (in parallel) and maintain a managed GitHub comment. Returns `uploads` and `failures` (one bad file does not abort the batch). Each success has `url`, `embedUrl`, and `markdown` (prefer embedUrl for GitHub). With no pr/issue, targets the current branch PR, and files already staged for that branch are promoted into it (`promotion`/`promoteError`; renamed branches are followed automatically, or name the old branch with fromBranch). Attachments are public and keys are predictable; upload only non-sensitive media.",
1068
1087
  inputSchema: {
1069
1088
  type: "object",
1070
1089
  properties: {
@@ -1078,6 +1097,14 @@ export function createUploadsMcpTools(opts) {
1078
1097
  type: "boolean",
1079
1098
  description: "Upload only; don't create/update the managed comment.",
1080
1099
  },
1100
+ noPromote: {
1101
+ type: "boolean",
1102
+ description: "Skip promoting files staged for the current branch into the PR.",
1103
+ },
1104
+ fromBranch: {
1105
+ type: "string",
1106
+ description: "Promote files staged under this branch name instead of the current branch (for a branch renamed before the PR opened).",
1107
+ },
1081
1108
  contentType: {
1082
1109
  type: "string",
1083
1110
  description: "Override the Content-Type (applied to every file; ignored when optimize rewrites).",
@@ -1109,6 +1136,7 @@ export function createUploadsMcpTools(opts) {
1109
1136
  examples: [
1110
1137
  { files: ["./after.png"], pr: 12, state: "after" },
1111
1138
  { files: ["./before.png", "./after.png"], pr: 12 },
1139
+ { files: ["./after.png"], pr: 12, fromBranch: "old-branch-name" },
1112
1140
  ],
1113
1141
  },
1114
1142
  async handler(args) {
@@ -1118,6 +1146,13 @@ export function createUploadsMcpTools(opts) {
1118
1146
  const explicitTarget = ghTargetFromArgs(args, run);
1119
1147
  const target = explicitTarget ??
1120
1148
  resolveCurrentPullRequest(resolveRepo(optString(args, "repo"), run), run);
1149
+ const fromBranch = optString(args, "fromBranch");
1150
+ if (fromBranch !== undefined && optBool(args, "noPromote")) {
1151
+ usage("fromBranch cannot be combined with noPromote");
1152
+ }
1153
+ if (fromBranch !== undefined && target.kind !== "pull") {
1154
+ usage("fromBranch only promotes into a pull request");
1155
+ }
1121
1156
  const { config, client } = await clientFor(args);
1122
1157
  const contentType = optString(args, "contentType");
1123
1158
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
@@ -1154,10 +1189,30 @@ export function createUploadsMcpTools(opts) {
1154
1189
  failures,
1155
1190
  });
1156
1191
  }
1192
+ // Auto-promote (CLI `attach --pr` parity, issue #920): before the
1193
+ // comment sync, best-effort promote this workspace's branch-staged
1194
+ // files into the PR, following any `git branch -m` rename. Never for
1195
+ // issues, never with noPromote, and silently skipped when the current
1196
+ // branch can't be resolved — this must not fail the attach.
1197
+ let promotion;
1198
+ let promoteError;
1199
+ if (target.kind === "pull" && !optBool(args, "noPromote")) {
1200
+ const branch = fromBranch ?? resolveCurrentBranchSafe(run);
1201
+ if (branch !== undefined) {
1202
+ await registerRenamesBestEffort(client, run, target.repo, branch, {
1203
+ explicit: fromBranch !== undefined,
1204
+ });
1205
+ ({ promotion, promoteError } = await attemptPromote(client, target, branch));
1206
+ }
1207
+ }
1208
+ const promoteFields = {
1209
+ ...(promotion ? { promotion } : {}),
1210
+ ...(promoteError ? { promoteError } : {}),
1211
+ };
1157
1212
  if (optBool(args, "noComment"))
1158
- return { target, uploads, failures };
1213
+ return { target, uploads, failures, ...promoteFields };
1159
1214
  const { comment, commentError } = await syncComment(client, target, config.workspace);
1160
- return { target, uploads, failures, comment, commentError };
1215
+ return { target, uploads, failures, ...promoteFields, comment, commentError };
1161
1216
  },
1162
1217
  },
1163
1218
  {
package/dist/optimize.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * SVG, video, and non-images are left unchanged.
7
7
  */
8
8
  import sharp from "sharp";
9
+ import { fileKindFromName, inferContentType } from "./embed.js";
9
10
  /** Longest edge in pixels (screenshots beyond this rarely help PR review). */
10
11
  export const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
11
12
  /** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
@@ -68,11 +69,25 @@ function passthrough(bytes, filename, contentType, skippedReason) {
68
69
  */
69
70
  export async function optimizeImageForUpload(bytes, filename, opts = {}) {
70
71
  const originalBytes = bytes.byteLength;
72
+ // The filename's own claim, used by every passthrough exit below.
73
+ const guessed = inferContentType(filename);
71
74
  if (opts.enabled === false) {
72
- return passthrough(bytes, filename, guessContentType(filename), "disabled");
75
+ return passthrough(bytes, filename, guessed, "disabled");
73
76
  }
74
77
  if (originalBytes === 0) {
75
- return passthrough(bytes, filename, guessContentType(filename), "empty");
78
+ return passthrough(bytes, filename, guessed, "empty");
79
+ }
80
+ // A known non-image extension (log, pdf, zip, video…) never needs sharp —
81
+ // and must be checked before `looksLikeSvg` below, since that check sniffs
82
+ // the leading bytes for an XML/SVG prologue with no regard for the actual
83
+ // extension: a `.log` or `.json` file that happens to start with `<?xml`
84
+ // (a JUnit report, say) would otherwise misreport as `skippedReason: "svg"`
85
+ // / `image/svg+xml`. An unknown extension is still "might be an image": it
86
+ // falls through to the SVG sniff and the sharp probe below, so an
87
+ // extension-less screenshot is optimized as before.
88
+ const kind = fileKindFromName(filename);
89
+ if (kind === "file" || kind === "video") {
90
+ return passthrough(bytes, filename, guessed, "not_image");
76
91
  }
77
92
  if (looksLikeSvg(bytes, filename)) {
78
93
  return passthrough(bytes, filename, "image/svg+xml", "svg");
@@ -92,14 +107,14 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
92
107
  meta = await image.metadata();
93
108
  }
94
109
  catch {
95
- return passthrough(bytes, filename, guessContentType(filename), "not_image");
110
+ return passthrough(bytes, filename, guessed, "not_image");
96
111
  }
97
112
  if (!meta.format) {
98
- return passthrough(bytes, filename, guessContentType(filename), "not_image");
113
+ return passthrough(bytes, filename, guessed, "not_image");
99
114
  }
100
115
  // Animated GIF/WebP: keep as-is (re-encoding often breaks or balloons size).
101
116
  if ((meta.pages ?? 1) > 1) {
102
- return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" : guessContentType(filename), "animated");
117
+ return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" : guessed, "animated");
103
118
  }
104
119
  if (meta.format === "gif") {
105
120
  // Single-frame GIF can convert; multi-page already returned above.
@@ -134,10 +149,10 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
134
149
  }
135
150
  }
136
151
  catch {
137
- return passthrough(bytes, filename, guessContentType(filename), "encode_failed");
152
+ return passthrough(bytes, filename, guessed, "encode_failed");
138
153
  }
139
154
  if (encoded.byteLength >= originalBytes) {
140
- return passthrough(bytes, filename, guessContentType(filename), "not_smaller");
155
+ return passthrough(bytes, filename, guessed, "not_smaller");
141
156
  }
142
157
  const outFilename = withImageExtension(filename, format === "jpeg" ? "jpg" : "webp");
143
158
  return {
@@ -149,25 +164,6 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
149
164
  outputBytes: encoded.byteLength,
150
165
  };
151
166
  }
152
- function guessContentType(filename) {
153
- switch (extensionOf(filename)) {
154
- case "png":
155
- return "image/png";
156
- case "jpg":
157
- case "jpeg":
158
- return "image/jpeg";
159
- case "gif":
160
- return "image/gif";
161
- case "webp":
162
- return "image/webp";
163
- case "avif":
164
- return "image/avif";
165
- case "svg":
166
- return "image/svg+xml";
167
- default:
168
- return "application/octet-stream";
169
- }
170
- }
171
167
  /** Rewrite an object key's trailing image extension to match optimized output. */
172
168
  export function rewriteKeyExtension(key, filename) {
173
169
  const ext = extensionOf(filename);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.50.1",
3
+ "version": "0.52.0",
4
4
  "mcpName": "sh.uploads/mcp",
5
5
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
6
6
  "type": "module",