@functional-systems/srcx 0.1.0-dev.4 → 0.1.0-dev.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@functional-systems/srcx",
3
- "version": "0.1.0-dev.4",
3
+ "version": "0.1.0-dev.6",
4
4
  "private": false,
5
5
  "description": "Version-aware code search on LambdaDB",
6
6
  "type": "module",
@@ -28,11 +28,14 @@
28
28
  "start": "node dist/cli.js",
29
29
  "demo": "npm run build && node scripts/demo.mjs",
30
30
  "prepack": "npm run build",
31
- "format": "prettier --write src test scripts README.md VALIDATION.md CONTRIBUTING.md RELEASING.md CHANGELOG.md .github package.json tsconfig.json",
32
- "format:check": "prettier --check src test scripts README.md VALIDATION.md CONTRIBUTING.md RELEASING.md CHANGELOG.md .github package.json tsconfig.json",
31
+ "format": "prettier --write src test scripts eval README.md VALIDATION.md CONTRIBUTING.md RELEASING.md CHANGELOG.md .github package.json tsconfig.json",
32
+ "format:check": "prettier --check src test scripts eval README.md VALIDATION.md CONTRIBUTING.md RELEASING.md CHANGELOG.md .github package.json tsconfig.json",
33
33
  "test:live": "npm run build && node --env-file=.env.local scripts/live.mjs",
34
34
  "check:version": "node scripts/check-release.mjs",
35
- "test:package": "node scripts/test-package.mjs"
35
+ "test:package": "node scripts/test-package.mjs",
36
+ "test:live:branches": "npm run build && node --env-file=.env.local scripts/live-branches.mjs",
37
+ "eval:prepare": "npm run build && node scripts/retrieval-eval.mjs prepare",
38
+ "eval:run": "node scripts/retrieval-eval.mjs run"
36
39
  },
37
40
  "dependencies": {
38
41
  "@functional-systems/lambdadb": "0.5.1",
@@ -55,5 +58,5 @@
55
58
  "url": "https://github.com/lambdadb/srcx/issues"
56
59
  },
57
60
  "license": "Apache-2.0",
58
- "gitHead": "9612cf3c342dbcf898b0ed8ca1526c0c3a04648f"
61
+ "gitHead": "8c0d1656d4d66a6461a32e9072b2e5f7fda9cec1"
59
62
  }
package/src/build.ts CHANGED
@@ -27,6 +27,8 @@ export type Preset = {
27
27
  schemaVersion: 1;
28
28
  chunker: typeof CHUNKER;
29
29
  mode: "syntax" | "window";
30
+ /** Internal evaluation override; the CLI keeps its original enrichment. */
31
+ enrichment?: "path-only-v1";
30
32
  maxFileBytes: number;
31
33
  embedding: { model: string; dimensions: number } | null;
32
34
  };
@@ -123,6 +125,7 @@ function exclude(path: string): string | undefined {
123
125
  export async function materialize(args: {
124
126
  identity: Identity;
125
127
  ref: string;
128
+ resolvedCommit?: Awaited<ReturnType<typeof resolveCommit>>;
126
129
  output: string;
127
130
  previous?: Build;
128
131
  preset?: Preset;
@@ -135,6 +138,8 @@ export async function materialize(args: {
135
138
  invariant(
136
139
  hash(preset.chunker) === hash(CHUNKER) &&
137
140
  ["syntax", "window"].includes(preset.mode) &&
141
+ (preset.enrichment === undefined ||
142
+ preset.enrichment === "path-only-v1") &&
138
143
  preset.maxFileBytes === PRESET.maxFileBytes,
139
144
  "Unsupported build preset.",
140
145
  );
@@ -151,7 +156,8 @@ export async function materialize(args: {
151
156
  args.embedder.dimensions === preset.embedding.dimensions),
152
157
  "Embedding provider must match the pinned preset.",
153
158
  );
154
- const commit = await resolveCommit(args.identity.path, args.ref);
159
+ const commit =
160
+ args.resolvedCommit ?? (await resolveCommit(args.identity.path, args.ref));
155
161
  const directory = resolve(args.output);
156
162
  await mkdir(directory, { recursive: false, mode: 0o700 });
157
163
  const output = await open(join(directory, "records.jsonl"), "wx", 0o600);
@@ -233,7 +239,12 @@ export async function materialize(args: {
233
239
  item.reason = "lfs-pointer";
234
240
  continue;
235
241
  }
236
- const parsed = await chunk(source, e.path, preset.mode);
242
+ const parsed = await chunk(
243
+ source,
244
+ e.path,
245
+ preset.mode,
246
+ preset.enrichment,
247
+ );
237
248
  const contentHash = hash(data);
238
249
  const fileId = `f-${hash([e.pathBase64, contentHash])}`;
239
250
  const base = { schemaVersion: 1, configHash };
package/src/chunk.ts CHANGED
@@ -75,7 +75,9 @@ export function detectLanguage(path: string): string {
75
75
  )[ext ?? ""] ?? "text"
76
76
  );
77
77
  }
78
- function enrichment(path: string, u: Unit): string {
78
+ export type Enrichment = "path-scope-symbol-v1" | "path-only-v1";
79
+ function enrichment(path: string, u: Unit, policy: Enrichment): string {
80
+ if (policy === "path-only-v1") return path.slice(0, 240) + "\n";
79
81
  return (
80
82
  [path.slice(0, 240), u.scope?.slice(0, 160), u.symbol?.slice(0, 160)]
81
83
  .filter(Boolean)
@@ -106,10 +108,11 @@ function splitUnit(
106
108
  path: string,
107
109
  u: Unit,
108
110
  overlap: number,
111
+ policy: Enrichment,
109
112
  ): Unit[] {
110
113
  const result: Unit[] = [];
111
114
  let start = u.start;
112
- const prefix = enrichment(path, u);
115
+ const prefix = enrichment(path, u, policy);
113
116
  invariant(
114
117
  tokens(prefix) < CHUNKER.maxTokens,
115
118
  "Path/scope enrichment exceeds token limit.",
@@ -185,6 +188,7 @@ export async function chunk(
185
188
  source: string,
186
189
  path: string,
187
190
  mode: "syntax" | "window" = "syntax",
191
+ policy: Enrichment = CHUNKER.enrichment,
188
192
  ): Promise<{ spans: Span[]; parseStatus: string; language: string }> {
189
193
  const lang = detectLanguage(path);
190
194
  if (!source.length)
@@ -224,7 +228,7 @@ export async function chunk(
224
228
  if (
225
229
  n.namedChildCount &&
226
230
  (container ||
227
- tokens(enrichment(path, u) + n.text) > CHUNKER.maxTokens)
231
+ tokens(enrichment(path, u, policy) + n.text) > CHUNKER.maxTokens)
228
232
  ) {
229
233
  for (const child of n.namedChildren) if (child) visit(child);
230
234
  } else if (n.endIndex > n.startIndex) units.push(u);
@@ -271,8 +275,9 @@ export async function chunk(
271
275
  prev.kind !== "function" &&
272
276
  u.kind === prev.kind &&
273
277
  u.scope === prev.scope &&
274
- tokens(enrichment(path, prev) + source.slice(prev.start, u.end)) <=
275
- CHUNKER.targetTokens
278
+ tokens(
279
+ enrichment(path, prev, policy) + source.slice(prev.start, u.end),
280
+ ) <= CHUNKER.targetTokens
276
281
  ) {
277
282
  prev.end = u.end;
278
283
  delete prev.symbol;
@@ -280,7 +285,7 @@ export async function chunk(
280
285
  } else merged.push({ ...u });
281
286
  }
282
287
  const bounded = merged.flatMap((u) =>
283
- splitUnit(source, path, u, fallback ? CHUNKER.fallbackOverlap : 0),
288
+ splitUnit(source, path, u, fallback ? CHUNKER.fallbackOverlap : 0, policy),
284
289
  );
285
290
  const offsets = new Uint32Array(source.length + 1);
286
291
  let byte = 0;
@@ -298,7 +303,7 @@ export async function chunk(
298
303
  const startByte = offsets[u.start]!,
299
304
  endByte = offsets[u.end]!;
300
305
  const text = source.slice(u.start, u.end);
301
- const searchText = enrichment(path, u) + text;
306
+ const searchText = enrichment(path, u, policy) + text;
302
307
  const kind = text.trim() === "" ? "structural" : u.kind;
303
308
  return {
304
309
  startByte,
package/src/cli.ts CHANGED
@@ -5,7 +5,7 @@ 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";
8
+ import { identity, resolveCommit } from "./git.js";
9
9
  import { loadBuild, materialize, validateBuild, type Build } from "./build.js";
10
10
  import { invariant, optionalJson } from "./common.js";
11
11
  import { LambdaRemote } from "./remote.js";
@@ -16,7 +16,13 @@ import {
16
16
  register,
17
17
  selectRepository,
18
18
  } from "./repository.js";
19
- import { exclusive, publish, published, type Published } from "./publish.js";
19
+ import {
20
+ exclusive,
21
+ lastBuildPath,
22
+ publish,
23
+ published,
24
+ type Published,
25
+ } from "./publish.js";
20
26
  import { resolveVersion, syncTags } from "./releases.js";
21
27
  import { directHandle, loadHandle, readHandle, search } from "./search.js";
22
28
  const cli = new Command()
@@ -147,14 +153,29 @@ cli
147
153
  const buildLocal = async (
148
154
  source: Awaited<ReturnType<typeof identity>>,
149
155
  previous?: Build,
156
+ state?: string,
150
157
  ) => {
151
158
  invariant(o.ref, "--ref is required to build a commit.");
159
+ const commit = await resolveCommit(source.path, o.ref);
160
+ if (state) {
161
+ const last = await optionalJson<{ artifact: string }>(
162
+ lastBuildPath(state, commit.branch),
163
+ );
164
+ if (last) {
165
+ try {
166
+ previous = await loadBuild(last.artifact);
167
+ } catch (error) {
168
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
169
+ }
170
+ }
171
+ }
152
172
  const parent = join(stateRoot(), "builds");
153
173
  await mkdir(parent, { recursive: true, mode: 0o700 });
154
174
  const path = o.output ? resolve(o.output) : join(parent, randomUUID());
155
175
  return materialize({
156
176
  identity: source,
157
177
  ref: o.ref,
178
+ resolvedCommit: commit,
158
179
  output: path,
159
180
  previous,
160
181
  });
@@ -212,20 +233,33 @@ cli
212
233
  // Pending attempts reconcile against the journal's immutable remote
213
234
  // baseline; they do not need the previous build's local files.
214
235
  const pending = await optionalJson(join(state, "pending.json"));
236
+ const b = o.artifact
237
+ ? await loadBuild(o.artifact)
238
+ : await buildLocal(
239
+ await attachment(settings, r),
240
+ undefined,
241
+ pending ? undefined : state,
242
+ );
215
243
  let baseline: { version: Published; build: Build } | undefined;
216
- if (!pending) {
244
+ // Tracked branches discover their own immutable baseline remotely.
245
+ // Manual SHA/tag imports retain the previous frozen workspace optimization.
246
+ if (!pending && !b.branchRef) {
217
247
  const last = await optionalJson<{
218
248
  artifact: string;
219
249
  version: Published;
220
- }>(join(state, "last-build.json"));
250
+ }>(lastBuildPath(state));
221
251
  if (last) {
222
- const b = await loadBuild(last.artifact);
223
- baseline = { version: last.version, build: b };
252
+ try {
253
+ baseline = {
254
+ version: last.version,
255
+ build: await loadBuild(last.artifact),
256
+ };
257
+ } catch (error) {
258
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
259
+ throw error;
260
+ }
224
261
  }
225
262
  }
226
- const b = o.artifact
227
- ? await loadBuild(o.artifact)
228
- : await buildLocal(await attachment(settings, r), baseline?.build);
229
263
  return publish({
230
264
  store,
231
265
  binding: r,
@@ -249,7 +283,7 @@ cli
249
283
  cli
250
284
  .command("resolve")
251
285
  .requiredOption("--repo <name>")
252
- .requiredOption("--ref <commit-or-release>")
286
+ .requiredOption("--ref <commit-branch-or-release>")
253
287
  .action(async (o) => {
254
288
  const { remote } = await connected();
255
289
  const r = await selectRepository(remote, o.repo);
@@ -258,7 +292,7 @@ cli
258
292
  cli
259
293
  .command("search")
260
294
  .requiredOption("--repo <name>")
261
- .requiredOption("--version <commit-or-release>")
295
+ .requiredOption("--version <commit-branch-or-release>")
262
296
  .requiredOption("--query <text>")
263
297
  .option("--limit <count>", "Maximum hits", integer, 10)
264
298
  .option("--path <path>", "Exact path filter")
@@ -279,7 +313,7 @@ cli
279
313
  .command("read")
280
314
  .option("--result <id>")
281
315
  .option("--repo <name>")
282
- .option("--version <commit-or-release>")
316
+ .option("--version <commit-branch-or-release>")
283
317
  .option("--path <path>")
284
318
  .option("--lines <start:end>")
285
319
  .option("--context <lines>", "Expand an evidence span", integer, 0)
package/src/publish.ts CHANGED
@@ -36,6 +36,8 @@ export type Journal = {
36
36
  attemptId: string;
37
37
  writer: string;
38
38
  source: string;
39
+ gitRef?: string;
40
+ reuseWriter?: boolean;
39
41
  baseline?: Published;
40
42
  phase: "branch" | "writing" | "waiting" | "publishing";
41
43
  batches: {
@@ -45,6 +47,43 @@ export type Journal = {
45
47
  }[];
46
48
  candidate?: { name: string; snapshotId: string };
47
49
  };
50
+ /** The applied snapshot may differ from the shared canonical commit Tag. */
51
+ export type TrackedBranch = Doc & {
52
+ gitRef: string;
53
+ writer: string;
54
+ applied?: Published;
55
+ publishedTagName?: string;
56
+ pending?: { attemptId: string; buildId: string; commitOid: string };
57
+ };
58
+ export function gitBranchName(ref: string): string {
59
+ invariant(
60
+ ref.startsWith("refs/heads/") && ref.length > 11,
61
+ "Expected a full Git branch ref.",
62
+ );
63
+ return `git-${hash(ref).slice(0, 40)}`;
64
+ }
65
+ export const gitBranchId = (ref: string): string => `branch-${hash(ref)}`;
66
+ export const lastBuildPath = (state: string, ref?: string): string =>
67
+ join(state, ref ? `last-build-${hash(ref)}.json` : "last-build.json");
68
+ export async function trackedBranch(
69
+ store: CollectionStore,
70
+ binding: Binding,
71
+ ref: string,
72
+ ): Promise<TrackedBranch | undefined> {
73
+ const doc = await one(store, branch("main"), gitBranchId(ref), true);
74
+ if (!doc) return undefined;
75
+ invariant(
76
+ doc.kind === "manifest" &&
77
+ doc.role === "git-branch" &&
78
+ doc.gitRef === ref &&
79
+ doc.writer === gitBranchName(ref) &&
80
+ doc.repoId === binding.repoId &&
81
+ doc.indexId === binding.indexId &&
82
+ doc.configHash === binding.configHash,
83
+ "Tracked branch identity mismatch.",
84
+ );
85
+ return doc as TrackedBranch;
86
+ }
48
87
  export function versionTag(oid: string): string {
49
88
  return `ver-${hash(oid).slice(0, 40)}`;
50
89
  }
@@ -255,7 +294,9 @@ export async function publish(args: {
255
294
  invariant(
256
295
  j.buildId === b.buildId &&
257
296
  j.recordsHash === b.recordsHash &&
258
- j.inventoryHash === b.inventoryHash,
297
+ j.inventoryHash === b.inventoryHash &&
298
+ (j.gitRef === b.branchRef ||
299
+ (j.reuseWriter === undefined && j.writer.startsWith("work-"))),
259
300
  "Another build is pending. Resume the artifact recorded in pending.json before importing a new commit.",
260
301
  );
261
302
  invariant(
@@ -266,6 +307,22 @@ export async function publish(args: {
266
307
  const existing = (await published(store, binding)).find(
267
308
  (v) => v.commitOid === b.commitOid,
268
309
  );
310
+ const tracked = b.branchRef
311
+ ? await trackedBranch(store, binding, b.branchRef)
312
+ : undefined;
313
+ if (tracked?.pending)
314
+ invariant(
315
+ j?.attemptId === tracked.pending.attemptId,
316
+ "Git branch has a pending import; resume with its original retry journal.",
317
+ );
318
+ if (j?.gitRef && j.phase !== "branch")
319
+ invariant(
320
+ tracked?.pending?.attemptId === j.attemptId ||
321
+ (j.phase === "publishing" &&
322
+ !tracked?.pending &&
323
+ tracked?.applied?.attemptId === j.attemptId),
324
+ "Tracked branch import ownership changed; refusing to replay this journal.",
325
+ );
269
326
  if (existing) {
270
327
  invariant(
271
328
  await validateCandidate(
@@ -277,17 +334,79 @@ export async function publish(args: {
277
334
  ),
278
335
  "Existing published version differs from this build.",
279
336
  );
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;
337
+ // A shared commit Tag does not mean this Git branch has applied that commit.
338
+ if (!j && (!b.branchRef || tracked?.applied?.commitOid === b.commitOid)) {
339
+ if (tracked?.applied) {
340
+ const writer = (await store.branches()).find(
341
+ (x) => x.name === tracked.writer,
342
+ );
343
+ invariant(
344
+ writer?.snapshotId === tracked.applied.snapshotId,
345
+ "Writer baseline has changed; refusing to update tracked branch.",
346
+ );
347
+ invariant(
348
+ await validateCandidate(
349
+ store,
350
+ tracked.applied.tagName,
351
+ b,
352
+ binding,
353
+ tracked.applied.attemptId,
354
+ ),
355
+ "Tracked branch baseline validation failed.",
356
+ );
357
+ }
358
+ await atomic(lastBuildPath(state, b.branchRef), {
359
+ artifact: b.directory,
360
+ version: existing,
361
+ });
362
+ return existing;
363
+ }
364
+ // Legacy manual imports can finish bookkeeping after publication on retry.
365
+ if (j && !j.gitRef) {
366
+ await atomic(lastBuildPath(state), {
367
+ artifact: b.directory,
368
+ version: existing,
369
+ });
370
+ await rm(journalPath);
371
+ return existing;
372
+ }
286
373
  }
287
374
  if (!j) {
288
375
  let source = "checkpoint-empty";
289
376
  let baseline: Published | undefined;
290
- if (args.baseline) {
377
+ if (b.branchRef) {
378
+ const writer = (await store.branches()).find(
379
+ (x) => x.name === gitBranchName(b.branchRef!),
380
+ );
381
+ if (tracked?.applied) {
382
+ baseline = tracked.applied;
383
+ invariant(
384
+ writer?.snapshotId === baseline.snapshotId,
385
+ "Writer baseline has changed; refusing to update tracked branch.",
386
+ );
387
+ const pin = (await store.tags()).find(
388
+ (t) => t.name === baseline!.tagName,
389
+ );
390
+ const root = await one(store, tag(baseline.tagName), "__manifest__");
391
+ invariant(
392
+ pin?.snapshotId === baseline.snapshotId &&
393
+ root?.repoId === binding.repoId &&
394
+ root.indexId === binding.indexId &&
395
+ root.configHash === binding.configHash &&
396
+ root.buildId === baseline.buildId &&
397
+ root.attemptId === baseline.attemptId &&
398
+ root.recordsHash === baseline.recordsHash &&
399
+ root.inventoryHash === baseline.inventoryHash &&
400
+ root.commitOid === baseline.commitOid,
401
+ "Tracked branch baseline Tag is missing or changed.",
402
+ );
403
+ } else {
404
+ invariant(
405
+ !writer && !tracked,
406
+ "Unowned or unfinished tracked branch; retain its retry journal.",
407
+ );
408
+ }
409
+ } else if (args.baseline?.version.writer.startsWith("work-")) {
291
410
  const prev = args.baseline;
292
411
  await validateBuild(prev.build);
293
412
  invariant(
@@ -322,7 +441,9 @@ export async function publish(args: {
322
441
  inventoryHash: b.inventoryHash,
323
442
  artifact: b.directory,
324
443
  attemptId,
325
- writer: `work-${attemptId}`,
444
+ writer: b.branchRef ? gitBranchName(b.branchRef) : `work-${attemptId}`,
445
+ gitRef: b.branchRef,
446
+ reuseWriter: !!tracked?.applied,
326
447
  source,
327
448
  baseline,
328
449
  phase: "branch",
@@ -332,15 +453,48 @@ export async function publish(args: {
332
453
  }
333
454
  const save = () => atomic(journalPath, j);
334
455
  if (j.phase === "branch") {
456
+ if (j.gitRef) {
457
+ // Record intent before any corpus mutation, including before branch creation.
458
+ // A fresh local state must not treat a lagging, partially written head as clean.
459
+ const current = await trackedBranch(store, binding, j.gitRef);
460
+ invariant(
461
+ (!current?.pending || current.pending.attemptId === j.attemptId) &&
462
+ current?.applied?.snapshotId === j.baseline?.snapshotId,
463
+ "Git branch ownership or baseline changed before import.",
464
+ );
465
+ await store.upsert("main", [
466
+ {
467
+ ...current,
468
+ id: gitBranchId(j.gitRef),
469
+ kind: "manifest",
470
+ role: "git-branch",
471
+ schemaVersion: 1,
472
+ repoId: binding.repoId,
473
+ indexId: binding.indexId,
474
+ configHash: binding.configHash,
475
+ gitRef: j.gitRef,
476
+ writer: j.writer,
477
+ pending: {
478
+ attemptId: j.attemptId,
479
+ buildId: j.buildId,
480
+ commitOid: b.commitOid,
481
+ },
482
+ },
483
+ ]);
484
+ }
335
485
  const branches = await store.branches();
336
486
  const found = branches.find((x) => x.name === j!.writer);
337
487
  if (found)
338
488
  invariant(
339
- found.parent === j.source &&
489
+ (j.reuseWriter || found.parent === j.source) &&
340
490
  found.snapshotId === (j.baseline?.snapshotId ?? null),
341
491
  "Partially created writer has an unexpected baseline.",
342
492
  );
343
493
  else {
494
+ invariant(
495
+ !j.reuseWriter,
496
+ "Tracked writer is missing; refusing to recreate a nonempty baseline.",
497
+ );
344
498
  const source = branches.find((x) => x.name === j!.source);
345
499
  invariant(
346
500
  source &&
@@ -454,10 +608,12 @@ export async function publish(args: {
454
608
  const found = (await store.tags()).find((t) => t.name === name);
455
609
  const final = found ?? (await store.tag(name, tag(j.candidate.name)));
456
610
  invariant(
457
- final.snapshotId === j.candidate.snapshotId,
611
+ existing
612
+ ? final.snapshotId === existing.snapshotId
613
+ : final.snapshotId === j.candidate.snapshotId,
458
614
  "Published Tag name already points to a different snapshot.",
459
615
  );
460
- const result: Published = {
616
+ const result: Published = existing ?? {
461
617
  id: versionId(b.commitOid),
462
618
  kind: "manifest",
463
619
  role: "published",
@@ -477,8 +633,42 @@ export async function publish(args: {
477
633
  counts: b.counts,
478
634
  validation: { inventory: true, content: true, query: true },
479
635
  };
480
- await store.upsert("main", [result]);
481
- await atomic(join(state, "last-build.json"), {
636
+ if (!existing) await store.upsert("main", [result]);
637
+ if (j.gitRef) {
638
+ const current = await trackedBranch(store, binding, j.gitRef);
639
+ invariant(
640
+ current?.pending?.attemptId === j.attemptId ||
641
+ (!current?.pending && current?.applied?.attemptId === j.attemptId),
642
+ "Tracked branch publication ownership changed.",
643
+ );
644
+ // Pin the exact writer baseline even when another branch published this commit first.
645
+ const applied: Published = {
646
+ ...result,
647
+ writer: j.writer,
648
+ snapshotId: j.candidate.snapshotId,
649
+ tagName:
650
+ final.snapshotId === j.candidate.snapshotId
651
+ ? final.name
652
+ : j.candidate.name,
653
+ attemptId: j.attemptId,
654
+ };
655
+ await store.upsert("main", [
656
+ {
657
+ id: gitBranchId(j.gitRef),
658
+ kind: "manifest",
659
+ role: "git-branch",
660
+ schemaVersion: 1,
661
+ repoId: binding.repoId,
662
+ indexId: binding.indexId,
663
+ configHash: binding.configHash,
664
+ gitRef: j.gitRef,
665
+ writer: j.writer,
666
+ applied,
667
+ publishedTagName: result.tagName,
668
+ },
669
+ ]);
670
+ }
671
+ await atomic(lastBuildPath(state, j.gitRef), {
482
672
  artifact: b.directory,
483
673
  version: result,
484
674
  });
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/")) {