@nseng-ai/branch-context 0.1.1

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.
@@ -0,0 +1,495 @@
1
+ import type { BranchContextAttachData } from "./branch-memory.ts";
2
+ import { assertBrmemEntryAbsent, attachBranchContext, AttachBranchContextError } from "./attach.ts";
3
+ import { BRANCH_CONTEXT_NAMESPACE, buildBranchContextPlanKey } from "./constants.ts";
4
+ import type { BrmemGateway } from "@nseng-ai/brmem";
5
+ import type { GraphiteBranchGateway } from "@nseng-ai/capability-kit/graphite/branch";
6
+ import { type CommandExecApi, formatCommand } from "@nseng-ai/foundation/exec";
7
+ import type { GitGateway } from "@nseng-ai/capability-kit/git";
8
+ import { formatErrorMessage, optionalEntry } from "@nseng-ai/foundation/primitives";
9
+ import { normalizeSummary, resolvePlanSourceFile } from "@nseng-ai/plans";
10
+ import type { BranchContextContext } from "./context.ts";
11
+
12
+ export { BRANCH_CONTEXT_NAMESPACE, buildBranchContextPlanKey } from "./constants.ts";
13
+
14
+ const MAX_ERROR_CHARS = 4_000;
15
+
16
+ export const BRANCH_CREATION_METHODS = ["plain-git", "graphite"] as const;
17
+ export type BranchCreationMethod = (typeof BRANCH_CREATION_METHODS)[number];
18
+ export const DEFAULT_BRANCH_CREATION_METHOD: BranchCreationMethod = "plain-git";
19
+
20
+ export function describeBranchContextGraphiteCreationSteps(parentBranch: string): string {
21
+ return `Branch-context Graphite branch creation is \`git branch <target> HEAD\` plus \`gt track <target> --parent ${parentBranch} --no-interactive\`, not \`gt create\`.`;
22
+ }
23
+
24
+ export interface CreateBranchContextFromFileParams {
25
+ slug: string;
26
+ filePath: string;
27
+ branchName?: string;
28
+ branchCreation?: BranchCreationMethod;
29
+ summary?: string;
30
+ }
31
+
32
+ export interface CreateBranchContextFromFileOptions {
33
+ cwd: string;
34
+ context: BranchContextContext;
35
+ signal?: AbortSignal;
36
+ }
37
+
38
+ export interface BranchContextEvidence {
39
+ slug: string;
40
+ branch: string;
41
+ branchCreation: BranchCreationMethod;
42
+ startPoint: string;
43
+ namespace: string;
44
+ key: string;
45
+ refName: string;
46
+ commit: string;
47
+ sourceFile: string;
48
+ summary?: string;
49
+ }
50
+
51
+ export interface BranchContextCreateOperation {
52
+ slug: string;
53
+ filePath: string;
54
+ branch: string;
55
+ branchCreation: BranchCreationMethod;
56
+ namespace: string;
57
+ key: string;
58
+ params: CreateBranchContextFromFileParams;
59
+ summary?: string;
60
+ }
61
+
62
+ export interface BranchContextCreatePreviewContext {
63
+ startPoint: string;
64
+ graphiteParentBranch?: string;
65
+ }
66
+
67
+ export interface CreateBranchContextFromResolvedSourceOptions {
68
+ cwd: string;
69
+ operation: BranchContextCreateOperation;
70
+ sourceFile: string;
71
+ git: GitGateway;
72
+ brmem: BrmemGateway;
73
+ graphite: GraphiteBranchGateway;
74
+ signal?: AbortSignal;
75
+ }
76
+
77
+ export async function createBranchContextFromFile(
78
+ pi: CommandExecApi,
79
+ params: CreateBranchContextFromFileParams,
80
+ options: CreateBranchContextFromFileOptions,
81
+ ): Promise<BranchContextEvidence> {
82
+ const operation = buildBranchContextCreateOperation(params);
83
+ const { git, brmem, graphite } = options.context;
84
+ await checkBranchRefFormat(git, options.cwd, operation.branch, options.signal);
85
+ const sourceFile = await resolvePlanSourceFile(pi, {
86
+ cwd: options.cwd,
87
+ rawFilePath: operation.filePath,
88
+ ...optionalEntry("signal", options.signal),
89
+ git,
90
+ });
91
+ return createBranchContextFromResolvedSource({
92
+ cwd: options.cwd,
93
+ operation,
94
+ sourceFile,
95
+ git,
96
+ brmem,
97
+ graphite,
98
+ ...optionalEntry("signal", options.signal),
99
+ });
100
+ }
101
+
102
+ export async function createBranchContextFromResolvedSource(
103
+ options: CreateBranchContextFromResolvedSourceOptions,
104
+ ): Promise<BranchContextEvidence> {
105
+ const startPoint = await resolveStartPoint(options.git, options.cwd, options.signal);
106
+ await assertLocalBranchAbsent(options.git, options.cwd, options.operation.branch, options.signal);
107
+ await assertBrmemEntryAbsent(options.brmem, options.operation.branch, options.operation.key, {
108
+ formatPresentMessage: formatStaleTargetBranchMemoryMessage,
109
+ });
110
+ await createBranchContext(options.git, options.graphite, {
111
+ cwd: options.cwd,
112
+ method: options.operation.branchCreation,
113
+ branch: options.operation.branch,
114
+ signal: options.signal,
115
+ });
116
+
117
+ let attach: BranchContextAttachData;
118
+ try {
119
+ attach = await attachBranchContext({
120
+ brmem: options.brmem,
121
+ cwd: options.cwd,
122
+ branch: options.operation.branch,
123
+ key: options.operation.key,
124
+ sourceFile: options.sourceFile,
125
+ });
126
+ } catch (error) {
127
+ throw partialFailureError({
128
+ title: attachFailureTitle(error instanceof AttachBranchContextError ? error.code : "unknown"),
129
+ branch: options.operation.branch,
130
+ branchCreation: options.operation.branchCreation,
131
+ startPoint,
132
+ namespace: BRANCH_CONTEXT_NAMESPACE,
133
+ key: options.operation.key,
134
+ sourceFile: options.sourceFile,
135
+ cause: formatErrorMessage(error),
136
+ });
137
+ }
138
+
139
+ return buildEvidence({
140
+ data: attach,
141
+ slug: options.operation.slug,
142
+ branchCreation: options.operation.branchCreation,
143
+ startPoint,
144
+ summary: options.operation.summary,
145
+ });
146
+ }
147
+
148
+ export function buildBranchContextCreateOperation(
149
+ params: CreateBranchContextFromFileParams,
150
+ ): BranchContextCreateOperation {
151
+ const slug = params.slug.trim();
152
+ const branchCreation = params.branchCreation ?? DEFAULT_BRANCH_CREATION_METHOD;
153
+ const branch = deriveTargetBranch(params.branchName, slug);
154
+ const summary = normalizeSummary(params.summary);
155
+ const operationParams: CreateBranchContextFromFileParams = {
156
+ slug,
157
+ filePath: params.filePath,
158
+ branchCreation,
159
+ };
160
+ if (branch !== slug) {
161
+ operationParams.branchName = branch;
162
+ }
163
+ if (summary !== undefined) {
164
+ operationParams.summary = summary;
165
+ }
166
+
167
+ const operation = {
168
+ slug,
169
+ filePath: params.filePath,
170
+ branch,
171
+ branchCreation,
172
+ namespace: BRANCH_CONTEXT_NAMESPACE,
173
+ key: buildBranchContextPlanKey(slug),
174
+ params: operationParams,
175
+ };
176
+ if (summary === undefined) {
177
+ return operation;
178
+ }
179
+ return { ...operation, summary };
180
+ }
181
+
182
+ export async function resolveBranchContextCreatePreviewContext(
183
+ _pi: CommandExecApi,
184
+ options: { cwd: string; context: BranchContextContext; signal?: AbortSignal },
185
+ ): Promise<BranchContextCreatePreviewContext> {
186
+ return { startPoint: await resolveStartPoint(options.context.git, options.cwd, options.signal) };
187
+ }
188
+
189
+ export function formatBranchContextCreatePreview(
190
+ operation: BranchContextCreateOperation,
191
+ context: BranchContextCreatePreviewContext,
192
+ ): string {
193
+ const graphiteParentBranch = context.graphiteParentBranch ?? "<current-branch>";
194
+ const lines = [
195
+ "Target:",
196
+ `Branch: ${operation.branch}`,
197
+ `Branch creation: ${operation.branchCreation}`,
198
+ `Start point: ${context.startPoint}`,
199
+ `Branch Memory namespace: ${operation.namespace}`,
200
+ `Branch Memory key: ${operation.key}`,
201
+ "",
202
+ "Branch-context operations that would run:",
203
+ ];
204
+ if (operation.branchCreation === "graphite") {
205
+ lines.push(formatCommand("gt", ["info", graphiteParentBranch, "--no-interactive"]));
206
+ }
207
+ lines.push(formatCommand("git", ["branch", operation.branch, "HEAD"]));
208
+ if (operation.branchCreation === "graphite") {
209
+ lines.push(
210
+ formatCommand("gt", [
211
+ "track",
212
+ operation.branch,
213
+ "--parent",
214
+ graphiteParentBranch,
215
+ "--no-interactive",
216
+ ]),
217
+ );
218
+ }
219
+ lines.push(
220
+ [
221
+ "Attach plan through the in-process Branch Memory gateway:",
222
+ `Namespace: ${operation.namespace}`,
223
+ `Branch: ${operation.branch}`,
224
+ `Key: ${operation.key}`,
225
+ `Source file: ${operation.filePath}`,
226
+ ].join(" "),
227
+ );
228
+ return lines.join("\n");
229
+ }
230
+
231
+ export function formatBranchContextEvidence(evidence: BranchContextEvidence): string {
232
+ const lines = [
233
+ "Created branch context and attached plan.",
234
+ `Branch: ${evidence.branch}`,
235
+ `Branch creation: ${evidence.branchCreation}`,
236
+ `Start point: ${evidence.startPoint}`,
237
+ `Namespace: ${evidence.namespace}`,
238
+ `Key: ${evidence.key}`,
239
+ `Ref: ${evidence.refName}`,
240
+ `Commit: ${evidence.commit}`,
241
+ `Source file: ${evidence.sourceFile}`,
242
+ `Slug: ${evidence.slug}`,
243
+ ];
244
+ if (evidence.summary !== undefined) {
245
+ lines.push(`Summary: ${evidence.summary}`);
246
+ }
247
+ return lines.join("\n");
248
+ }
249
+
250
+ export function formatBranchContextCreateFailure(
251
+ operation: BranchContextCreateOperation,
252
+ error: unknown,
253
+ ): string {
254
+ return [
255
+ "Failed to create branch context and attach plan.",
256
+ `Branch: ${operation.branch}`,
257
+ `Branch creation: ${operation.branchCreation}`,
258
+ `Namespace: ${operation.namespace}`,
259
+ `Key: ${operation.key}`,
260
+ `Source file: ${operation.filePath}`,
261
+ "",
262
+ formatErrorMessage(error),
263
+ ].join("\n");
264
+ }
265
+
266
+ export function deriveTargetBranch(branchName: string | undefined, slug: string): string {
267
+ const trimmedBranchName = branchName?.trim();
268
+ return trimmedBranchName && trimmedBranchName.length > 0 ? trimmedBranchName : slug;
269
+ }
270
+
271
+ async function checkBranchRefFormat(
272
+ git: GitGateway,
273
+ cwd: string,
274
+ targetBranch: string,
275
+ signal: AbortSignal | undefined,
276
+ ): Promise<void> {
277
+ const refFormat = await git.validateBranchRef({ cwd, branch: targetBranch, signal });
278
+ if (!refFormat.ok) {
279
+ throw new Error(refFormat.error.message);
280
+ }
281
+ }
282
+
283
+ async function resolveStartPoint(
284
+ git: GitGateway,
285
+ cwd: string,
286
+ signal: AbortSignal | undefined,
287
+ ): Promise<string> {
288
+ const head = await git.headCommit({ cwd, signal });
289
+ if (!head.ok) {
290
+ throw new Error(head.error.message);
291
+ }
292
+ return head.value;
293
+ }
294
+
295
+ async function assertLocalBranchAbsent(
296
+ git: GitGateway,
297
+ cwd: string,
298
+ targetBranch: string,
299
+ signal: AbortSignal | undefined,
300
+ ): Promise<void> {
301
+ const check = await git.localBranchPresence({ cwd, branch: targetBranch, signal });
302
+ if (check.type === "absent") {
303
+ return;
304
+ }
305
+ if (check.type === "present") {
306
+ throw new Error(
307
+ [
308
+ "Target branch already exists; refusing to overwrite.",
309
+ `Branch: ${targetBranch}`,
310
+ `Ref: ${check.refName}`,
311
+ `Command: ${check.displayCommand}`,
312
+ ].join("\n"),
313
+ );
314
+ }
315
+ throw new Error(check.error.message);
316
+ }
317
+
318
+ function formatStaleTargetBranchMemoryMessage(context: {
319
+ targetBranch: string;
320
+ key: string;
321
+ }): string {
322
+ return [
323
+ "Stale Branch Memory attachment exists for target branch; refusing to create branch context.",
324
+ "Local branch is absent, but Branch Memory already contains the attached plan key.",
325
+ `Namespace: ${BRANCH_CONTEXT_NAMESPACE}`,
326
+ `Branch: ${context.targetBranch}`,
327
+ `Key: ${context.key}`,
328
+ "Cleanup: run `brmem gc` to preview stale Branch Memory Snapshots, then `brmem gc --yes` to delete them.",
329
+ ].join("\n");
330
+ }
331
+
332
+ interface CreateBranchContextOptions {
333
+ cwd: string;
334
+ method: BranchCreationMethod;
335
+ branch: string;
336
+ signal: AbortSignal | undefined;
337
+ }
338
+
339
+ interface CreatePlainGitBranchOptions {
340
+ cwd: string;
341
+ branch: string;
342
+ signal: AbortSignal | undefined;
343
+ }
344
+
345
+ type CreateGraphiteBranchOptions = CreatePlainGitBranchOptions;
346
+
347
+ async function createBranchContext(
348
+ git: GitGateway,
349
+ graphite: GraphiteBranchGateway,
350
+ options: CreateBranchContextOptions,
351
+ ): Promise<void> {
352
+ if (options.method === "graphite") {
353
+ await createGraphiteBranch(git, graphite, options);
354
+ return;
355
+ }
356
+ await createPlainGitBranch(git, options);
357
+ }
358
+
359
+ async function createPlainGitBranch(
360
+ git: GitGateway,
361
+ options: CreatePlainGitBranchOptions,
362
+ ): Promise<void> {
363
+ const create = await git.createBranchAtHead({
364
+ cwd: options.cwd,
365
+ branch: options.branch,
366
+ signal: options.signal,
367
+ });
368
+ if (!create.ok) {
369
+ throw new Error(create.error.message);
370
+ }
371
+ }
372
+
373
+ async function createGraphiteBranch(
374
+ git: GitGateway,
375
+ graphite: GraphiteBranchGateway,
376
+ options: CreateGraphiteBranchOptions,
377
+ ): Promise<void> {
378
+ const parentBranch = await resolveCurrentBranch(git, options.cwd, options.signal);
379
+ const parentTracked = await graphite.checkBranchTracked({
380
+ cwd: options.cwd,
381
+ branch: parentBranch,
382
+ signal: options.signal,
383
+ });
384
+ if (!parentTracked.ok) {
385
+ throw new Error(parentTracked.error.message);
386
+ }
387
+ if (!parentTracked.tracked) {
388
+ throw new Error(
389
+ [
390
+ "Current branch is not tracked by Graphite; refusing to stack a branch context on it.",
391
+ `Parent branch: ${parentBranch}`,
392
+ "No branch was created and no plan was attached.",
393
+ `Track the parent first (gt track ${parentBranch} --parent <its-parent>) or pass --plain-git.`,
394
+ "",
395
+ parentTracked.detail,
396
+ ].join("\n"),
397
+ );
398
+ }
399
+ await createPlainGitBranch(git, options);
400
+ const track = await graphite.trackBranch({
401
+ cwd: options.cwd,
402
+ branch: options.branch,
403
+ parentBranch,
404
+ signal: options.signal,
405
+ });
406
+ if (!track.ok) {
407
+ throw new Error(
408
+ [
409
+ "Created local Git branch but failed to track it with Graphite.",
410
+ `Branch: ${options.branch}`,
411
+ "No attached plan was stored.",
412
+ "No cleanup was attempted; inspect the created branch manually.",
413
+ "",
414
+ track.error.message,
415
+ ].join("\n"),
416
+ );
417
+ }
418
+ }
419
+
420
+ async function resolveCurrentBranch(
421
+ git: GitGateway,
422
+ cwd: string,
423
+ signal: AbortSignal | undefined,
424
+ ): Promise<string> {
425
+ const branch = await git.currentBranch({ cwd, signal });
426
+ if (branch.type === "branch") return branch.branch;
427
+ if (branch.type === "detached") {
428
+ throw new Error(
429
+ "Graphite branch creation requires a named current branch; the current checkout appears to be detached.",
430
+ );
431
+ }
432
+ throw new Error(branch.error.message);
433
+ }
434
+
435
+ function buildEvidence(input: {
436
+ data: BranchContextAttachData;
437
+ slug: string;
438
+ branchCreation: BranchCreationMethod;
439
+ startPoint: string;
440
+ summary: string | undefined;
441
+ }): BranchContextEvidence {
442
+ const evidence = {
443
+ slug: input.slug,
444
+ branch: input.data.branch,
445
+ branchCreation: input.branchCreation,
446
+ startPoint: input.startPoint,
447
+ namespace: input.data.namespace,
448
+ key: input.data.key,
449
+ refName: input.data.refName,
450
+ commit: input.data.commit,
451
+ sourceFile: input.data.sourceFile,
452
+ };
453
+
454
+ if (input.summary === undefined) {
455
+ return evidence;
456
+ }
457
+ return { ...evidence, summary: input.summary };
458
+ }
459
+
460
+ function attachFailureTitle(_code: string): string {
461
+ return "Created branch but failed to attach the plan in Branch Memory.";
462
+ }
463
+
464
+ function partialFailureError(input: {
465
+ title: string;
466
+ branch: string;
467
+ branchCreation: BranchCreationMethod;
468
+ startPoint: string;
469
+ namespace: string;
470
+ key: string;
471
+ sourceFile: string;
472
+ cause: string;
473
+ }): Error {
474
+ return new Error(
475
+ [
476
+ `Partial failure: ${input.title}`,
477
+ `Created branch: ${input.branch}`,
478
+ `Branch creation: ${input.branchCreation}`,
479
+ `Start point: ${input.startPoint}`,
480
+ `Namespace: ${input.namespace}`,
481
+ `Key: ${input.key}`,
482
+ `Source file: ${input.sourceFile}`,
483
+ "No cleanup was attempted; inspect the created branch manually.",
484
+ "",
485
+ trimErrorText(input.cause),
486
+ ].join("\n"),
487
+ );
488
+ }
489
+
490
+ function trimErrorText(value: string): string {
491
+ if (value.length <= MAX_ERROR_CHARS) {
492
+ return value;
493
+ }
494
+ return `…${value.slice(-MAX_ERROR_CHARS)}`;
495
+ }
@@ -0,0 +1,147 @@
1
+ import {
2
+ mustEntryLocator,
3
+ putEntryFromFile,
4
+ type BrmemErrorInfo,
5
+ type BrmemGateway,
6
+ } from "@nseng-ai/brmem";
7
+ import type { Result } from "@nseng-ai/foundation/result";
8
+
9
+ import { BRANCH_CONTEXT_NAMESPACE } from "./constants.ts";
10
+
11
+ export interface AttachedPlanEntry {
12
+ namespace: string;
13
+ key: string;
14
+ branch: string;
15
+ refName: string;
16
+ }
17
+
18
+ export interface BranchContextAttachData {
19
+ namespace: string;
20
+ key: string;
21
+ branch: string;
22
+ refName: string;
23
+ commit: string;
24
+ sourceFile: string;
25
+ }
26
+
27
+ export interface BrmemGetContent {
28
+ content: string;
29
+ refName: string;
30
+ }
31
+
32
+ export type BranchContextBrmemResult<T> = Result<T, BrmemErrorInfo>;
33
+
34
+ export type BrmemAttachmentPresenceResult =
35
+ | { type: "present" }
36
+ | { type: "absent" }
37
+ | { type: "error"; error: BrmemErrorInfo };
38
+
39
+ export function throwBranchContextBrmemError(error: BrmemErrorInfo): never {
40
+ throw new Error(error.message);
41
+ }
42
+
43
+ export function unwrapBranchContextBrmemResult<T>(result: BranchContextBrmemResult<T>): T {
44
+ if (!result.ok) throwBranchContextBrmemError(result.error);
45
+ return result.value;
46
+ }
47
+
48
+ export async function checkBranchContextEntryPresence(
49
+ brmem: BrmemGateway,
50
+ params: { branch: string; key: string },
51
+ ): Promise<BrmemAttachmentPresenceResult> {
52
+ const check = await brmem.checkEntry({
53
+ namespace: BRANCH_CONTEXT_NAMESPACE,
54
+ branch: params.branch,
55
+ key: params.key,
56
+ });
57
+ if (check.type === "found") return { type: "present" };
58
+ if (check.type === "missing") return { type: "absent" };
59
+ return { type: "error", error: check.error };
60
+ }
61
+
62
+ export async function attachBranchContextPlan(
63
+ brmem: BrmemGateway,
64
+ params: { cwd: string; branch: string; key: string; sourceFile: string },
65
+ ): Promise<BranchContextBrmemResult<BranchContextAttachData>> {
66
+ const attach = await putEntryFromFile({
67
+ gateway: brmem,
68
+ cwd: params.cwd,
69
+ namespace: BRANCH_CONTEXT_NAMESPACE,
70
+ branch: params.branch,
71
+ key: params.key,
72
+ sourceFile: params.sourceFile,
73
+ });
74
+ if (attach.type === "error") return { ok: false, error: attach.error };
75
+ return {
76
+ ok: true,
77
+ value: {
78
+ namespace: attach.value.namespace,
79
+ key: attach.value.key,
80
+ branch: attach.value.branch,
81
+ refName: attach.value.entryLocator,
82
+ commit: attach.value.commitSha,
83
+ sourceFile: attach.value.sourceFile,
84
+ },
85
+ };
86
+ }
87
+
88
+ export async function listBranchContextPlans(
89
+ brmem: BrmemGateway,
90
+ params: { branch: string },
91
+ ): Promise<BranchContextBrmemResult<AttachedPlanEntry[]>> {
92
+ const list = await brmem.listEntries({
93
+ namespace: BRANCH_CONTEXT_NAMESPACE,
94
+ branch: params.branch,
95
+ });
96
+ if (list.type === "error") return { ok: false, error: list.error };
97
+ return {
98
+ ok: true,
99
+ value: list.value.map((entry) => ({
100
+ namespace: entry.namespace,
101
+ key: entry.key,
102
+ branch: entry.branch,
103
+ refName: entry.entryLocator,
104
+ })),
105
+ };
106
+ }
107
+
108
+ export async function getBranchContextPlan(
109
+ brmem: BrmemGateway,
110
+ params: { branch: string; key: string },
111
+ ): Promise<BranchContextBrmemResult<BrmemGetContent>> {
112
+ const get = await brmem.getEntry({
113
+ namespace: BRANCH_CONTEXT_NAMESPACE,
114
+ branch: params.branch,
115
+ key: params.key,
116
+ });
117
+ if (get.type === "error") return { ok: false, error: get.error };
118
+ if (get.type === "missing") {
119
+ return {
120
+ ok: false,
121
+ error: {
122
+ code: "key-not-found",
123
+ message: `Branch-context key ${JSON.stringify(params.key)} not found on branch ${JSON.stringify(params.branch)}.`,
124
+ },
125
+ };
126
+ }
127
+ return {
128
+ ok: true,
129
+ value: {
130
+ content: get.value.content,
131
+ refName: mustEntryLocator(BRANCH_CONTEXT_NAMESPACE, params.key, params.branch),
132
+ },
133
+ };
134
+ }
135
+
136
+ export async function deleteBranchContextPlan(
137
+ brmem: BrmemGateway,
138
+ params: { branch: string; key: string },
139
+ ): Promise<BranchContextBrmemResult<void>> {
140
+ const deleted = await brmem.deleteEntry({
141
+ namespace: BRANCH_CONTEXT_NAMESPACE,
142
+ branch: params.branch,
143
+ key: params.key,
144
+ });
145
+ if (deleted.type === "error") return { ok: false, error: deleted.error };
146
+ return { ok: true, value: undefined };
147
+ }