@ok-ggwp/git-gloss 0.5.0 → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { parseArgs } from 'node:util';
3
- import { git, gitTry, GitError, NOTES_REF } from '../git.js';
3
+ import { git, gitTry, GitError, gitCleanup, NETWORK_TIMEOUT, NOTES_REF, rethrowTimeout } from '../git.js';
4
4
  import { carryNew } from '../origins.js';
5
5
  import { REVIEWS_REF } from '../reviews.js';
6
6
  import { carry } from './carry.js';
@@ -22,7 +22,8 @@ function createdAt(blob) {
22
22
  const value = JSON.parse(git(['cat-file', 'blob', blob]))?.created_at;
23
23
  return typeof value === 'string' ? value : null;
24
24
  }
25
- catch {
25
+ catch (err) {
26
+ rethrowTimeout(err);
26
27
  return null;
27
28
  }
28
29
  }
@@ -50,12 +51,34 @@ function shareable(remote, pushing) {
50
51
  // code (or an update-ref onto our own notes ref) touches it.
51
52
  function checkTracking(remote, ref, tracking) {
52
53
  if (gitTry(['rev-parse', '-q', '--verify', `${tracking}^{commit}`]) === null) {
53
- gitTry(['update-ref', '-d', tracking]);
54
+ gitCleanup(['update-ref', '-d', tracking]);
54
55
  throw new GitError(`${remote}'s ${ref} is not a notes commit; ignoring it (nothing was changed here)`);
55
56
  }
56
57
  }
58
+ // `init`'s refspec is a glob (refs/notes/gloss*), so a remote that once published
59
+ // refs/notes/gloss/x leaves refs/notes/remotes/<remote>/gloss/x behind, and git
60
+ // then cannot create refs/notes/remotes/<remote>/gloss once the remote has that
61
+ // ref (a ref cannot be both a file and a directory): every fetch fails until
62
+ // `git remote prune`. We are about to fetch the ref into `tracking`, so
63
+ // whatever sits below it is stale and cannot be valid together with it. Only
64
+ // that namespace is touched, in one batched update-ref.
65
+ function clearBlockingTracking(tracking) {
66
+ const stale = (gitTry(['for-each-ref', '--format=%(refname)', `${tracking}/`]) ?? '').split('\n').filter((r) => r.startsWith(`${tracking}/`));
67
+ if (stale.length)
68
+ git(['update-ref', '--stdin'], stale.map((r) => `delete ${r}\n`).join(''));
69
+ }
57
70
  function fetchTracking(remote, ref, tracking) {
58
- git(['fetch', '-q', remote, `+${ref}:${tracking}`]);
71
+ try {
72
+ clearBlockingTracking(tracking);
73
+ git(['fetch', '-q', remote, `+${ref}:${tracking}`], undefined, {}, NETWORK_TIMEOUT);
74
+ }
75
+ catch (err) {
76
+ rethrowTimeout(err);
77
+ if (err instanceof GitError && /cannot lock ref|Unable to create .*\.lock|some local refs could not be updated/.test(err.message)) {
78
+ throw new GitError(`${err.message}\n a stale remote-tracking ref may be in the way; try: git remote prune ${remote}`);
79
+ }
80
+ throw err;
81
+ }
59
82
  checkTracking(remote, ref, tracking);
60
83
  }
61
84
  const PUSH_REF = 'refs/notes/gloss-sync-tmp';
@@ -63,7 +86,7 @@ const PUSH_REF = 'refs/notes/gloss-sync-tmp';
63
86
  // someone else's push and the round should be retried.
64
87
  function round(remote, pushing, carried) {
65
88
  const tracking = `refs/notes/remotes/${remote}/gloss`;
66
- const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF]) !== null;
89
+ const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF], undefined, NETWORK_TIMEOUT) !== null;
67
90
  if (remoteHas)
68
91
  fetchTracking(remote, NOTES_REF, tracking);
69
92
  const local = noteList(NOTES_REF);
@@ -142,13 +165,13 @@ function round(remote, pushing, carried) {
142
165
  if (remoteHas)
143
166
  git(['update-ref', PUSH_REF, tracking]);
144
167
  else
145
- gitTry(['update-ref', '-d', PUSH_REF]);
168
+ gitCleanup(['update-ref', '-d', PUSH_REF]);
146
169
  for (const [object, blob] of toPush)
147
170
  git(['notes', '--ref', PUSH_REF, 'add', '-f', '-C', blob, object]);
148
171
  try {
149
172
  // --no-verify: don't run the pre-push hook for the notes push itself,
150
173
  // which would call sync again.
151
- git(['push', '-q', '--no-verify', remote, `${PUSH_REF}:${NOTES_REF}`]);
174
+ git(['push', '-q', '--no-verify', remote, `${PUSH_REF}:${NOTES_REF}`], undefined, {}, NETWORK_TIMEOUT);
152
175
  }
153
176
  catch (err) {
154
177
  if (/\(fetch first\)|non-fast-forward/.test(err.message))
@@ -159,7 +182,7 @@ function round(remote, pushing, carried) {
159
182
  git(['notes', '--ref', NOTES_REF, 'merge', '-q', '-s', 'ours', PUSH_REF]);
160
183
  }
161
184
  finally {
162
- gitTry(['update-ref', '-d', PUSH_REF]);
185
+ gitCleanup(['update-ref', '-d', PUSH_REF]);
163
186
  }
164
187
  return { fetched, pushed: toPush.length, withheld, conflicts };
165
188
  }
@@ -169,7 +192,7 @@ const REVIEWS_PUSH_REF = 'refs/notes/gloss-reviews-sync-tmp';
169
192
  // commits the remote may see are sent. Returns null if the push lost a race.
170
193
  function syncReviews(remote, pushing) {
171
194
  const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
172
- const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
195
+ const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF], undefined, NETWORK_TIMEOUT) !== null;
173
196
  if (remoteHas)
174
197
  fetchTracking(remote, REVIEWS_REF, tracking);
175
198
  const before = noteList(REVIEWS_REF);
@@ -190,11 +213,11 @@ function syncReviews(remote, pushing) {
190
213
  if (remoteHas)
191
214
  git(['update-ref', REVIEWS_PUSH_REF, tracking]);
192
215
  else
193
- gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
216
+ gitCleanup(['update-ref', '-d', REVIEWS_PUSH_REF]);
194
217
  for (const [object, blob] of toPush)
195
218
  git(['notes', '--ref', REVIEWS_PUSH_REF, 'add', '-f', '-C', blob, object]);
196
219
  try {
197
- git(['push', '-q', '--no-verify', remote, `${REVIEWS_PUSH_REF}:${REVIEWS_REF}`]);
220
+ git(['push', '-q', '--no-verify', remote, `${REVIEWS_PUSH_REF}:${REVIEWS_REF}`], undefined, {}, NETWORK_TIMEOUT);
198
221
  }
199
222
  catch (err) {
200
223
  if (/\(fetch first\)|non-fast-forward/.test(err.message))
@@ -204,7 +227,7 @@ function syncReviews(remote, pushing) {
204
227
  git(['notes', '--ref', REVIEWS_REF, 'merge', '-q', '-s', 'cat_sort_uniq', REVIEWS_PUSH_REF]);
205
228
  }
206
229
  finally {
207
- gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
230
+ gitCleanup(['update-ref', '-d', REVIEWS_PUSH_REF]);
208
231
  }
209
232
  return { fetched, pushed: toPush.length };
210
233
  }
@@ -1,6 +1,9 @@
1
1
  import { parseArgs } from 'node:util';
2
- import { commitDiff, git, gitTry, GlossError, NOTES_REF, resolveCommit, WIP_NOTES_REF, WIP_REF, writeNote } from '../git.js';
2
+ import { commitDiff, git, GitError, gitTry, GlossError, noteSizeProblem, NOTES_REF, parseNote, resolveCommit, WIP_NOTES_REF, WIP_REF, rethrowTimeout, writeNote } from '../git.js';
3
+ import { plain } from '../plain.js';
3
4
  import { location, parseDiff } from '../diff.js';
5
+ import { loadGlossOf } from '../loader.js';
6
+ import { carriedErrors } from '../validate.js';
4
7
  import { mergeGlosses } from '../merge.js';
5
8
  import { noteFormat } from '../schema.js';
6
9
  import { refLabel } from '../selection.js';
@@ -51,18 +54,29 @@ function history(ref, only) {
51
54
  flush();
52
55
  return out;
53
56
  }
57
+ // How many group ids the listing names for one version.
58
+ const MAX_LISTED = 20;
59
+ // Everything a version of the history prints comes from a note that may have been
60
+ // pushed by anyone: it is size-checked before it is read, cleaned, and bounded.
54
61
  function summary(blob) {
62
+ const size = Number(gitTry(['cat-file', '-s', blob]));
63
+ const big = noteSizeProblem(size);
64
+ if (big)
65
+ return `(not read: ${big})`;
55
66
  try {
56
67
  const note = JSON.parse(git(['cat-file', 'blob', blob]));
57
68
  const a = note.annotation;
58
- const from = note.sources ? `carried from ${note.sources.map((s) => s.slice(0, 7)).join(', ')}; ` : '';
59
- return `${from}${a.groups.length} group${a.groups.length === 1 ? '' : 's'}: ${a.groups.map((g) => g.id).join(', ')}\n task: ${JSON.stringify(a.task)}`;
69
+ const from = Array.isArray(note.sources) ? `carried from ${note.sources.slice(0, 5).map((s) => plain(String(s)).slice(0, 7)).join(', ')}; ` : '';
70
+ const ids = a.groups.slice(0, MAX_LISTED).map((g) => plain(String(g.id)).slice(0, 40));
71
+ const n = a.groups.length;
72
+ return `${from}${n} group${n === 1 ? '' : 's'}: ${ids.join(', ')}${n > MAX_LISTED ? `, and ${n - MAX_LISTED} more` : ''}\n task: ${JSON.stringify(plain(String(a.task)).slice(0, 200))}`;
60
73
  }
61
- catch {
74
+ catch (err) {
75
+ rethrowTimeout(err);
62
76
  return '(not a readable gloss: joined notes from a squash, or not JSON)';
63
77
  }
64
78
  }
65
- const when = (iso) => iso.slice(0, 16).replace('T', ' ');
79
+ const when = (iso) => plain(iso).slice(0, 16).replace('T', ' ');
66
80
  export function run(argv) {
67
81
  const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { show: { type: 'string' }, restore: { type: 'string' } } });
68
82
  const wip = positionals[0] === 'wip';
@@ -84,12 +98,37 @@ export function run(argv) {
84
98
  return { n, v: v };
85
99
  };
86
100
  if (values.show !== undefined) {
87
- process.stdout.write(git(['cat-file', 'blob', pick(values.show, '--show').v.blob]));
101
+ // Cleaned like everything else printed (control characters, escape sequences, bidi and
102
+ // zero-width characters become spaces), so not byte-identical to the stored blob.
103
+ process.stdout.write(plain(git(['cat-file', 'blob', pick(values.show, '--show').v.blob]), true));
88
104
  return 0;
89
105
  }
90
106
  if (values.restore !== undefined) {
91
107
  const { n, v } = pick(values.restore, '--restore');
108
+ // Only a gloss that readers accept is brought back (a restore must not plant what they refuse).
109
+ const text = git(['cat-file', 'blob', v.blob]);
110
+ let note;
111
+ try {
112
+ note = parseNote(text, `version ${n}`);
113
+ }
114
+ catch (err) {
115
+ if (!(err instanceof GlossError) || err instanceof GitError)
116
+ throw err;
117
+ throw new GlossError(`${err.message.replace(/; (replace it|re-gloss it|upgrade).*$/, '')}, so it is not restored (read it with --show ${n})`);
118
+ }
119
+ // What readers refuse for the commit it would go on (too many claimed lines).
120
+ const checkOn = (target) => {
121
+ try {
122
+ loadGlossOf(target, note);
123
+ }
124
+ catch (err) {
125
+ if (!(err instanceof GlossError) || err instanceof GitError)
126
+ throw err;
127
+ throw new GlossError(`version ${n} would be refused on ${target.slice(0, 7)}: ${err.message.replace(/^the gloss on \S+ is refused: /, '')} (not restored; read it with --show ${n})`);
128
+ }
129
+ };
92
130
  if (!wip) {
131
+ checkOn(sha);
93
132
  git(['notes', '--ref', ref, 'add', '-f', '-C', v.blob, sha]);
94
133
  console.log(`restored version ${n} as the gloss of ${label}`);
95
134
  return 0;
@@ -100,8 +139,8 @@ export function run(argv) {
100
139
  const onto = gitTry(['rev-parse', '-q', '--verify', WIP_REF])?.trim();
101
140
  if (!onto)
102
141
  throw new GlossError('there is no snapshot of uncommitted work to restore onto; run git gloss hunks wip first');
103
- const note = JSON.parse(git(['cat-file', 'blob', v.blob]));
104
142
  if (v.object === onto || gitTry(['cat-file', '-e', `${v.object}^{commit}`]) === null) {
143
+ checkOn(onto);
105
144
  git(['notes', '--ref', ref, 'add', '-f', '-C', v.blob, onto]);
106
145
  console.log(`restored version ${n} onto the wip snapshot ${onto.slice(0, 7)}` +
107
146
  (v.object === onto ? '' : `; its snapshot ${v.object.slice(0, 7)} is gone, so hunk references may not match: check with git gloss show wip`));
@@ -109,6 +148,11 @@ export function run(argv) {
109
148
  }
110
149
  const current = parseDiff(commitDiff(onto));
111
150
  const merged = mergeGlosses([{ sha: v.object, note, hunks: parseDiff(commitDiff(v.object)) }], current);
151
+ if (merged.annotation) {
152
+ const errors = carriedErrors(merged.annotation, current);
153
+ if (errors.length)
154
+ throw new GlossError(`version ${n} doesn't fit the uncommitted work: ${errors.join('; ')} (not restored)`);
155
+ }
112
156
  if (!merged.annotation)
113
157
  throw new GlossError(`none of version ${n}'s glossed changes are in the uncommitted work any more; read it with --show ${n}`);
114
158
  writeNote(onto, {
@@ -122,7 +166,7 @@ export function run(argv) {
122
166
  }
123
167
  console.log(`glosses of ${label}, newest first:`);
124
168
  versions.forEach((v, i) => {
125
- const head = `${String(i + 1).padStart(3)} ${when(v.at)} ${v.how}${wip ? ` (snapshot ${v.object.slice(0, 7)})` : ''}`;
169
+ const head = `${String(i + 1).padStart(3)} ${when(v.at)} ${plain(v.how)}${wip ? ` (snapshot ${v.object.slice(0, 7)})` : ''}`;
126
170
  console.log(`${head}${i === 0 && v.blob && !wip ? ' [current]' : ''}`);
127
171
  console.log(` ${v.blob ? summary(v.blob) : 'removed'}`);
128
172
  });
@@ -1,7 +1,7 @@
1
1
  import { plain } from '../plain.js';
2
2
  import { parseArgs } from 'node:util';
3
3
  import { blameRange, commitSubject, lineHistory } from '../git.js';
4
- import { loadGloss } from '../loader.js';
4
+ import { loadGlossInRange, refusedWarning } from '../loader.js';
5
5
  import { servesOf } from '../schema.js';
6
6
  import { field, groupHeader, style } from '../render.js';
7
7
  import { bodyLines, formatRanges } from '../selection.js';
@@ -10,8 +10,13 @@ export const usage = 'why <file>:<line>[-<end>] [--history] [--rev <commit>] [--
10
10
  // refer to it; each load diffs the commit.
11
11
  const glossCache = new Map();
12
12
  function glossFor(commit) {
13
- if (!glossCache.has(commit))
14
- glossCache.set(commit, loadGloss(commit));
13
+ if (!glossCache.has(commit)) {
14
+ // A refused gloss (hostile or broken) is skipped with a warning, once, not a failed command.
15
+ const { gloss, refused } = loadGlossInRange(commit);
16
+ if (refused)
17
+ console.error(refusedWarning(commit, refused));
18
+ glossCache.set(commit, gloss);
19
+ }
15
20
  return glossCache.get(commit);
16
21
  }
17
22
  // Groups in a gloss that own `line` of `file` on the new side of the commit's
package/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { git, GitError, GlossError, gitTry } from './git.js';
2
+ import { git, GitError, GlossError, gitTry, rethrowTimeout } from './git.js';
3
3
  import { globToRegExp } from './glob.js';
4
4
  // Settings for the computed signals, in three layers (later wins for scalars,
5
5
  // repo config extends the lists of the one before):
@@ -137,10 +137,12 @@ function gitInt(key) {
137
137
  }
138
138
  // Git failing (a broken repo, an unreadable object, no permission) is not the
139
139
  // same as "no base" or "no file": the repo's own, perhaps stricter, config
140
- // would silently not apply. It is not fatal either (the built-in defaults
141
- // still give a floor, and refusing to rate anything would make every command
142
- // fail in a damaged repo), so it is warned about on stderr, once per message,
143
- // and the defaults are used.
140
+ // would silently not apply, and a floor computed from the defaults alone could
141
+ // be lower than the real one. So the ratings are unverifiable: loadConfig
142
+ // throws a ConfigError, which `add` and `signals` report as an error, and
143
+ // `show`, `lint`, `html` and `hunks` meet through withSignals (the signals are
144
+ // unavailable, `lint` says "incomplete"). A broken base candidate is only
145
+ // warned about (once per message) while a later one still works.
144
146
  const warned = new Set();
145
147
  function warnOnce(msg) {
146
148
  if (warned.has(msg))
@@ -171,8 +173,14 @@ function gitAnswer(args) {
171
173
  // is always a `sensitive` path.
172
174
  function trustedRef() {
173
175
  // No commits yet: no base, and nothing failed.
174
- if (gitAnswer(['rev-parse', '--verify', '-q', 'HEAD']) === null)
175
- return noBase();
176
+ try {
177
+ if (gitAnswer(['rev-parse', '--verify', '-q', 'HEAD']) === null)
178
+ return noBase();
179
+ }
180
+ catch (err) {
181
+ rethrowTimeout(err);
182
+ throw new ConfigError(`git failed finding HEAD (${err.message}), so ${CONFIG_FILE} cannot be read and the ratings cannot be verified`);
183
+ }
176
184
  let failed = false;
177
185
  // Full ref names, so a tag called "main" cannot stand in for the branch.
178
186
  for (const b of ['refs/remotes/origin/main', 'refs/heads/main', 'refs/remotes/origin/master', 'refs/heads/master']) {
@@ -184,20 +192,23 @@ function trustedRef() {
184
192
  return mb;
185
193
  }
186
194
  catch (err) {
195
+ rethrowTimeout(err);
187
196
  // A broken candidate does not hide the ones after it.
188
197
  failed = true;
189
198
  warnOnce(`git failed finding the base branch with ${b} (${err.message}); trying the next candidate`);
190
199
  }
191
200
  }
192
201
  // Some candidate failed: git's warning above is the reason, not an absent branch.
193
- return failed ? (console.error(`warning: no working base branch, so ${CONFIG_FILE} is not read; using the built-in defaults`), null) : noBase();
202
+ if (failed)
203
+ throw new ConfigError(`git failed on every base branch candidate, so ${CONFIG_FILE} cannot be read and the ratings cannot be verified (the repo's own patterns would not apply); fix the repository, or see the warnings above`);
204
+ return noBase();
194
205
  }
195
206
  function noBase() {
196
207
  console.error('warning: no base branch (origin/main, main, origin/master, master) found, so .gloss/config.json is not read; using the built-in defaults');
197
208
  return null;
198
209
  }
199
210
  // The config file at `ref`: null when the file is not there (the usual case),
200
- // a warning and null when git could not tell or could not read it. Presence
211
+ // a ConfigError when git could not tell or could not read it. Presence
201
212
  // is asked of ls-tree (empty output: absent), not read off an error message.
202
213
  function configAt(ref) {
203
214
  try {
@@ -206,8 +217,8 @@ function configAt(ref) {
206
217
  return git(['show', `${ref}:${CONFIG_FILE}`]);
207
218
  }
208
219
  catch (err) {
209
- warnOnce(`${CONFIG_FILE} could not be read from the base branch (${err.message}); using the built-in defaults, so the repo's own patterns do not apply`);
210
- return null;
220
+ rethrowTimeout(err);
221
+ throw new ConfigError(`${CONFIG_FILE} could not be read from the base branch (${err.message}); the repo's own patterns cannot be applied, so the ratings cannot be verified`);
211
222
  }
212
223
  }
213
224
  function readRepoConfig() {