@senad-d/branchme 0.3.2 → 0.3.3

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.
@@ -9,7 +9,6 @@ import {
9
9
  CREATE_WORKTREE_TOOL_NAME,
10
10
  FETCH_BRANCH_TOOL_NAME,
11
11
  GIT_BRANCH_SUMMARY_LIMIT_CHARS,
12
- GIT_INTEGRATION_SUMMARY_LIMIT_CHARS,
13
12
  GIT_RETIREMENT_SUMMARY_LIMIT_CHARS,
14
13
  GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
15
14
  INIT_REPOSITORY_TOOL_NAME,
@@ -47,7 +46,7 @@ import {
47
46
  withRepositoryMutationQueue,
48
47
  } from "../git.ts";
49
48
  import { collectGitContext, formatGitContext } from "../git-context.ts";
50
- import { integrateBranch } from "../git-integration.ts";
49
+ import { formatConflictPathList, integrateBranch } from "../git-integration.ts";
51
50
  import { retireBranch } from "../git-retirement.ts";
52
51
  import { formatLandBranch, landBranch } from "../git-landing.ts";
53
52
  import {
@@ -328,42 +327,17 @@ export function formatRemoveWorktree(details: RemoveWorktreeDetails): string {
328
327
  return `Removed linked worktree directory ${path}. Verified it is no longer registered and retained local branch ${branch} at HEAD ${shortCommit(details.handoff.head)}; the removed cwd is not ready for handoff.`;
329
328
  }
330
329
 
331
- function integrationConflictOmissionLine(omitted: number): string {
332
- return `${omitted} conflict path${omitted === 1 ? "" : "s"} omitted.`;
333
- }
334
-
335
- function formatIntegrationConflict(details: Extract<IntegrateBranchDetails, { status: "conflict" }>): string {
330
+ function formatIntegrationConflict(details: Extract<IntegrateBranchDetails, { status: "conflict" | "conflict_kept" }>): string {
336
331
  const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
337
332
  const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
338
- const lines = [
339
- `integrate_branch found conflicts while integrating ${source} into ${target}; the merge was automatically aborted and exact restoration was verified at target HEAD ${shortCommit(details.verified.heads.after.targetHead)}.`,
340
- "Conflict paths:",
341
- ];
342
- let omitted = details.conflict.omitted;
343
-
344
- for (const [index, entry] of details.conflict.paths.entries()) {
345
- const path = safeWorktreeFormatValue(entry.path, WORKTREE_FORMAT_PATH_LIMIT_CHARS);
346
- const line = `- ${path}`;
347
- const remaining = details.conflict.paths.length - index - 1;
348
- const candidateOmitted = details.conflict.omitted + remaining;
349
- const candidate = [
350
- ...lines,
351
- line,
352
- ...(candidateOmitted > 0 ? [integrationConflictOmissionLine(candidateOmitted)] : []),
353
- ].join("\n");
354
- if (candidate.length > GIT_INTEGRATION_SUMMARY_LIMIT_CHARS) {
355
- omitted += details.conflict.paths.length - index;
356
- break;
357
- }
358
- lines.push(line);
359
- }
360
-
361
- if (omitted > 0) lines.push(integrationConflictOmissionLine(omitted));
362
- return lines.join("\n");
333
+ const header = details.status === "conflict"
334
+ ? `integrate_branch found conflicts while integrating ${source} into ${target}; the merge was automatically aborted and exact restoration was verified at target HEAD ${shortCommit(details.verified.heads.after.targetHead)}.`
335
+ : `integrate_branch found conflicts while integrating ${source} into ${target}; the merge was kept in progress at target HEAD ${shortCommit(details.heads.targetHead)} and must be finished with conclude_merge.`;
336
+ return formatConflictPathList(header, details.conflict);
363
337
  }
364
338
 
365
339
  export function formatIntegrateBranch(details: IntegrateBranchDetails): string {
366
- if (details.status === "conflict") return formatIntegrationConflict(details);
340
+ if (details.status === "conflict" || details.status === "conflict_kept") return formatIntegrationConflict(details);
367
341
 
368
342
  const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
369
343
  const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
@@ -1,11 +1,57 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
3
- import { PULL_REQUEST_STATUS_TOOL_NAME, TRACK_BRANCH_TOOL_NAME, UPDATE_FROM_BASE_TOOL_NAME } from "../constants.ts";
4
- import { getGitRoot, requireCurrentBranch } from "../git.ts";
4
+ import {
5
+ CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS, PULL_REQUEST_STATUS_TOOL_NAME, TRACK_BRANCH_TOOL_NAME,
6
+ UPDATE_FROM_BASE_TOOL_NAME,
7
+ } from "../constants.ts";
8
+ import { getGitRoot, requireCurrentBranch, safeWorktreeValue } from "../git.ts";
9
+ import { concludeMerge, formatConflictPathList } from "../git-integration.ts";
5
10
  import { findGitHubPullRequest, getGitHubPullRequest, resolveGitHubRepository, resolveGitHubToken } from "../github.ts";
6
- import type { PullRequestStatusDetails } from "../types.ts";
11
+ import type { ConcludeMergeDetails, PullRequestStatusDetails } from "../types.ts";
7
12
  import type { BranchMeToolOptions } from "./branchme-tools.ts";
8
- import { trackBranch, updateFromBase } from "../git-workflow.ts";
13
+ import { trackBranch, updateFromBase, type UpdateFromBaseDetails } from "../git-workflow.ts";
14
+
15
+ function shortCommit(commit: string): string {
16
+ return commit.slice(0, 12);
17
+ }
18
+
19
+ function branchLabel(branch: string): string {
20
+ return safeWorktreeValue(branch, GIT_CONTEXT_VALUE_LIMIT_CHARS);
21
+ }
22
+
23
+ export function formatUpdateFromBase(details: UpdateFromBaseDetails): string {
24
+ const { integration } = details;
25
+ const source = branchLabel(integration.request.sourceBranch);
26
+ const target = branchLabel(integration.request.targetBranch);
27
+ if (integration.status === "conflict") {
28
+ return formatConflictPathList(
29
+ `update_from_base: conflict. Merging ${source} into ${target} conflicted; the merge was automatically aborted and restoration was verified at HEAD ${shortCommit(integration.verified.heads.after.targetHead)}. Rerun with keepConflicts: true to keep the conflicted merge in progress for resolution.`,
30
+ integration.conflict,
31
+ );
32
+ }
33
+ if (integration.status === "conflict_kept") {
34
+ return formatConflictPathList(
35
+ `update_from_base: conflict_kept. Merging ${source} (${shortCommit(integration.heads.sourceHead)}) into ${target} conflicted; the merge is in progress with MERGE_HEAD set and HEAD unchanged at ${shortCommit(integration.heads.targetHead)}. Remove the conflict markers in the listed paths, then run conclude_merge with action "conclude" to commit the merge or action "abort" to restore the branch.`,
36
+ integration.conflict,
37
+ );
38
+ }
39
+ return `update_from_base: ${integration.status}. Base integration verified without rewriting published history.`;
40
+ }
41
+
42
+ export function formatConcludeMerge(details: ConcludeMergeDetails): string {
43
+ const branch = branchLabel(details.branch);
44
+ if (details.status === "aborted") {
45
+ return `conclude_merge: aborted. git merge --abort restored ${branch} to HEAD ${shortCommit(details.head)}; MERGE_HEAD ${shortCommit(details.mergeHead)} is cleared, no Git operation is in progress, and the working tree is clean.`;
46
+ }
47
+ const count = details.resolvedPaths.length;
48
+ return formatConflictPathList(
49
+ `conclude_merge: concluded. Committed merge ${shortCommit(details.heads.after)} on ${branch} with parents ${shortCommit(details.parents.first)} (previous HEAD) and ${shortCommit(details.parents.second)} (MERGE_HEAD); MERGE_HEAD is cleared and both parents were verified. Only the ${count} resolved path${count === 1 ? "" : "s"} listed below ${count === 1 ? "was" : "were"} staged during this call; other working-tree changes remain unstaged. The commit includes all previously staged entries.`,
50
+ { paths: details.resolvedPaths, omitted: 0 },
51
+ "Resolved paths:",
52
+ "resolved path",
53
+ );
54
+ }
9
55
 
10
56
  function formatPullRequestStatus(pullRequest: PullRequestStatusDetails | null): string {
11
57
  if (!pullRequest) return "No matching pull request found.";
@@ -47,20 +93,43 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
47
93
  pi.registerTool({
48
94
  name: UPDATE_FROM_BASE_TOOL_NAME,
49
95
  label: "Update From Base",
50
- description: "update_from_base fetches one remote base and merges its captured commit into the clean current feature branch with verified no-op, fast-forward, merge-commit, or automatically aborted conflict results. Never rebases, pushes, stashes, or changes upstream configuration.",
51
- promptSnippet: "update_from_base: merge a fresh remote base into the current feature without rewriting published history",
96
+ description: "update_from_base fetches one remote base and merges its captured commit into the clean current feature branch with verified no-op, fast-forward, merge-commit, or conflict results. A conflict is automatically aborted with restoration verified unless keepConflicts is true, which leaves the conflicted merge in progress for conclude_merge. Never rebases, pushes, stashes, or changes upstream configuration.",
97
+ promptSnippet: "update_from_base: merge a fresh remote base into the current feature without rewriting published history; keepConflicts keeps a conflicted merge in progress",
52
98
  promptGuidelines: [
53
99
  "Use update_from_base only when asked to update the current feature branch from an explicit baseBranch; remote defaults to origin.",
54
- "Run update_from_base by itself. It fetches the base and uses the fixed normal-merge policy; conflicts are automatically aborted with restoration verified.",
55
- "update_from_base preserves published history and upstream configuration; committing conflict-resolution changes belongs to a separate workflow.",
100
+ "Run update_from_base by itself. It fetches the base and uses the fixed normal-merge policy; by default conflicts are automatically aborted with restoration verified and the conflict paths are listed.",
101
+ "Pass keepConflicts: true to update_from_base only when a file-editing step will resolve the listed paths; the result conflict_kept leaves MERGE_HEAD set, and conclude_merge must then conclude or abort it before any other Git mutation.",
102
+ "update_from_base preserves published history and upstream configuration; it never resolves conflict content itself.",
56
103
  ],
57
104
  parameters: Type.Object({
58
105
  baseBranch: Type.String({ minLength: 1, description: "Exact base branch on the remote, for example main." }),
59
106
  remote: Type.Optional(Type.String({ minLength: 1, description: "Configured remote; defaults to origin." })),
107
+ keepConflicts: Type.Optional(Type.Boolean({ description: "When true, a conflicted merge is left in progress (MERGE_HEAD set, conflicted paths unmerged) for conclude_merge instead of being aborted; defaults to false." })),
60
108
  }, { additionalProperties: false }),
61
109
  async execute(_id, params, signal, _update, ctx) {
62
110
  const details = await updateFromBase(pi, ctx, params, signal);
63
- return { content: [{ type: "text", text: `update_from_base: ${details.status}. ${details.status === "conflict" ? "Merge aborted and restoration verified; inspect conflict paths in details." : "Base integration verified without rewriting published history."}` }], details };
111
+ return { content: [{ type: "text", text: formatUpdateFromBase(details) }], details };
112
+ },
113
+ });
114
+ pi.registerTool({
115
+ name: CONCLUDE_MERGE_TOOL_NAME,
116
+ label: "Conclude Merge",
117
+ description: "conclude_merge finishes or abandons the in-progress merge on the current checkout, normally one kept by update_from_base with keepConflicts: true. action \"conclude\" checks unmerged working-tree paths and staged blobs for default/custom-sized conflict marker lines, stages exactly the unmerged paths, checks the candidate index again, and commits the full index with Git's prepared merge message, then verifies the two-parent result; action \"abort\" runs git merge --abort and verifies restoration. conclude_merge never stages other changes, accepts no commit message, and never pushes.",
118
+ promptSnippet: "conclude_merge: commit or abort the kept in-progress merge after conflict markers are removed",
119
+ promptGuidelines: [
120
+ "Use conclude_merge only after update_from_base reported conflict_kept, or when a single-head merge is otherwise in progress on the current checkout; run conclude_merge by itself. Multi-head merges are refused before mutation.",
121
+ "Before conclude_merge with action conclude, a file-editing step must remove every <<<<<<<, |||||||, =======, and >>>>>>> marker line from the listed paths; conclude_merge refuses and names the paths that still contain markers.",
122
+ "conclude_merge with action conclude stages only the currently unmerged paths and uses Git's prepared merge message; unrelated unstaged edits stay unstaged and are not part of the merge commit. Git commits the entire index, including previously staged entries: do not stage unrelated edits during the merge.",
123
+ "conclude_merge with action abort restores the branch with git merge --abort and verifies a clean idle checkout; conclude_merge refuses when no merge is in progress.",
124
+ ],
125
+ parameters: Type.Object({
126
+ action: StringEnum(["conclude", "abort"] as const, {
127
+ description: "conclude stages the resolved formerly unmerged paths and commits the merge; abort runs git merge --abort and verifies restoration.",
128
+ }),
129
+ }, { additionalProperties: false }),
130
+ async execute(_id, params, signal, _update, ctx) {
131
+ const details = await concludeMerge(pi, ctx, params, signal);
132
+ return { content: [{ type: "text", text: formatConcludeMerge(details) }], details };
64
133
  },
65
134
  });
66
135
  pi.registerTool({
package/src/types.ts CHANGED
@@ -318,7 +318,7 @@ export interface PushBranchDetails {
318
318
  output: string;
319
319
  }
320
320
 
321
- export type IntegrateBranchStatus = "already_integrated" | "fast_forward" | "merge_commit" | "conflict";
321
+ export type IntegrateBranchStatus = "already_integrated" | "fast_forward" | "merge_commit" | "conflict" | "conflict_kept";
322
322
 
323
323
  export interface IntegrateBranchHeads {
324
324
  sourceHead: string;
@@ -406,11 +406,72 @@ export interface IntegrateBranchConflictDetails extends IntegrateBranchDetailsBa
406
406
  };
407
407
  }
408
408
 
409
+ /** A conflicted merge deliberately left in progress (update_from_base keepConflicts); conclude_merge finishes or abandons it. */
410
+ export interface IntegrateBranchConflictKeptDetails {
411
+ action: "integrate_branch";
412
+ status: "conflict_kept";
413
+ mergeExecuted: true;
414
+ request: IntegrateBranchToolInput;
415
+ /** targetHead is the unchanged HEAD; sourceHead is the commit recorded in MERGE_HEAD. */
416
+ heads: IntegrateBranchHeads;
417
+ conflict: {
418
+ paths: IntegrateBranchConflictPathEntry[];
419
+ omitted: number;
420
+ kept: true;
421
+ mergeInProgress: true;
422
+ };
423
+ }
424
+
409
425
  export type IntegrateBranchDetails =
410
426
  | IntegrateBranchAlreadyIntegratedDetails
411
427
  | IntegrateBranchFastForwardDetails
412
428
  | IntegrateBranchMergeCommitDetails
413
- | IntegrateBranchConflictDetails;
429
+ | IntegrateBranchConflictDetails
430
+ | IntegrateBranchConflictKeptDetails;
431
+
432
+ export type ConcludeMergeAction = "conclude" | "abort";
433
+
434
+ export interface ConcludeMergeToolInput {
435
+ action: ConcludeMergeAction;
436
+ }
437
+
438
+ export interface ConcludeMergeConcludedDetails {
439
+ action: "conclude_merge";
440
+ status: "concluded";
441
+ request: ConcludeMergeToolInput;
442
+ repoRoot: string;
443
+ branch: string;
444
+ heads: {
445
+ before: string;
446
+ after: string;
447
+ };
448
+ parents: {
449
+ first: string;
450
+ second: string;
451
+ };
452
+ mergeHead: string;
453
+ /** Exactly the formerly unmerged paths that were staged for the merge commit. */
454
+ resolvedPaths: IntegrateBranchConflictPathEntry[];
455
+ }
456
+
457
+ export interface ConcludeMergeAbortedDetails {
458
+ action: "conclude_merge";
459
+ status: "aborted";
460
+ request: ConcludeMergeToolInput;
461
+ repoRoot: string;
462
+ branch: string;
463
+ head: string;
464
+ mergeHead: string;
465
+ restoration: {
466
+ verified: true;
467
+ headRestored: true;
468
+ branchPreserved: true;
469
+ operationStateCleared: true;
470
+ cleanWorktree: true;
471
+ };
472
+ }
473
+
474
+ export type ConcludeMergeDetails = ConcludeMergeConcludedDetails | ConcludeMergeAbortedDetails;
414
475
 
415
476
  export type RetireBranchMode = "merged" | "forced_unmerged";
416
477