@coderook/cli 0.26.0 → 0.28.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.
@@ -25,6 +25,7 @@ const registry_js_1 = require("./registry.js");
25
25
  const help_js_1 = require("./help.js");
26
26
  const progress_js_1 = require("./progress.js");
27
27
  const publish_js_1 = require("./publish.js");
28
+ const attach_command_js_1 = require("./attach_command.js");
28
29
  const project_commands_js_1 = require("./project_commands.js");
29
30
  const track_commands_js_1 = require("./track_commands.js");
30
31
  const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
@@ -93,18 +94,62 @@ const done = (line) => {
93
94
  node_process_1.default.stdout.write("\r");
94
95
  }
95
96
  };
96
- function parse(argv) {
97
+ /**
98
+ * The flags a command declares as taking a value.
99
+ *
100
+ * Read from the option's own help text, which already says so: a value is
101
+ * written as a placeholder after the name, as in `--name <text>`, and a
102
+ * switch has nothing after it. So there is no second list to keep in step
103
+ * with the first — the thing already written for the reader is the thing
104
+ * the parser uses.
105
+ */
106
+ function valueTaking(spec) {
107
+ const names = new Set();
108
+ for (const option of spec?.options ?? []) {
109
+ if (!/[<[]/.test(option.flags))
110
+ continue;
111
+ for (const match of option.flags.matchAll(/--?([A-Za-z0-9][\w-]*)/g)) {
112
+ names.add(match[1]);
113
+ }
114
+ }
115
+ return names;
116
+ }
117
+ /**
118
+ * What was typed, split into positions and flags.
119
+ *
120
+ * A flag takes the word after it only when the command says it takes one.
121
+ * Guessing from the shape of the next word — anything not starting with a
122
+ * dash — meant `cbx take-down 1 --yes my-project` read the project name as
123
+ * the value of `--yes` and then reported that `--yes` had not been given,
124
+ * having also swallowed the argument that said which project. Every switch
125
+ * followed by a positional had the same fault.
126
+ */
127
+ function parse(argv, spec) {
97
128
  const positional = [];
98
129
  const flags = new Map();
130
+ const takesValue = valueTaking(spec);
99
131
  for (let at = 0; at < argv.length; at += 1) {
100
132
  const item = argv[at];
101
133
  if (!item.startsWith("-")) {
102
134
  positional.push(item);
103
135
  continue;
104
136
  }
105
- const name = item.replace(/^--?/, "");
137
+ /* `--name=value` says so itself and needs no declaration. */
138
+ const written = item.replace(/^--?/, "");
139
+ const split = written.indexOf("=");
140
+ if (split > 0) {
141
+ flags.set(written.slice(0, split), written.slice(split + 1));
142
+ continue;
143
+ }
144
+ const name = written;
106
145
  const next = argv[at + 1];
107
- if (next && !next.startsWith("-")) {
146
+ /*
147
+ An undeclared flag keeps the old guess. Commands that never declared
148
+ their options rely on it, and narrowing that here would trade one
149
+ quiet misreading for another.
150
+ */
151
+ const wants = takesValue.size ? takesValue.has(name) : true;
152
+ if (wants && next !== undefined && !next.startsWith("-")) {
108
153
  flags.set(name, next);
109
154
  at += 1;
110
155
  }
@@ -398,7 +443,7 @@ async function commandImport(parsed) {
398
443
  flags: submitFlags,
399
444
  });
400
445
  if (code === 0 && plan.temporary) {
401
- console.log(dim(`The working copy stays at ${plan.folder} until you remove it.
446
+ console.log(dim(`The working copy stays at ${plan.folder} until you remove it.
402
447
  ` +
403
448
  `Run ${accent("cbx get " + plan.name)} anywhere to fetch it fresh.`));
404
449
  }
@@ -526,17 +571,17 @@ async function commandSubmit(parsed) {
526
571
  */
527
572
  const shielded = (await (0, worktree_js_1.detectPrivateDirectories)(folder)).filter((finding) => [...sending].some((file) => file === finding.path || file.startsWith(`${finding.path}/`)));
528
573
  if (shielded.length && !hasFlag(parsed, "allow-private")) {
529
- console.log(red(`
574
+ console.log(red(`
530
575
  ${shielded.length} folder${shielded.length === 1 ? "" : "s"} here belong${shielded.length === 1 ? "s" : ""} to a program, not to your project:`));
531
576
  for (const finding of shielded) {
532
577
  console.log(` ${finding.path} ${dim(`— ${finding.because}`)}`);
533
578
  }
534
- console.log(`
579
+ console.log(`
535
580
  Nothing was sent. To leave them behind:`);
536
581
  for (const finding of shielded) {
537
582
  console.log(` ${accent(`echo "${finding.rule}" >> .gitignore`)}`);
538
583
  }
539
- console.error(`
584
+ console.error(`
540
585
  Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
541
586
  return 1;
542
587
  }
@@ -571,7 +616,7 @@ Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
571
616
  const pasted = await (0, worktree_js_1.detectPastedCredentials)(folder, files.map((file) => file.path));
572
617
  const stillPasted = pasted.filter((finding) => !exposed.includes(finding.path));
573
618
  if (stillPasted.length) {
574
- console.log(red(`
619
+ console.log(red(`
575
620
  ${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s have credentials"} inside:`));
576
621
  for (const finding of stillPasted.slice(0, 20)) {
577
622
  console.log(` ${finding.path}`);
@@ -586,11 +631,11 @@ ${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s
586
631
  console.log(dim(` …and ${stillPasted.length - 20} more`));
587
632
  }
588
633
  if (!hasFlag(parsed, "allow-secrets")) {
589
- console.error(`
634
+ console.error(`
590
635
  Nothing was sent. Move the key into an environment variable, and if` +
591
636
  ` it has ever been published, replace it at the service that issued` +
592
637
  ` it — a key that has leaked stays leaked.` +
593
- `
638
+ `
594
639
  Pass ${accent("--allow-secrets")} if these are not real keys.`);
595
640
  return 1;
596
641
  }
@@ -649,6 +694,48 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
649
694
  ...(hasFlag(parsed, "allow-secrets") ? { allowSecrets: true } : {}),
650
695
  ...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
651
696
  });
697
+ /*
698
+ Whether this is walking into a merge, asked before anything is sent.
699
+
700
+ The service has offered this since Merge Tracks shipped and nothing ever
701
+ asked, so the first anybody heard that their upload would be diverted was
702
+ after it had finished — which on a slow line is the worst possible moment
703
+ to find out. It is advice: the answer can be stale by the time the
704
+ publish lands and the publish path decides for real, so this says what is
705
+ coming and gets out of the way.
706
+ */
707
+ if (link?.repositoryId && link.baseVersionId) {
708
+ try {
709
+ const ahead = await (0, api_js_1.collisionCheck)(link.repositoryId, link.baseVersionId, files.filter((file) => !file.deleted).map((file) => file.path));
710
+ if (ahead.collidingPaths.length) {
711
+ console.log(accent("Heads up") +
712
+ ` somebody has saved since you last caught up, and ` +
713
+ `${ahead.collidingPaths.length} of your file` +
714
+ `${ahead.collidingPaths.length === 1 ? "" : "s"} ` +
715
+ `${ahead.collidingPaths.length === 1 ? "is" : "are"} among what they changed.`);
716
+ for (const path of ahead.collidingPaths.slice(0, 5)) {
717
+ console.log(dim(` ${path}`));
718
+ }
719
+ if (ahead.collidingPaths.length > 5) {
720
+ console.log(dim(` and ${ahead.collidingPaths.length - 5} more`));
721
+ }
722
+ console.log(dim(" Your upload will be kept whole and put on a merge for you to settle."));
723
+ console.log(dim(" Run cbx get first if you would rather build on theirs."));
724
+ console.log("");
725
+ }
726
+ else if (ahead.behind) {
727
+ console.log(dim("Somebody has saved since you last caught up, but not to any file " +
728
+ "you changed — this will combine on its own."));
729
+ console.log("");
730
+ }
731
+ }
732
+ catch {
733
+ /*
734
+ Advice that cannot be fetched is not a reason to refuse a publish.
735
+ The service checks properly at the point it matters.
736
+ */
737
+ }
738
+ }
652
739
  let result;
653
740
  try {
654
741
  const plan = await uploader.plan(uploadRequest, (progress) => {
@@ -688,7 +775,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
688
775
  const failure = (0, publish_js_1.classifyPublishFailure)(error);
689
776
  const text = error instanceof Error ? error.message : String(error);
690
777
  if (failure?.kind === "interrupted") {
691
- console.error(red(`
778
+ console.error(red(`
692
779
  The connection failed: ${text}`));
693
780
  console.error(`Your work may already have been saved. Run the same command again —` +
694
781
  ` it will not create a second version.`);
@@ -890,7 +977,7 @@ async function commandGet(parsed) {
890
977
  `changed while the interrupted fetch was stopped:`));
891
978
  for (const file of changedInTheGap.slice(0, 20))
892
979
  console.error(` ${file.path}`);
893
- console.error(`
980
+ console.error(`
894
981
  Save them with ${accent("cbx submit")}, or finish the fetch and ` +
895
982
  `discard them with ${accent("cbx get --replace")}.`);
896
983
  return 1;
@@ -1122,8 +1209,8 @@ async function suggestRules(folder, apply) {
1122
1209
  const addition = (0, detect_js_1.rulesFromSuggestions)(recommended);
1123
1210
  const shared = rules.shared.trimEnd();
1124
1211
  await (0, worktree_js_1.writeRules)(folder, {
1125
- shared: shared ? `${shared}
1126
-
1212
+ shared: shared ? `${shared}
1213
+
1127
1214
  ${addition}` : addition,
1128
1215
  local: rules.local,
1129
1216
  });
@@ -1250,7 +1337,7 @@ async function commandMerges(parsed) {
1250
1337
  const counts = merge.conflicts;
1251
1338
  console.log(`${accent(merge.reference)} ${counts ? `${counts.unresolved} of ${counts.total} still to decide` : ""} ${dim(new Date(merge.createdAt).toLocaleString())}`);
1252
1339
  }
1253
- console.log(dim(`
1340
+ console.log(dim(`
1254
1341
  Run cbx merge <reference> to look at one.`));
1255
1342
  return 0;
1256
1343
  }
@@ -1316,7 +1403,7 @@ async function commandMerge(parsed) {
1316
1403
  }
1317
1404
  }
1318
1405
  const now = await (0, api_js_1.mergeTrack)(summary.id);
1319
- console.log(`
1406
+ console.log(`
1320
1407
  ${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
1321
1408
  (now.provisional.ready
1322
1409
  ? "ready to apply"
@@ -1331,19 +1418,19 @@ ${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
1331
1418
  return 1;
1332
1419
  }
1333
1420
  const applied = await (0, api_js_1.applyMerge)(summary.id);
1334
- console.log(`
1421
+ console.log(`
1335
1422
  Applied as ${accent(`v${applied.version.sequence}`)}.`);
1336
1423
  console.log(dim("Run cbx get to bring it down to this folder."));
1337
1424
  return 0;
1338
1425
  }
1339
1426
  if (!decision) {
1340
- console.log(dim(`
1427
+ console.log(dim(`
1341
1428
  --mine keeps yours, --theirs keeps what was already saved,` +
1342
- ` --drop removes the file.
1429
+ ` --drop removes the file.
1343
1430
  Add --path <file> for one file, then --apply when ready.`));
1344
1431
  }
1345
1432
  else if (now.provisional.ready) {
1346
- console.log(dim(`
1433
+ console.log(dim(`
1347
1434
  Run cbx merge ${now.mergeTrack.reference} --apply to publish it.`));
1348
1435
  }
1349
1436
  return 0;
@@ -1758,8 +1845,20 @@ const SPECS = [
1758
1845
  group: "Your projects",
1759
1846
  summary: "what has been saved to a project",
1760
1847
  usage: "versions [project]",
1761
- detail: "Newest first. Run it inside a linked folder to leave the name out.",
1762
- options: [{ flags: "--limit <n>", description: "how many to show (default 20)" }],
1848
+ detail: "Newest first. Run it inside a linked folder to leave the name out.\n\n" +
1849
+ "A save is a commit; a commit somebody named is a version, and versions\n" +
1850
+ "are the only thing the public side shows. Both are listed together\n" +
1851
+ "unless you ask for one lane — the same three views the website has.",
1852
+ options: [
1853
+ { flags: "--limit <n>", description: "how many to show (default 20)" },
1854
+ { flags: "--versions", description: "only the ones that were named" },
1855
+ { flags: "--commits", description: "only the ones that were not" },
1856
+ { flags: "--notes", description: "print what each one says about itself" },
1857
+ ],
1858
+ examples: [
1859
+ "cbx versions --versions",
1860
+ "cbx versions my-project --commits",
1861
+ ],
1763
1862
  run: project_commands_js_1.commandVersions,
1764
1863
  },
1765
1864
  {
@@ -1791,11 +1890,21 @@ const SPECS = [
1791
1890
  group: "Your projects",
1792
1891
  summary: "issues on a project, or open one",
1793
1892
  usage: "issues [project]",
1893
+ detail: "Labels are given when the issue is opened rather than added\n" +
1894
+ "afterwards, and any name that does not exist yet is created.\n\n" +
1895
+ "--version says which save it is about. \"It broke\" and \"it broke in\n" +
1896
+ "v41\" are different reports, and the second is the one somebody can\n" +
1897
+ "act on.",
1794
1898
  options: [
1795
1899
  { flags: '--new "<title>"', description: "open a new issue" },
1796
1900
  { flags: "--body <text>", description: "the description for a new one" },
1901
+ { flags: "--labels <a,b>", description: "labels for a new one, comma separated" },
1902
+ { flags: "--version <n>", description: "which save it is about" },
1903
+ ],
1904
+ examples: [
1905
+ 'cbx issues my-project --new "Crash on export"',
1906
+ 'cbx issues --new "Installer will not run" --labels bug,windows --version 41',
1797
1907
  ],
1798
- examples: ['cbx issues my-project --new "Crash on export"'],
1799
1908
  run: service_commands_js_1.commandIssues,
1800
1909
  },
1801
1910
  {
@@ -1806,6 +1915,37 @@ const SPECS = [
1806
1915
  usage: "releases [project]",
1807
1916
  run: service_commands_js_1.commandReleases,
1808
1917
  },
1918
+ {
1919
+ /*
1920
+ The service has been able to notify something else since Merge Tracks
1921
+ shipped — queue an event, sign it, deliver it after the request that
1922
+ caused it — and nothing could reach it, so no project could be told to
1923
+ tell a build box or a status page anything.
1924
+ */
1925
+ name: "hooks",
1926
+ aliases: ["webhooks"],
1927
+ group: "Your projects",
1928
+ summary: "where this project tells something else what happened",
1929
+ usage: "hooks [project]",
1930
+ detail: "A signed POST to an address of yours when something happens here.\n" +
1931
+ "The signing secret is generated by the service and shown once, when\n" +
1932
+ "the hook is added — no route hands it back afterwards.\n\n" +
1933
+ "Events: version.published, merge.opened, merge.applied,\n" +
1934
+ "check.reported, change_request.opened, change_request.reviewed.\n" +
1935
+ "Naming none of them sends all of them.",
1936
+ options: [
1937
+ { flags: "--add <url>", description: "notify this address; https only" },
1938
+ { flags: "--events <a,b>", description: "only these, comma separated" },
1939
+ { flags: "--remove <id>", description: "stop notifying it" },
1940
+ { flags: "--project <name>", description: "which project" },
1941
+ ],
1942
+ examples: [
1943
+ "cbx hooks",
1944
+ "cbx hooks --add https://example.com/coderook",
1945
+ "cbx hooks --add https://example.com/builds --events version.published",
1946
+ ],
1947
+ run: service_commands_js_1.commandHooks,
1948
+ },
1809
1949
  {
1810
1950
  /*
1811
1951
  The half that hiding never covered.
@@ -1844,6 +1984,8 @@ const SPECS = [
1844
1984
  options: [
1845
1985
  { flags: "--name <text>", description: "what to call it" },
1846
1986
  { flags: "--notes <text>", description: "what changed" },
1987
+ { flags: "--notes-file <path>", description: "what changed, from a file" },
1988
+ { flags: "--edit", description: "write what changed in your editor" },
1847
1989
  ],
1848
1990
  examples: [
1849
1991
  "cbx promote",
@@ -1867,6 +2009,58 @@ const SPECS = [
1867
2009
  that works until somebody scripts it. This marks a save — pins it,
1868
2010
  hides it, labels it — which is what it does anyway.
1869
2011
  */
2012
+ name: "attach",
2013
+ group: "Your projects",
2014
+ summary: "put a file people can download on a version",
2015
+ usage: "attach <file> [n] [project]",
2016
+ detail: "A version can carry downloads — an installer, a build, a changelog —\n" +
2017
+ "and until now the only thing that could put one there was a workflow\n" +
2018
+ "run. This attaches a file from this machine.\n\n" +
2019
+ "Attaching is not publishing. A download on a commit is reachable by\n" +
2020
+ "nobody until that commit is made a version; on a version already\n" +
2021
+ "offered it is public the moment it lands.\n\n" +
2022
+ "Large files go up in parts, so a 95 MB installer works the same way a\n" +
2023
+ "text file does.",
2024
+ options: [
2025
+ { flags: "--name <text>", description: "call it something else on the version" },
2026
+ { flags: "--project <name>", description: "which project" },
2027
+ ],
2028
+ examples: [
2029
+ "cbx attach ./release/Installer-1.0.exe",
2030
+ "cbx attach ./release/app.dmg 41",
2031
+ 'cbx attach ./notes.pdf --name "Release notes.pdf"',
2032
+ ],
2033
+ run: attach_command_js_1.commandAttach,
2034
+ },
2035
+ {
2036
+ name: "notes",
2037
+ aliases: ["release-notes"],
2038
+ group: "Your projects",
2039
+ summary: "read or write what a version says about itself",
2040
+ usage: "notes [n] [project]",
2041
+ detail: "The release info the website shows on a version, and the same field\n" +
2042
+ "`--notes` writes when promoting. With nothing to write it prints what\n" +
2043
+ "is there, which the terminal could not do before: `cbx releases` shows\n" +
2044
+ "the first line and stops.\n\n" +
2045
+ "Written in Markdown, because that is what the page renders. --edit\n" +
2046
+ "opens $EDITOR (or $VISUAL, or notepad on Windows) the way git does,\n" +
2047
+ "which is the only comfortable way to write more than a sentence.",
2048
+ options: [
2049
+ { flags: "--edit", description: "write it in your editor" },
2050
+ { flags: "--notes-file <path>", description: "take the text from a file" },
2051
+ { flags: "--notes <text>", description: "set it in one line" },
2052
+ { flags: "--clear", description: "remove what is there" },
2053
+ { flags: "--project <name>", description: "which project" },
2054
+ ],
2055
+ examples: [
2056
+ "cbx notes",
2057
+ "cbx notes 41",
2058
+ "cbx notes 41 --edit",
2059
+ "cbx notes 41 --notes-file RELEASE.md",
2060
+ ],
2061
+ run: version_commands_js_1.commandNotes,
2062
+ },
2063
+ {
1870
2064
  name: "mark",
1871
2065
  aliases: ["set"],
1872
2066
  group: "Your projects",
@@ -1879,11 +2073,13 @@ const SPECS = [
1879
2073
  { flags: "--name <text>", description: "give it a code name" },
1880
2074
  { flags: "--unname", description: "take the name off again" },
1881
2075
  { flags: "--notes <text>", description: "what changed" },
2076
+ { flags: "--notes-file <path>", description: "what changed, from a file" },
2077
+ { flags: "--edit", description: "write what changed in your editor" },
1882
2078
  { flags: "--hide", description: "nobody outside the project can read it" },
1883
2079
  { flags: "--show", description: "anybody can read it" },
1884
2080
  {
1885
2081
  flags: "--visibility <who>",
1886
- description: "private, unlisted (link only) or public",
2082
+ description: "public, or private to hold it back",
1887
2083
  },
1888
2084
  { flags: "--pin", description: "keep it at the top of the list" },
1889
2085
  { flags: "--unpin", description: "put it back in order" },
@@ -1892,7 +2088,7 @@ const SPECS = [
1892
2088
  ],
1893
2089
  examples: [
1894
2090
  "cbx mark --name v2.1 --pin",
1895
- "cbx mark 41 --visibility unlisted",
2091
+ "cbx mark 41 --visibility private",
1896
2092
  'cbx mark 41 --labels "shipped,client work"',
1897
2093
  ],
1898
2094
  run: version_commands_js_1.commandVersion,
@@ -1994,7 +2190,22 @@ const SPECS = [
1994
2190
  summary: "the automations a project has",
1995
2191
  usage: "actions [project]",
1996
2192
  detail: "Shows what each action runs, what it runs on, and how many of its\n" +
1997
- "runs have passed. Use `cbx runner` to execute them on this machine.",
2193
+ "runs have passed. Use `cbx runner` to execute them on this machine.\n\n" +
2194
+ "An action that keeps files can hand them straight to the version it\n" +
2195
+ "ran against, so a passing build becomes a download without anybody\n" +
2196
+ "opening the website. --ship turns that on for one action.\n\n" +
2197
+ "Attaching is publishing: what an action ships onto a public version\n" +
2198
+ "is a download anybody can take, from the moment the run passes.",
2199
+ options: [
2200
+ { flags: "--ship <action>", description: "its builds become downloads on the version" },
2201
+ { flags: "--no-ship <action>", description: "keep its files without attaching them" },
2202
+ { flags: "--project <name>", description: "which project" },
2203
+ ],
2204
+ examples: [
2205
+ "cbx actions",
2206
+ 'cbx actions --ship "Windows installer"',
2207
+ 'cbx actions --no-ship "Windows installer"',
2208
+ ],
1998
2209
  run: service_commands_js_1.commandWorkflows,
1999
2210
  },
2000
2211
  {
@@ -2249,7 +2460,7 @@ async function main(argv) {
2249
2460
  if (spec.deprecatedBy) {
2250
2461
  console.error(dim(`"${name}" is now "${spec.deprecatedBy}". The old name still works.`));
2251
2462
  }
2252
- return spec.run(parse(rest));
2463
+ return spec.run(parse(rest, spec));
2253
2464
  }
2254
2465
  /**
2255
2466
  * Set the status and let Node wind down on its own.
@@ -47,9 +47,19 @@ exports.main = main;
47
47
  * `coderook://` URLs, which is what lets every editor's built-in git panel work
48
48
  * with CodeRook without any per-editor work.
49
49
  *
50
- * Both directions are implemented: `push` publishes commits as versions, and
51
- * `import` rebuilds a git history from versions so `git clone` and `git fetch`
52
- * work.
50
+ * Both directions are implemented: `push` publishes each commit as a save, and
51
+ * `import` rebuilds a git history from those saves so `git clone` and `git
52
+ * fetch` work.
53
+ *
54
+ * The vocabulary matters here, because the two products do not agree by
55
+ * accident. A pushed commit becomes a *commit* in CodeRook — a save, inside
56
+ * the project, that nobody outside can see. Tagging it names it, and a named
57
+ * save is a *version*, which is the only thing the public side offers. That
58
+ * is the same distinction git already draws between a commit and a tag, so
59
+ * the mapping is one-to-one rather than an approximation.
60
+ *
61
+ * A save also carries a description of its own, separate from its message.
62
+ * That travels as a git note on `refs/notes/coderook` in both directions.
53
63
  *
54
64
  * ## Why the `push` capability rather than `export`
55
65
  *
@@ -143,6 +153,26 @@ function git(args) {
143
153
  }
144
154
  return result.stdout;
145
155
  }
156
+ /**
157
+ * Where CodeRook keeps a save's description inside a git repository.
158
+ *
159
+ * `refs/notes/coderook` rather than git's default `refs/notes/commits`,
160
+ * because the default is shared ground — anything else attaching notes writes
161
+ * there too, and a fetch that overwrote somebody's own notes with ours would
162
+ * be taking something that was not offered.
163
+ */
164
+ const NOTES_REF = "refs/notes/coderook";
165
+ /**
166
+ * The description attached to a commit, if there is one.
167
+ *
168
+ * Empty rather than throwing for every ordinary reason it can be missing: no
169
+ * notes ref at all, no note on this commit, or a repository where notes have
170
+ * never been used. None of those is a problem worth stopping a push for.
171
+ */
172
+ function noteOn(sha) {
173
+ const result = (0, node_child_process_1.spawnSync)("git", ["notes", `--ref=${NOTES_REF}`, "show", sha], { encoding: "utf8", maxBuffer: 1024 * 1024 * 8 });
174
+ return result.status === 0 ? (result.stdout ?? "").trim() : "";
175
+ }
146
176
  /** Run git for its exit status alone. False rather than throwing. */
147
177
  function gitOk(args) {
148
178
  return ((0, node_child_process_1.spawnSync)("git", args, { encoding: "utf8", stdio: "ignore" }).status === 0);
@@ -665,6 +695,40 @@ async function doImport(refs, url) {
665
695
  say(` ${done}/${fresh.length} versions`);
666
696
  }
667
697
  }
698
+ /*
699
+ What each save says about itself, as git notes.
700
+
701
+ A CodeRook save carries a description separate from its message — the
702
+ thing the website shows as release info and `cbx notes` writes — and a
703
+ fetch used to drop all of it except on the few versions that were also
704
+ releases. Git's answer for text attached to a commit without changing
705
+ the commit is a note, so that is where it goes: `git log
706
+ --notes=coderook` shows them, and pushing them back returns them.
707
+
708
+ Written as one commit on its own ref after every version commit exists,
709
+ because a note has to name a commit that is already in the stream.
710
+ */
711
+ const described = fresh.filter((version) => (version.notes ?? "").trim());
712
+ if (described.length) {
713
+ send(`commit ${(0, git_history_js_1.importRef)(NOTES_REF)}`);
714
+ send(`committer CodeRook <noreply@coderook.com> ` +
715
+ `${(0, git_history_js_1.stamp)(new Date().toISOString())} +0000`);
716
+ const why = Buffer.from("What each save says about itself\n", "utf8");
717
+ send(`data ${why.length}`);
718
+ node_process_1.default.stdout.write(why);
719
+ node_process_1.default.stdout.write("\n");
720
+ for (const version of described) {
721
+ const mark = commitMark.get(version.id);
722
+ if (mark === undefined)
723
+ continue;
724
+ const body = Buffer.from(`${(version.notes ?? "").trim()}\n`, "utf8");
725
+ send(`N inline :${mark}`);
726
+ send(`data ${body.length}`);
727
+ node_process_1.default.stdout.write(body);
728
+ node_process_1.default.stdout.write("\n");
729
+ }
730
+ say(` ${described.length} description${described.length === 1 ? "" : "s"} as git notes`);
731
+ }
668
732
  /* Every ref git asked for, named outright now the commits exist. */
669
733
  settleRefs();
670
734
  /*
@@ -856,7 +920,7 @@ async function doPush(requests, url) {
856
920
  const existing = await releases(repositoryId).catch(() => []);
857
921
  const already = existing.find((release) => release.versionId === versionId && release.name !== tag);
858
922
  if (already) {
859
- say(` note: v${already.sequence} was already released as "${already.name}".
923
+ say(` note: v${already.sequence} was already released as "${already.name}".
860
924
  ` +
861
925
  ` A version carries one name, so it is now "${tag}".`);
862
926
  }
@@ -1098,10 +1162,10 @@ async function doPush(requests, url) {
1098
1162
  happened and go looking for a way to undo it.
1099
1163
  */
1100
1164
  send(`error ${request.dst} the connection failed part way through`);
1101
- say(`
1102
- ${published} of ${commits.length} commits were published.
1165
+ say(`
1166
+ ${published} of ${commits.length} commits were published.
1103
1167
  ` +
1104
- ` Push again — commits already published are not sent twice.
1168
+ ` Push again — commits already published are not sent twice.
1105
1169
  `);
1106
1170
  throw error;
1107
1171
  }
@@ -1113,8 +1177,8 @@ async function doPush(requests, url) {
1113
1177
  anyway.
1114
1178
  */
1115
1179
  send(`error ${request.dst} this commit carries a credential`);
1116
- say(`
1117
- ${failure.message}
1180
+ say(`
1181
+ ${failure.message}
1118
1182
  `);
1119
1183
  /*
1120
1184
  Deleting the file in a later commit does not help, and saying
@@ -1124,14 +1188,14 @@ async function doPush(requests, url) {
1124
1188
  The history has to lose it.
1125
1189
  */
1126
1190
  say(` A later commit that deletes it is not enough — every commit` +
1127
- ` being pushed
1191
+ ` being pushed
1128
1192
  becomes a version, and the one that added` +
1129
- ` the key still carries it.
1193
+ ` the key still carries it.
1130
1194
  ` +
1131
1195
  ` Rewrite it out (git rebase -i, or git commit --amend if it` +
1132
- ` is the last one),
1196
+ ` is the last one),
1133
1197
  or publish this deliberately with` +
1134
- ` cbx submit --allow-secrets.
1198
+ ` cbx submit --allow-secrets.
1135
1199
  `);
1136
1200
  /*
1137
1201
  Reported already, and in more detail than the wrapper can. The
@@ -1141,8 +1205,8 @@ async function doPush(requests, url) {
1141
1205
  }
1142
1206
  if (failure?.kind === "conflict") {
1143
1207
  send(`error ${request.dst} somebody published to "${branch}" while this push was running`);
1144
- say(`
1145
- git fetch, then push again.
1208
+ say(`
1209
+ git fetch, then push again.
1146
1210
  `);
1147
1211
  throw error;
1148
1212
  }
@@ -1154,6 +1218,27 @@ async function doPush(requests, url) {
1154
1218
  }
1155
1219
  currentRepositoryId = result.repositoryId;
1156
1220
  baseVersionId = result.versionId;
1221
+ /*
1222
+ A note on the commit becomes what the version says about itself.
1223
+
1224
+ CodeRook lets a save be described before anybody decides to publish
1225
+ it, and git has exactly one place for text attached to a commit
1226
+ without altering it. Sent after the publish rather than with it,
1227
+ because a note is not part of what was saved and a failure to
1228
+ record one must not fail the push.
1229
+ */
1230
+ const note = noteOn(sha);
1231
+ if (note) {
1232
+ try {
1233
+ const { changeVersion } = await Promise.resolve().then(() => __importStar(require("./api.js")));
1234
+ await changeVersion(result.repositoryId, result.versionId, {
1235
+ notes: note,
1236
+ });
1237
+ }
1238
+ catch {
1239
+ say(` note: the description on ${sha.slice(0, 8)} could not be saved`);
1240
+ }
1241
+ }
1157
1242
  /*
1158
1243
  The version and the commit it came from are the same thing in this
1159
1244
  repository from here on. Recorded so a later fetch does not rebuild