@buildinternet/uploads 0.50.1 → 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/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
@@ -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";
@@ -809,11 +809,13 @@ takes effect with zero files and cannot combine with --branch/--issue/
809
809
  --no-promote. Promotion never applies to issues. Staged files stay findable
810
810
  with "uploads find gh.branch=<branch>" either way.
811
811
 
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.
812
+ A branch renamed with "git branch -m" is followed automatically: the rename
813
+ is read from the branch reflog and registered, so promotion sweeps the older
814
+ names too. That needs one uploads run after the rename. If the branch was
815
+ renamed without one, or was deleted, pass "--from-branch <old-name>" with
816
+ "--pr <num>". With no file arguments, this promotes the stale branch prefix
817
+ and refreshes the managed comment. With file or existing-key arguments, it
818
+ promotes the stale prefix before the normal attach flow.
817
819
 
818
820
  Options:
819
821
  --pr <num> Attach to this pull request
@@ -1174,6 +1176,45 @@ export async function uploadPuts(opts) {
1174
1176
  }
1175
1177
  return { uploads, failures, firstError, sentMetadata };
1176
1178
  }
1179
+ /**
1180
+ * Best-effort branch-rename registration (issue #920): reads this branch's
1181
+ * reflog for `git branch -m` steps and tells the server about each one, so a
1182
+ * later promote sweeps the branch's whole name lineage instead of only its
1183
+ * current name. No-op when the reflog has no rename (the common case) and
1184
+ * when the client predates the route. Every failure is swallowed — this runs
1185
+ * alongside staging and promote, and must never fail either. Set
1186
+ * `UPLOADS_DEBUG=1` to see what was skipped.
1187
+ *
1188
+ * `opts.explicit` marks a branch the user named themselves (`--from-branch`
1189
+ * / the MCP `fromBranch` argument): that is the manual escape hatch (PR
1190
+ * #919), and its lineage belongs to a different name than the one we are
1191
+ * standing on, so nothing is registered for it.
1192
+ */
1193
+ export async function registerRenamesBestEffort(client, run, repo, branch, opts = {}) {
1194
+ if (opts.explicit)
1195
+ return;
1196
+ let lineage;
1197
+ try {
1198
+ lineage = renameLineageFromReflog(run, branch);
1199
+ }
1200
+ catch {
1201
+ return;
1202
+ }
1203
+ if (lineage.length === 0)
1204
+ return;
1205
+ for (const step of lineage) {
1206
+ try {
1207
+ await client.registerBranchRename({ repo, from: step.from, to: step.to });
1208
+ }
1209
+ catch (err) {
1210
+ if (process.env.UPLOADS_DEBUG === "1") {
1211
+ const detail = err instanceof Error ? err.message : String(err);
1212
+ process.stderr.write(`debug: could not register branch rename ${step.from} -> ${step.to}: ${detail}\n`);
1213
+ }
1214
+ return; // one failure means the route is unavailable; don't retry the rest
1215
+ }
1216
+ }
1217
+ }
1177
1218
  /**
1178
1219
  * Best-effort call to `POST /v1/workspaces/:workspace/github/promote` (server contract,
1179
1220
  * PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
@@ -1189,11 +1230,20 @@ async function attemptPromoteBranch(client, target, branch) {
1189
1230
  return undefined;
1190
1231
  }
1191
1232
  }
1192
- /** Human-mode note for a promotion that actually promoted something. */
1233
+ /**
1234
+ * Human-mode note(s) for a promotion that actually promoted something. When
1235
+ * the server-side sweep followed a rename it adds a second line naming the
1236
+ * older branch names (issue #920): `lineage` is current-name-first, so the
1237
+ * older names are its tail.
1238
+ */
1193
1239
  function promotionNote(promotion, branch) {
1194
1240
  const n = promotion.promoted.length;
1195
1241
  const branchSuffix = branch ? ` from branch ${branch}` : "";
1196
- return `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
1242
+ const promoted = `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
1243
+ const older = (promotion.lineage ?? []).slice(1);
1244
+ if (older.length === 0)
1245
+ return promoted;
1246
+ return `${promoted}>> followed rename from ${older.join(", ")}\n`;
1197
1247
  }
1198
1248
  export async function runAttach(ctx, args, help = false, run = execRunner) {
1199
1249
  const parsed = parseCommandArgs(args);
@@ -1344,16 +1394,11 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
1344
1394
  let promotion;
1345
1395
  let promotedBranch;
1346
1396
  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
- }
1397
+ promotedBranch = fromBranch ?? resolveCurrentBranchSafe(run);
1356
1398
  if (promotedBranch !== undefined) {
1399
+ await registerRenamesBestEffort(ctx.client, run, target.repo, promotedBranch, {
1400
+ explicit: fromBranch !== undefined,
1401
+ });
1357
1402
  promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
1358
1403
  }
1359
1404
  }
@@ -1449,6 +1494,9 @@ async function runAttachBranch(ctx, parsed, branch, run) {
1449
1494
  process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"} (staged for branch ${branch})\n`);
1450
1495
  }
1451
1496
  const target = { repo, branch };
1497
+ // Register any `git branch -m` steps behind this name (issue #920) so the
1498
+ // promote that runs when the PR opens sweeps the older names too.
1499
+ await registerRenamesBestEffort(ctx.client, run, repo, branch);
1452
1500
  const { uploads, failures, firstError } = await uploadBranchAttachments({
1453
1501
  client: ctx.client,
1454
1502
  target,
@@ -1567,7 +1615,11 @@ async function runAttachPromoteOnly(ctx, parsed, run) {
1567
1615
  if (target.kind !== "pull") {
1568
1616
  throw new UsageError("--from-branch only promotes into a pull request");
1569
1617
  }
1570
- const branch = flagString(parsed.flags, "--from-branch") ?? resolveCurrentBranch(run);
1618
+ const explicitFromBranch = flagString(parsed.flags, "--from-branch");
1619
+ const branch = explicitFromBranch ?? resolveCurrentBranch(run);
1620
+ await registerRenamesBestEffort(ctx.client, run, target.repo, branch, {
1621
+ explicit: explicitFromBranch !== undefined,
1622
+ });
1571
1623
  const promotion = await attemptPromoteBranch(ctx.client, target, branch);
1572
1624
  let comment;
1573
1625
  let commentError;
@@ -1656,13 +1708,9 @@ export function resolvePutNudgeContext(opts) {
1656
1708
  try {
1657
1709
  if (deriveRepoFromGit(run) === undefined)
1658
1710
  return undefined; // not a (usable) git repo
1659
- let branch;
1660
- try {
1661
- branch = resolveCurrentBranch(run);
1662
- }
1663
- catch {
1711
+ const branch = resolveCurrentBranchSafe(run);
1712
+ if (branch === undefined)
1664
1713
  return undefined; // detached HEAD, or git unavailable
1665
- }
1666
1714
  const defaultBranch = resolveDefaultBranch(run);
1667
1715
  const onDefaultBranch = defaultBranch
1668
1716
  ? branch === defaultBranch
@@ -1704,6 +1752,10 @@ export function resolvePutNudgeContext(opts) {
1704
1752
  * default branch, or with `--no-git`; any failure (not a repo, detached
1705
1753
  * HEAD, gh missing/unauthenticated/timed out, no open PR) degrades to
1706
1754
  * undefined so the caller falls back to its normal staging/dated behavior.
1755
+ *
1756
+ * The match carries the `branch` it was resolved from, so callers that need
1757
+ * the current branch (the #920 rename registration) reuse it instead of
1758
+ * spawning `git rev-parse` a second time.
1707
1759
  */
1708
1760
  export function resolveAutoPrTarget(opts) {
1709
1761
  const { ghTarget, keyHint, refArg, prefixArg, destinationArg, branchArg, noGit, noAutoPr, repoArg, run, } = opts;
@@ -1716,13 +1768,9 @@ export function resolveAutoPrTarget(opts) {
1716
1768
  try {
1717
1769
  if (deriveRepoFromGit(run) === undefined)
1718
1770
  return undefined; // not a (usable) git repo
1719
- let branch;
1720
- try {
1721
- branch = resolveCurrentBranch(run);
1722
- }
1723
- catch {
1771
+ const branch = resolveCurrentBranchSafe(run);
1772
+ if (branch === undefined)
1724
1773
  return undefined; // detached HEAD, or git unavailable
1725
- }
1726
1774
  const defaultBranch = resolveDefaultBranch(run);
1727
1775
  const onDefaultBranch = defaultBranch
1728
1776
  ? branch === defaultBranch
@@ -1733,7 +1781,7 @@ export function resolveAutoPrTarget(opts) {
1733
1781
  // this must never be felt as a hang.
1734
1782
  const timed = run === execRunner ? timedExecRunner(PUT_NUDGE_GH_TIMEOUT_MS) : run;
1735
1783
  const repo = resolveRepo(repoArg, timed);
1736
- return resolveCurrentPullRequest(repo, timed);
1784
+ return { target: resolveCurrentPullRequest(repo, timed), branch };
1737
1785
  }
1738
1786
  catch {
1739
1787
  return undefined; // gh/git unavailable, no open PR, or repo unresolvable
@@ -1766,13 +1814,9 @@ export function resolvePutStagingTarget(opts) {
1766
1814
  try {
1767
1815
  if (deriveRepoFromGit(run) === undefined)
1768
1816
  return undefined; // not a (usable) git repo
1769
- let branch;
1770
- try {
1771
- branch = resolveCurrentBranch(run);
1772
- }
1773
- catch {
1817
+ const branch = resolveCurrentBranchSafe(run);
1818
+ if (branch === undefined)
1774
1819
  return undefined; // detached HEAD, or git unavailable
1775
- }
1776
1820
  const defaultBranch = resolveDefaultBranch(run);
1777
1821
  const onDefaultBranch = defaultBranch
1778
1822
  ? branch === defaultBranch
@@ -1993,7 +2037,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1993
2037
  // resolveAutoPrTarget. Supersedes both the #403 staging default and the
1994
2038
  // #393 nudge for this case; computed before the gh.* metadata resolution
1995
2039
  // below since it takes over that resolution entirely.
1996
- const autoPrTarget = ghTarget
2040
+ const autoPrMatch = ghTarget
1997
2041
  ? undefined
1998
2042
  : resolveAutoPrTarget({
1999
2043
  ghTarget,
@@ -2006,6 +2050,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
2006
2050
  repoArg: flagString(parsed.flags, "--repo") ?? defaults.repo,
2007
2051
  run,
2008
2052
  });
2053
+ const autoPrTarget = autoPrMatch?.target;
2009
2054
  const effectiveGhTarget = ghTarget ?? autoPrTarget;
2010
2055
  // Comment sync runs by default with --pr/--issue (matches `attach`); opt
2011
2056
  // out with --no-comment. --comment is accepted as a redundant no-op for
@@ -2137,9 +2182,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
2137
2182
  validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
2138
2183
  metadata = merged;
2139
2184
  attachedRef = merged["gh.ref"];
2185
+ // Auto-PR (#700) suppresses staging, so the staging branch below never
2186
+ // runs — but this put still comes from a branch that may have been
2187
+ // renamed while files were staged under the old name. Register the
2188
+ // lineage here too (issue #920), reusing the branch the match already
2189
+ // resolved. Only for the auto-PR match: an explicit --pr/--issue names
2190
+ // no branch of its own.
2191
+ if (autoPrMatch && !dryRun) {
2192
+ await registerRenamesBestEffort(ctx.client, run, autoPrMatch.target.repo, autoPrMatch.branch);
2193
+ }
2140
2194
  }
2141
2195
  else if (stagingTarget) {
2142
2196
  metadata = mergeStagingMeta(userMeta, stagingTarget);
2197
+ // Branch staging: register any rename behind this name (issue #920) so
2198
+ // the PR-time promote finds files staged under the older names too.
2199
+ if (!dryRun) {
2200
+ await registerRenamesBestEffort(ctx.client, run, stagingTarget.repo, stagingTarget.branch);
2201
+ }
2143
2202
  }
2144
2203
  else {
2145
2204
  // gh.* additionally needs git, which the shared derived gate ignores.
@@ -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",
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.50.1",
3
+ "version": "0.51.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",