@functional-systems/srcx 0.1.0-dev.1 → 0.1.0-dev.10

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/src/cli.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
- import { Command, InvalidArgumentError } from "commander";
3
+ import { Command, InvalidArgumentError, Option } from "commander";
4
4
  import { mkdir } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { configure, loadSettings, stateRoot } from "./settings.js";
8
- import { identity } from "./git.js";
9
- import { loadBuild, materialize, validateBuild, type Build } from "./build.js";
8
+ import { identity, resolveCommit } from "./git.js";
9
+ import {
10
+ loadBuild,
11
+ materialize,
12
+ validateBuild,
13
+ type Build,
14
+ type Preset,
15
+ PRESET,
16
+ presetFor,
17
+ } from "./build.js";
10
18
  import { invariant, optionalJson } from "./common.js";
11
19
  import { LambdaRemote } from "./remote.js";
12
20
  import {
@@ -16,7 +24,13 @@ import {
16
24
  register,
17
25
  selectRepository,
18
26
  } from "./repository.js";
19
- import { exclusive, publish, published, type Published } from "./publish.js";
27
+ import {
28
+ exclusive,
29
+ lastBuildPath,
30
+ publish,
31
+ published,
32
+ type Published,
33
+ } from "./publish.js";
20
34
  import { resolveVersion, syncTags } from "./releases.js";
21
35
  import { directHandle, loadHandle, readHandle, search } from "./search.js";
22
36
  const cli = new Command()
@@ -81,6 +95,14 @@ repo
81
95
  .option("--remote <name>")
82
96
  .option("--description <text>")
83
97
  .option("--tag <key=value>", "One optional Collection metadata tag")
98
+ .addOption(
99
+ new Option(
100
+ "--embedding <model>",
101
+ "Managed model (source text is sent to the provider on import)",
102
+ )
103
+ .choices(["none", "text-embedding-3-small", "text-embedding-3-large"])
104
+ .default("none"),
105
+ )
84
106
  .action(async (o) => {
85
107
  const { remote } = await connected();
86
108
  let labels: Record<string, string> | undefined;
@@ -95,6 +117,7 @@ repo
95
117
  remote: o.remote,
96
118
  description: o.description,
97
119
  labels,
120
+ preset: presetFor(o.embedding),
98
121
  }),
99
122
  );
100
123
  });
@@ -122,6 +145,12 @@ cli
122
145
  .option("--repo <name>", "Registered remote repository")
123
146
  .option("--ref <ref>", "Local branch, Git tag, or commit OID")
124
147
  .option("--dry-run", "Only materialize and validate a local build")
148
+ .addOption(
149
+ new Option(
150
+ "--embedding <model>",
151
+ "Preset for --dry-run --path; connected imports use the repository preset",
152
+ ).choices(["none", "text-embedding-3-small", "text-embedding-3-large"]),
153
+ )
125
154
  .option("--output <directory>", "New artifact directory")
126
155
  .option(
127
156
  "--previous <directory>",
@@ -134,6 +163,10 @@ cli
134
163
  )
135
164
  .option("--timeout <seconds>", "Validation wait deadline", integer, 120)
136
165
  .action(async (o) => {
166
+ invariant(
167
+ !o.embedding || (o.dryRun && o.path),
168
+ "--embedding is only accepted with --dry-run --path; connected imports use the repository preset.",
169
+ );
137
170
  invariant(!(o.path && o.repo), "Choose --path or --repo.");
138
171
  invariant(
139
172
  !o.previous || o.dryRun,
@@ -147,16 +180,33 @@ cli
147
180
  const buildLocal = async (
148
181
  source: Awaited<ReturnType<typeof identity>>,
149
182
  previous?: Build,
183
+ state?: string,
184
+ preset: Preset = PRESET,
150
185
  ) => {
151
186
  invariant(o.ref, "--ref is required to build a commit.");
187
+ const commit = await resolveCommit(source.path, o.ref);
188
+ if (state) {
189
+ const last = await optionalJson<{ artifact: string }>(
190
+ lastBuildPath(state, commit.branch),
191
+ );
192
+ if (last) {
193
+ try {
194
+ previous = await loadBuild(last.artifact);
195
+ } catch (error) {
196
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
197
+ }
198
+ }
199
+ }
152
200
  const parent = join(stateRoot(), "builds");
153
201
  await mkdir(parent, { recursive: true, mode: 0o700 });
154
202
  const path = o.output ? resolve(o.output) : join(parent, randomUUID());
155
203
  return materialize({
156
204
  identity: source,
157
205
  ref: o.ref,
206
+ resolvedCommit: commit,
158
207
  output: path,
159
208
  previous,
209
+ preset,
160
210
  });
161
211
  };
162
212
  if (o.dryRun) {
@@ -166,18 +216,20 @@ cli
166
216
  await validateBuild(b);
167
217
  } else {
168
218
  let source;
219
+ let preset = presetFor(o.embedding);
169
220
  if (o.path) source = await identity(o.path, o.remote);
170
221
  else {
171
222
  invariant(o.repo, "Use --path for a credential-free dry run.");
172
223
  const { remote, settings } = await connected();
173
- source = await attachment(
174
- settings,
175
- await selectRepository(remote, o.repo),
176
- );
224
+ const selected = await selectRepository(remote, o.repo);
225
+ preset = selected.preset ?? PRESET;
226
+ source = await attachment(settings, selected);
177
227
  }
178
228
  b = await buildLocal(
179
229
  source,
180
230
  o.previous ? await loadBuild(o.previous) : undefined,
231
+ undefined,
232
+ preset,
181
233
  );
182
234
  }
183
235
  output({
@@ -212,20 +264,34 @@ cli
212
264
  // Pending attempts reconcile against the journal's immutable remote
213
265
  // baseline; they do not need the previous build's local files.
214
266
  const pending = await optionalJson(join(state, "pending.json"));
267
+ const b = o.artifact
268
+ ? await loadBuild(o.artifact)
269
+ : await buildLocal(
270
+ await attachment(settings, r),
271
+ undefined,
272
+ pending ? undefined : state,
273
+ r.preset ?? PRESET,
274
+ );
215
275
  let baseline: { version: Published; build: Build } | undefined;
216
- if (!pending) {
276
+ // Tracked branches discover their own immutable baseline remotely.
277
+ // Manual SHA/tag imports retain the previous frozen workspace optimization.
278
+ if (!pending && !b.branchRef) {
217
279
  const last = await optionalJson<{
218
280
  artifact: string;
219
281
  version: Published;
220
- }>(join(state, "last-build.json"));
282
+ }>(lastBuildPath(state));
221
283
  if (last) {
222
- const b = await loadBuild(last.artifact);
223
- baseline = { version: last.version, build: b };
284
+ try {
285
+ baseline = {
286
+ version: last.version,
287
+ build: await loadBuild(last.artifact),
288
+ };
289
+ } catch (error) {
290
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
291
+ throw error;
292
+ }
224
293
  }
225
294
  }
226
- const b = o.artifact
227
- ? await loadBuild(o.artifact)
228
- : await buildLocal(await attachment(settings, r), baseline?.build);
229
295
  return publish({
230
296
  store,
231
297
  binding: r,
@@ -249,7 +315,7 @@ cli
249
315
  cli
250
316
  .command("resolve")
251
317
  .requiredOption("--repo <name>")
252
- .requiredOption("--ref <commit-or-release>")
318
+ .requiredOption("--ref <commit-branch-or-release>")
253
319
  .action(async (o) => {
254
320
  const { remote } = await connected();
255
321
  const r = await selectRepository(remote, o.repo);
@@ -258,9 +324,17 @@ cli
258
324
  cli
259
325
  .command("search")
260
326
  .requiredOption("--repo <name>")
261
- .requiredOption("--version <commit-or-release>")
327
+ .requiredOption("--version <commit-branch-or-release>")
262
328
  .requiredOption("--query <text>")
263
329
  .option("--limit <count>", "Maximum hits", integer, 10)
330
+ .addOption(
331
+ new Option(
332
+ "--mode <mode>",
333
+ "Retrieval method; semantic/hybrid incur managed query embedding usage",
334
+ )
335
+ .choices(["lexical", "semantic", "hybrid"])
336
+ .default("lexical"),
337
+ )
264
338
  .option("--path <path>", "Exact path filter")
265
339
  .option("--language <name>")
266
340
  .action(async (o) => {
@@ -269,17 +343,26 @@ cli
269
343
  store = remote.store(r.collection),
270
344
  v = await resolveVersion(store, r, o.version);
271
345
  output(
272
- await search(store, settings, r, v, o.query, o.limit, {
273
- path: o.path,
274
- language: o.language,
275
- }),
346
+ await search(
347
+ store,
348
+ settings,
349
+ r,
350
+ v,
351
+ o.query,
352
+ o.limit,
353
+ {
354
+ path: o.path,
355
+ language: o.language,
356
+ },
357
+ o.mode,
358
+ ),
276
359
  );
277
360
  });
278
361
  cli
279
362
  .command("read")
280
363
  .option("--result <id>")
281
364
  .option("--repo <name>")
282
- .option("--version <commit-or-release>")
365
+ .option("--version <commit-branch-or-release>")
283
366
  .option("--path <path>")
284
367
  .option("--lines <start:end>")
285
368
  .option("--context <lines>", "Expand an evidence span", integer, 0)
package/src/publish.ts CHANGED
@@ -10,7 +10,14 @@ import {
10
10
  sleep,
11
11
  type Doc,
12
12
  } from "./common.js";
13
- import { corpusMetadata, records, validateBuild, type Build } from "./build.js";
13
+ import {
14
+ corpusMetadata,
15
+ records,
16
+ recordHash,
17
+ supportedPreset,
18
+ validateBuild,
19
+ type Build,
20
+ } from "./build.js";
14
21
  import { branch, tag, one, type CollectionStore } from "./remote.js";
15
22
  export type Binding = {
16
23
  repoId: string;
@@ -36,6 +43,8 @@ export type Journal = {
36
43
  attemptId: string;
37
44
  writer: string;
38
45
  source: string;
46
+ gitRef?: string;
47
+ reuseWriter?: boolean;
39
48
  baseline?: Published;
40
49
  phase: "branch" | "writing" | "waiting" | "publishing";
41
50
  batches: {
@@ -45,6 +54,43 @@ export type Journal = {
45
54
  }[];
46
55
  candidate?: { name: string; snapshotId: string };
47
56
  };
57
+ /** The applied snapshot may differ from the shared canonical commit Tag. */
58
+ export type TrackedBranch = Doc & {
59
+ gitRef: string;
60
+ writer: string;
61
+ applied?: Published;
62
+ publishedTagName?: string;
63
+ pending?: { attemptId: string; buildId: string; commitOid: string };
64
+ };
65
+ export function gitBranchName(ref: string): string {
66
+ invariant(
67
+ ref.startsWith("refs/heads/") && ref.length > 11,
68
+ "Expected a full Git branch ref.",
69
+ );
70
+ return `git-${hash(ref).slice(0, 40)}`;
71
+ }
72
+ export const gitBranchId = (ref: string): string => `branch-${hash(ref)}`;
73
+ export const lastBuildPath = (state: string, ref?: string): string =>
74
+ join(state, ref ? `last-build-${hash(ref)}.json` : "last-build.json");
75
+ export async function trackedBranch(
76
+ store: CollectionStore,
77
+ binding: Binding,
78
+ ref: string,
79
+ ): Promise<TrackedBranch | undefined> {
80
+ const doc = await one(store, branch("main"), gitBranchId(ref), true);
81
+ if (!doc) return undefined;
82
+ invariant(
83
+ doc.kind === "manifest" &&
84
+ doc.role === "git-branch" &&
85
+ doc.gitRef === ref &&
86
+ doc.writer === gitBranchName(ref) &&
87
+ doc.repoId === binding.repoId &&
88
+ doc.indexId === binding.indexId &&
89
+ doc.configHash === binding.configHash,
90
+ "Tracked branch identity mismatch.",
91
+ );
92
+ return doc as TrackedBranch;
93
+ }
48
94
  export function versionTag(oid: string): string {
49
95
  return `ver-${hash(oid).slice(0, 40)}`;
50
96
  }
@@ -115,7 +161,11 @@ export async function validateCandidate(
115
161
  for (const d of corpusMetadata(b, binding.repoId, binding.indexId, attemptId))
116
162
  expected.set(d.id, hash(d));
117
163
  for await (const d of store.list(tag(name))) {
118
- if (expected.get(d.id) !== hash(d)) return false;
164
+ try {
165
+ if (expected.get(d.id) !== recordHash(d, b.preset)) return false;
166
+ } catch {
167
+ return false;
168
+ }
119
169
  expected.delete(d.id);
120
170
  }
121
171
  if (expected.size) return false;
@@ -164,7 +214,8 @@ export async function validateCandidate(
164
214
  !found.length ||
165
215
  found.some(
166
216
  (h) =>
167
- h.doc.kind !== "chunk" || b.recordHashes[h.doc.id] !== hash(h.doc),
217
+ h.doc.kind !== "chunk" ||
218
+ b.recordHashes[h.doc.id] !== recordHash(h.doc, b.preset),
168
219
  )
169
220
  )
170
221
  return false;
@@ -245,8 +296,8 @@ export async function publish(args: {
245
296
  "Build does not belong to this index.",
246
297
  );
247
298
  invariant(
248
- !b.preset.embedding,
249
- "CLI publication currently supports only the embedding=none preset.",
299
+ !b.preset.embedding || supportedPreset(b.preset),
300
+ "Publication requires a supported lexical or managed embedding preset.",
250
301
  );
251
302
  await validateBuild(b);
252
303
  const journalPath = join(state, "pending.json");
@@ -255,7 +306,9 @@ export async function publish(args: {
255
306
  invariant(
256
307
  j.buildId === b.buildId &&
257
308
  j.recordsHash === b.recordsHash &&
258
- j.inventoryHash === b.inventoryHash,
309
+ j.inventoryHash === b.inventoryHash &&
310
+ (j.gitRef === b.branchRef ||
311
+ (j.reuseWriter === undefined && j.writer.startsWith("work-"))),
259
312
  "Another build is pending. Resume the artifact recorded in pending.json before importing a new commit.",
260
313
  );
261
314
  invariant(
@@ -266,6 +319,22 @@ export async function publish(args: {
266
319
  const existing = (await published(store, binding)).find(
267
320
  (v) => v.commitOid === b.commitOid,
268
321
  );
322
+ const tracked = b.branchRef
323
+ ? await trackedBranch(store, binding, b.branchRef)
324
+ : undefined;
325
+ if (tracked?.pending)
326
+ invariant(
327
+ j?.attemptId === tracked.pending.attemptId,
328
+ "Git branch has a pending import; resume with its original retry journal.",
329
+ );
330
+ if (j?.gitRef && j.phase !== "branch")
331
+ invariant(
332
+ tracked?.pending?.attemptId === j.attemptId ||
333
+ (j.phase === "publishing" &&
334
+ !tracked?.pending &&
335
+ tracked?.applied?.attemptId === j.attemptId),
336
+ "Tracked branch import ownership changed; refusing to replay this journal.",
337
+ );
269
338
  if (existing) {
270
339
  invariant(
271
340
  await validateCandidate(
@@ -277,17 +346,79 @@ export async function publish(args: {
277
346
  ),
278
347
  "Existing published version differs from this build.",
279
348
  );
280
- await atomic(join(state, "last-build.json"), {
281
- artifact: b.directory,
282
- version: existing,
283
- });
284
- if (j) await rm(journalPath);
285
- return existing;
349
+ // A shared commit Tag does not mean this Git branch has applied that commit.
350
+ if (!j && (!b.branchRef || tracked?.applied?.commitOid === b.commitOid)) {
351
+ if (tracked?.applied) {
352
+ const writer = (await store.branches()).find(
353
+ (x) => x.name === tracked.writer,
354
+ );
355
+ invariant(
356
+ writer?.snapshotId === tracked.applied.snapshotId,
357
+ "Writer baseline has changed; refusing to update tracked branch.",
358
+ );
359
+ invariant(
360
+ await validateCandidate(
361
+ store,
362
+ tracked.applied.tagName,
363
+ b,
364
+ binding,
365
+ tracked.applied.attemptId,
366
+ ),
367
+ "Tracked branch baseline validation failed.",
368
+ );
369
+ }
370
+ await atomic(lastBuildPath(state, b.branchRef), {
371
+ artifact: b.directory,
372
+ version: existing,
373
+ });
374
+ return existing;
375
+ }
376
+ // Legacy manual imports can finish bookkeeping after publication on retry.
377
+ if (j && !j.gitRef) {
378
+ await atomic(lastBuildPath(state), {
379
+ artifact: b.directory,
380
+ version: existing,
381
+ });
382
+ await rm(journalPath);
383
+ return existing;
384
+ }
286
385
  }
287
386
  if (!j) {
288
387
  let source = "checkpoint-empty";
289
388
  let baseline: Published | undefined;
290
- if (args.baseline) {
389
+ if (b.branchRef) {
390
+ const writer = (await store.branches()).find(
391
+ (x) => x.name === gitBranchName(b.branchRef!),
392
+ );
393
+ if (tracked?.applied) {
394
+ baseline = tracked.applied;
395
+ invariant(
396
+ writer?.snapshotId === baseline.snapshotId,
397
+ "Writer baseline has changed; refusing to update tracked branch.",
398
+ );
399
+ const pin = (await store.tags()).find(
400
+ (t) => t.name === baseline!.tagName,
401
+ );
402
+ const root = await one(store, tag(baseline.tagName), "__manifest__");
403
+ invariant(
404
+ pin?.snapshotId === baseline.snapshotId &&
405
+ root?.repoId === binding.repoId &&
406
+ root.indexId === binding.indexId &&
407
+ root.configHash === binding.configHash &&
408
+ root.buildId === baseline.buildId &&
409
+ root.attemptId === baseline.attemptId &&
410
+ root.recordsHash === baseline.recordsHash &&
411
+ root.inventoryHash === baseline.inventoryHash &&
412
+ root.commitOid === baseline.commitOid,
413
+ "Tracked branch baseline Tag is missing or changed.",
414
+ );
415
+ } else {
416
+ invariant(
417
+ !writer && !tracked,
418
+ "Unowned or unfinished tracked branch; retain its retry journal.",
419
+ );
420
+ }
421
+ } else if (args.baseline?.version.writer.startsWith("work-")) {
291
422
  const prev = args.baseline;
292
423
  await validateBuild(prev.build);
293
424
  invariant(
@@ -322,7 +453,9 @@ export async function publish(args: {
322
453
  inventoryHash: b.inventoryHash,
323
454
  artifact: b.directory,
324
455
  attemptId,
325
- writer: `work-${attemptId}`,
456
+ writer: b.branchRef ? gitBranchName(b.branchRef) : `work-${attemptId}`,
457
+ gitRef: b.branchRef,
458
+ reuseWriter: !!tracked?.applied,
326
459
  source,
327
460
  baseline,
328
461
  phase: "branch",
@@ -332,15 +465,48 @@ export async function publish(args: {
332
465
  }
333
466
  const save = () => atomic(journalPath, j);
334
467
  if (j.phase === "branch") {
468
+ if (j.gitRef) {
469
+ // Record intent before any corpus mutation, including before branch creation.
470
+ // A fresh local state must not treat a lagging, partially written head as clean.
471
+ const current = await trackedBranch(store, binding, j.gitRef);
472
+ invariant(
473
+ (!current?.pending || current.pending.attemptId === j.attemptId) &&
474
+ current?.applied?.snapshotId === j.baseline?.snapshotId,
475
+ "Git branch ownership or baseline changed before import.",
476
+ );
477
+ await store.upsert("main", [
478
+ {
479
+ ...current,
480
+ id: gitBranchId(j.gitRef),
481
+ kind: "manifest",
482
+ role: "git-branch",
483
+ schemaVersion: 1,
484
+ repoId: binding.repoId,
485
+ indexId: binding.indexId,
486
+ configHash: binding.configHash,
487
+ gitRef: j.gitRef,
488
+ writer: j.writer,
489
+ pending: {
490
+ attemptId: j.attemptId,
491
+ buildId: j.buildId,
492
+ commitOid: b.commitOid,
493
+ },
494
+ },
495
+ ]);
496
+ }
335
497
  const branches = await store.branches();
336
498
  const found = branches.find((x) => x.name === j!.writer);
337
499
  if (found)
338
500
  invariant(
339
- found.parent === j.source &&
501
+ (j.reuseWriter || found.parent === j.source) &&
340
502
  found.snapshotId === (j.baseline?.snapshotId ?? null),
341
503
  "Partially created writer has an unexpected baseline.",
342
504
  );
343
505
  else {
506
+ invariant(
507
+ !j.reuseWriter,
508
+ "Tracked writer is missing; refusing to recreate a nonempty baseline.",
509
+ );
344
510
  const source = branches.find((x) => x.name === j!.source);
345
511
  invariant(
346
512
  source &&
@@ -368,7 +534,7 @@ export async function publish(args: {
368
534
  if (j.baseline)
369
535
  for await (const d of store.list(tag(j.baseline.tagName))) {
370
536
  oldIds.add(d.id);
371
- oldHashes.set(d.id, hash(d));
537
+ oldHashes.set(d.id, recordHash(d, b.preset));
372
538
  }
373
539
  const newIds = new Set([
374
540
  ...Object.keys(b.recordHashes),
@@ -454,10 +620,12 @@ export async function publish(args: {
454
620
  const found = (await store.tags()).find((t) => t.name === name);
455
621
  const final = found ?? (await store.tag(name, tag(j.candidate.name)));
456
622
  invariant(
457
- final.snapshotId === j.candidate.snapshotId,
623
+ existing
624
+ ? final.snapshotId === existing.snapshotId
625
+ : final.snapshotId === j.candidate.snapshotId,
458
626
  "Published Tag name already points to a different snapshot.",
459
627
  );
460
- const result: Published = {
628
+ const result: Published = existing ?? {
461
629
  id: versionId(b.commitOid),
462
630
  kind: "manifest",
463
631
  role: "published",
@@ -477,8 +645,42 @@ export async function publish(args: {
477
645
  counts: b.counts,
478
646
  validation: { inventory: true, content: true, query: true },
479
647
  };
480
- await store.upsert("main", [result]);
481
- await atomic(join(state, "last-build.json"), {
648
+ if (!existing) await store.upsert("main", [result]);
649
+ if (j.gitRef) {
650
+ const current = await trackedBranch(store, binding, j.gitRef);
651
+ invariant(
652
+ current?.pending?.attemptId === j.attemptId ||
653
+ (!current?.pending && current?.applied?.attemptId === j.attemptId),
654
+ "Tracked branch publication ownership changed.",
655
+ );
656
+ // Pin the exact writer baseline even when another branch published this commit first.
657
+ const applied: Published = {
658
+ ...result,
659
+ writer: j.writer,
660
+ snapshotId: j.candidate.snapshotId,
661
+ tagName:
662
+ final.snapshotId === j.candidate.snapshotId
663
+ ? final.name
664
+ : j.candidate.name,
665
+ attemptId: j.attemptId,
666
+ };
667
+ await store.upsert("main", [
668
+ {
669
+ id: gitBranchId(j.gitRef),
670
+ kind: "manifest",
671
+ role: "git-branch",
672
+ schemaVersion: 1,
673
+ repoId: binding.repoId,
674
+ indexId: binding.indexId,
675
+ configHash: binding.configHash,
676
+ gitRef: j.gitRef,
677
+ writer: j.writer,
678
+ applied,
679
+ publishedTagName: result.tagName,
680
+ },
681
+ ]);
682
+ }
683
+ await atomic(lastBuildPath(state, j.gitRef), {
482
684
  artifact: b.directory,
483
685
  version: result,
484
686
  });
package/src/releases.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { hash, invariant, type Doc } from "./common.js";
2
2
  import { gitTags } from "./git.js";
3
3
  import { one, branch, type CollectionStore } from "./remote.js";
4
- import { published, type Binding, type Published } from "./publish.js";
4
+ import {
5
+ published,
6
+ trackedBranch,
7
+ type Binding,
8
+ type Published,
9
+ } from "./publish.js";
5
10
  export const releaseAlias = (ref: string) => `rel-${hash(ref).slice(0, 40)}`;
6
11
  export const releaseId = (ref: string) => `ref-${hash(ref)}`;
7
12
  /** Only observed local tags. Absence is not deletion authority; this command never prunes. */
@@ -58,11 +63,39 @@ export async function resolveVersion(
58
63
  binding: Binding,
59
64
  selector: string,
60
65
  ): Promise<Published> {
61
- const versions = await published(store, binding);
62
66
  const ref = selector.startsWith("refs/tags/")
63
67
  ? selector
64
68
  : `refs/tags/${selector}`;
65
- const control = await one(store, branch("main"), releaseId(ref), true);
69
+ const control = selector.startsWith("refs/heads/")
70
+ ? undefined
71
+ : await one(store, branch("main"), releaseId(ref), true);
72
+ const headRef = selector.startsWith("refs/heads/")
73
+ ? selector
74
+ : `refs/heads/${selector}`;
75
+ const head =
76
+ !selector.startsWith("refs/") || selector.startsWith("refs/heads/")
77
+ ? await trackedBranch(store, binding, headRef)
78
+ : undefined;
79
+ invariant(
80
+ !(head && control),
81
+ "Ambiguous branch/tag name: use refs/heads/... or refs/tags/...",
82
+ );
83
+ // Pin moving ref control before listing versions. Otherwise publication can
84
+ // advance the ref to a version absent from the earlier list.
85
+ const versions = await published(store, binding);
86
+ if (head) {
87
+ invariant(
88
+ head.applied,
89
+ "Git branch has no published version yet; finish its import.",
90
+ );
91
+ const result = versions.find(
92
+ (v) =>
93
+ v.commitOid === head.applied!.commitOid &&
94
+ v.tagName === head.publishedTagName,
95
+ );
96
+ invariant(result, "Git branch target is not a published version.");
97
+ return result;
98
+ }
66
99
  // Like Git import, a known tag name takes precedence over an OID spelling.
67
100
  // Its pending or invalid state must not fall back to an unrelated commit.
68
101
  if (!control && !selector.startsWith("refs/")) {