rman 2.15.0 → 2.15.2

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.
@@ -200,9 +200,13 @@ function jsonPlan(entries) {
200
200
  function printPlan(entries, root) {
201
201
  const updated = entries.filter(e => e.status === 'update');
202
202
  const behind = entries.filter(e => e.status === 'held' || e.status === 'skipped' || e.status === 'error' || (e.status === 'update' && e.reason));
203
- if (!updated.length && !behind.length) {
204
- console.log(colors.gray('Every dependency is up to date.'));
205
- return;
203
+ /** Said first when nothing moves, so a run whose only lines are majors left behind does not read
204
+ * as a list of things to do. */
205
+ if (!updated.length) {
206
+ console.log(colors.green('All dependencies are up to date') +
207
+ (behind.length ? colors.gray(' - newer versions their settings leave out, or another rule holds back:') : '.'));
208
+ if (!behind.length)
209
+ return;
206
210
  }
207
211
  const shown = [...updated, ...behind];
208
212
  const nameWidth = Math.max(...shown.map(e => e.name.length));
@@ -286,7 +286,8 @@ function printApplied(result) {
286
286
  console.log(`${colors.green('tags')} ${release.map(label).join(', ')} ${colors.gray('(repository release)')}`);
287
287
  }
288
288
  console.log(result.pushed
289
- ? `${colors.green('push')} pushed, with tags`
289
+ ? `${colors.green('push')} pushed the branch` +
290
+ (result.tags.length ? ` and ${result.tags.map(t => t.name).join(', ')}, together` : '')
290
291
  : `${colors.gray('push')} ${colors.gray('not pushed - run with --push, or push it yourself')}`);
291
292
  }
292
293
  function printPlan(entries) {
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.15.0';
1
+ export const version = '2.15.2';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.15.0",
4
+ "version": "2.15.2",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -254,8 +254,21 @@ export class VersionService extends Service {
254
254
  tagged.push({ name: releaseTag, created: true, release: true });
255
255
  }
256
256
  const pushed = !!options.push && bumped.length > 0;
257
- if (pushed)
258
- await git.push();
257
+ if (pushed) {
258
+ /** Every tag this release names, including one that was already there: it is this release's
259
+ * tag all the same, and a remote that lacks it is missing part of the release. */
260
+ const tags = tagged.map(t => t.name);
261
+ try {
262
+ await git.push({ tags });
263
+ }
264
+ catch (e) {
265
+ throw new Error(`${e.message}\n\n` +
266
+ ` Nothing was pushed - the branch and its tags go up together or not at all. Left behind ` +
267
+ `locally: ${commits.map(c => c.sha).join(', ') || 'no commit'}` +
268
+ (tags.length ? `, tagged ${tags.join(', ')}` : '') +
269
+ `.\n Once the cause is fixed, push them with: git push --atomic origin HEAD ${tags.join(' ')}`.trimEnd(), { cause: e });
270
+ }
271
+ }
259
272
  return { entries: plan, updated: bumped, commits, tags: tagged, pushed };
260
273
  }
261
274
  }
package/utils/git.d.ts CHANGED
@@ -121,11 +121,13 @@ export declare class GitHelper {
121
121
  * Throws if a tag with that name already exists - callers wanting idempotent tagging should
122
122
  * check `tagExists` first. */
123
123
  createTag(name: string, message?: string): Promise<void>;
124
- /** Pushes the current branch to `remote` (default `"origin"`), and its tags too unless
125
- * `options.tags` is `false`. */
124
+ /**
125
+ * Pushes the current branch to `remote` (default `"origin"`) together with `options.tags`, by
126
+ * name, in **one atomic push**: the branch and every tag go up, or none of them does.
127
+ */
126
128
  push(options?: {
127
129
  remote?: string;
128
- tags?: boolean;
130
+ tags?: readonly string[];
129
131
  }): Promise<void>;
130
132
  /** `git remote get-url <remote>` (default `"origin"`) - `undefined` when that remote isn't
131
133
  * configured at all, which is a perfectly normal state for a local-only repository. */
package/utils/git.js CHANGED
@@ -394,14 +394,24 @@ export class GitHelper {
394
394
  throw new Error(`Unable to create tag "${name}": ${e.message}`, { cause: e });
395
395
  }
396
396
  }
397
- /** Pushes the current branch to `remote` (default `"origin"`), and its tags too unless
398
- * `options.tags` is `false`. */
397
+ /**
398
+ * Pushes the current branch to `remote` (default `"origin"`) together with `options.tags`, by
399
+ * name, in **one atomic push**: the branch and every tag go up, or none of them does.
400
+ */
401
+ /* **Named tags, not `--tags`.** `git push <remote> --tags` sends every tag in the clone, so one
402
+ * stale tag from months ago - re-created locally, diverged from the remote's - failed every
403
+ * release from then on, and failed it *after* the new tag had already gone up. Measured on
404
+ * `postgrejs` with 2.15.0: `v3.13.0` pushed, `v3.10.0` rejected, exit 1, and in CI the publish
405
+ * step after it never ran.
406
+ *
407
+ * **One command, `--atomic`.** The branch used to go first and the tags second, so a failure in
408
+ * the second - GitHub's own `fatal error in commit_refs` did it to the same repository an hour
409
+ * earlier - left a pushed version bump with no tag, which a re-run cannot repair because it has
410
+ * nothing left to bump. Atomic, a failure leaves the remote exactly as it was. */
399
411
  async push(options) {
400
412
  const remote = options?.remote ?? 'origin';
401
413
  try {
402
- await execFileAsync('git', ['push', remote], { cwd: this.cwd });
403
- if (options?.tags !== false)
404
- await execFileAsync('git', ['push', remote, '--tags'], { cwd: this.cwd });
414
+ await execFileAsync('git', ['push', '--atomic', remote, 'HEAD', ...(options?.tags ?? [])], { cwd: this.cwd });
405
415
  }
406
416
  catch (e) {
407
417
  throw new Error(`Unable to push to "${remote}": ${e.message}`, { cause: e });
@@ -33,6 +33,12 @@ export declare class StatusRegion implements TerminalRegion {
33
33
  * there, so `runBin` keeps asking one object (`app.statusRegion`) whatever is actually drawing.
34
34
  */
35
35
  private takeover?;
36
+ /** Puts back the stream writes `guardWrites` replaced. */
37
+ private restoreWrites?;
38
+ /** Set while this region is writing its own frame, so the guard lets it through untouched. */
39
+ private drawing;
40
+ /** The last write from outside ended mid-line, so no frame is drawn until the line is finished. */
41
+ private partial;
36
42
  constructor(label: string, detail?: string, enabled?: boolean);
37
43
  /** Whether the spinner is actually drawn. `false` leaves `passThrough` a plain write and `stop`
38
44
  * its single line, which is what a non-TTY wants. */
@@ -69,6 +75,14 @@ export declare class StatusRegion implements TerminalRegion {
69
75
  passThrough(text: string): void;
70
76
  /** Erases the spinner and leaves one line saying how it went. */
71
77
  stop(outcome: 'ok' | 'fail'): void;
78
+ /**
79
+ * Moves every write to stdout and stderr above the spinner: erase it, write, draw it again below.
80
+ * Undone by `stop`.
81
+ */
82
+ private guardWrites;
83
+ /** Runs `write` as one of this region's own writes, so the guard passes it through. */
84
+ private own;
85
+ private clear;
72
86
  private draw;
73
87
  /** `0.4s` while running and `950ms` / `4.1s` / `2m 03s` at the end - one formatter, because a
74
88
  * duration that changes shape as it ticks reads as a glitch. */
@@ -12,6 +12,12 @@ export class StatusRegion {
12
12
  * there, so `runBin` keeps asking one object (`app.statusRegion`) whatever is actually drawing.
13
13
  */
14
14
  takeover;
15
+ /** Puts back the stream writes `guardWrites` replaced. */
16
+ restoreWrites;
17
+ /** Set while this region is writing its own frame, so the guard lets it through untouched. */
18
+ drawing = false;
19
+ /** The last write from outside ended mid-line, so no frame is drawn until the line is finished. */
20
+ partial = false;
15
21
  constructor(label, detail = '', enabled) {
16
22
  this.label = label;
17
23
  this.detail = detail;
@@ -26,6 +32,7 @@ export class StatusRegion {
26
32
  start() {
27
33
  if (!this.region.enabled)
28
34
  return;
35
+ this.guardWrites();
29
36
  this.draw();
30
37
  /** **`unref`, so a spinner never holds the process open.** A command that finishes its work and
31
38
  * leaves nothing else pending would otherwise wait out the interval before exiting. */
@@ -55,7 +62,7 @@ export class StatusRegion {
55
62
  if (this.timer)
56
63
  clearInterval(this.timer);
57
64
  this.timer = undefined;
58
- this.region.clear();
65
+ this.clear();
59
66
  }
60
67
  /**
61
68
  * Takes the terminal back - a no-op when it was never suspended.
@@ -88,8 +95,8 @@ export class StatusRegion {
88
95
  process.stderr.write(text);
89
96
  return;
90
97
  }
91
- this.region.clear();
92
- process.stderr.write(text);
98
+ this.clear();
99
+ this.own(() => process.stderr.write(text));
93
100
  this.draw();
94
101
  }
95
102
  /** Erases the spinner and leaves one line saying how it went. */
@@ -98,11 +105,62 @@ export class StatusRegion {
98
105
  if (this.timer)
99
106
  clearInterval(this.timer);
100
107
  this.timer = undefined;
101
- this.region.clear();
108
+ this.clear();
109
+ this.restoreWrites?.();
110
+ this.restoreWrites = undefined;
102
111
  const mark = outcome === 'ok' ? colors.green('✔') : colors.red('✖');
103
- process.stderr.write(`${mark} ${colors.bold(this.label)} ${colors.gray(this.elapsed())}\n`);
112
+ const lead = this.partial ? '\n' : '';
113
+ process.stderr.write(`${lead}${mark} ${colors.bold(this.label)} ${colors.gray(this.elapsed())}\n`);
114
+ }
115
+ /**
116
+ * Moves every write to stdout and stderr above the spinner: erase it, write, draw it again below.
117
+ * Undone by `stop`.
118
+ */
119
+ /* **Here, once, rather than in every command.** Measured with a pseudo-terminal: `rman deps`
120
+ * printed its plan with `console.log`, the spinner's next erase moved up one row from *below* the
121
+ * plan, and the plan's last line - the one dependency it was reporting - was gone from the screen,
122
+ * leaving a "not updated" heading with nothing under it. `version --show` lost its closing
123
+ * "Nothing to version." the same way. Asking each command to route its output through
124
+ * `passThrough` is a rule every new command would have to remember; a command's own `console.log`
125
+ * is the ordinary thing to write.
126
+ *
127
+ * **Left alone while suspended**: the region that took over draws to stdout itself, and it owns the
128
+ * terminal until `resume`. **A write ending mid-line holds the next frame back** (`partial`), or the
129
+ * frame's `\r` + erase would wipe the half-written line. */
130
+ guardWrites() {
131
+ const streams = [process.stdout, process.stderr];
132
+ const originals = streams.map(stream => stream.write);
133
+ streams.forEach((stream, i) => {
134
+ const original = originals[i];
135
+ stream.write = ((chunk, ...rest) => {
136
+ if (this.drawing || this.takeover)
137
+ return original.call(stream, chunk, ...rest);
138
+ this.clear();
139
+ const written = this.own(() => original.call(stream, chunk, ...rest));
140
+ this.partial = !String(chunk).endsWith('\n');
141
+ this.draw();
142
+ return written;
143
+ });
144
+ });
145
+ this.restoreWrites = () => streams.forEach((stream, i) => (stream.write = originals[i]));
146
+ }
147
+ /** Runs `write` as one of this region's own writes, so the guard passes it through. */
148
+ own(write) {
149
+ const was = this.drawing;
150
+ this.drawing = true;
151
+ try {
152
+ return write();
153
+ }
154
+ finally {
155
+ this.drawing = was;
156
+ }
157
+ }
158
+ clear() {
159
+ this.own(() => this.region.clear());
104
160
  }
105
161
  draw() {
162
+ if (this.partial)
163
+ return;
106
164
  const spinner = colors.cyan(FRAMES[this.frame % FRAMES.length]);
107
165
  const parts = [
108
166
  spinner,
@@ -110,7 +168,7 @@ export class StatusRegion {
110
168
  this.detail && colors.gray(this.detail),
111
169
  colors.gray(this.elapsed()),
112
170
  ];
113
- this.region.render([parts.filter(Boolean).join(' ')]);
171
+ this.own(() => this.region.render([parts.filter(Boolean).join(' ')]));
114
172
  }
115
173
  /** `0.4s` while running and `950ms` / `4.1s` / `2m 03s` at the end - one formatter, because a
116
174
  * duration that changes shape as it ticks reads as a glitch. */