@gitkraken/compose-tools 0.3.2 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-07-16
10
+
11
+ ### Added
12
+
13
+ - Adds `overrideSafetyFailures` on `ComposeInput`, `ComposePlanInput`, and `ApplyComposePlanInput` — an optional object for opting out of individual safety checks. Its only flag, `interiorRefs`, lets a range rewrite proceed even when branches or tags point at commits inside the rewritten range (those refs are left in place and diverge from the recomposed history rather than being blocked). The interior-fork check still always runs. Type export: `OverrideSafetyFailures`.
14
+ - Exports `detectInteriorRefs` — reports the branches, tags, or detached HEAD pointing at commits inside the range a rewrite of a source would replace, so consumers can warn before composing with the `interiorRefs` override. Type export: `InteriorRefViolation`.
15
+
16
+ ### Fixed
17
+
18
+ - `refinePlan` now repairs locked-commit ordering the model may have shuffled during a refine.
19
+ - The rewrite-safety message no longer says interior refs would be "orphaned" — they keep their commits and simply don't follow the recomposed history.
20
+ - Interior-ref detection and rewrite-time ref moves now resolve annotated tags to their target commit (via `%(*objectname)`) instead of comparing the tag object's SHA and missing them.
21
+
22
+ ## [0.4.0] - 2026-06-26
23
+
24
+ ### Added
25
+
26
+ - Adds `refinePlan` — produces an updated `ComposePlan` from a prior plan + session via a single AI continuation call (no diff collection, no compose pipeline, no git mutation). Accepts `lockedCommits` (ids preserved verbatim from the prior plan) and `clientContext` (consumer-supplied prompt context). Type exports: `RefinePlanInput`, `RefinePlanResult`, `RefineProgressEvent`.
27
+ - Exports a named `ComposeDiffStats` type for the `{ fileCount, hunkCount, addedLines, removedLines }` shape (previously inline on `ComposeResult.diffStats`).
28
+
29
+ ### Changed
30
+
31
+ - `ComposeProgressEvent`'s `collecting` variant now emits twice: once at the start as a spinner trigger (no `stats`), then again after collection and `hunkFilter` complete with a new `stats?: ComposeDiffStats` field carrying `{ fileCount, hunkCount, addedLines, removedLines }` — so consumers can render live file/hunk/line counts inline ("Collecting changes… done (3 files, +94/-5)") without re-deriving from `git diff --numstat`. The same second emit now also fires from `applyComposePlan`.
32
+ - `ComposePlanResult.diffStats` — `composePlan` now forwards the existing `diffStats` from `ComposeResult` so consumers using the two-phase `composePlan → applyComposePlan` flow can see the same counts on the plan result.
33
+
34
+ ### Fixed
35
+
36
+ - Fixes undo for compose apply targeting pre-existing branches. Previously, `applyBranchTarget` unconditionally recorded the target in createdRefs, causing undo to delete the branch entirely instead of restoring it.
37
+
9
38
  ## [0.3.2] - 2026-06-02
10
39
 
11
40
  ### Fixed
package/README.md CHANGED
@@ -600,6 +600,8 @@ const result = await compose({
600
600
  // event.event: ProgressEvent with { type, attempt, maxAttempts, errors? } (AI phases)
601
601
  // event.detail: phase-specific string (non-AI phases)
602
602
  // event.branch: only for 'partitioning', names the branch being subdivided
603
+ // event.stats: only for 'collecting'; set on the post-collection emit
604
+ // with { fileCount, hunkCount, addedLines, removedLines }
603
605
  },
604
606
  });
605
607
 
@@ -1 +1 @@
1
- import{GitError as e}from"../errors/git.js";import{applyPatchToWorkdir as t,checkout as n,cleanWorkdir as r,currentBranch as i,deleteBranch as a,discardWorkdirChanges as o,reset as s,revParse as c,updateRef as l}from"./port-dispatch.js";import{applyBranchStructure as u}from"./apply-branches.js";import{reparentDescendants as d}from"./reparent.js";import{applyCommitChain as f,applyRangeRewrite as p,buildCommitChain as m,moveRefsAtSha as h}from"./commit-chain.js";import{verifyMultiBranchDiff as g,verifyResultingDiff as _}from"./safety.js";async function v(t){let{git:n,source:r,target:i,branchPlans:a,allOrderedCommits:o,hunks:s,snapshot:l,collection:d,signing:h,authorAttribution:g,stashLabel:v,leftoverPatch:x}=t;if(i.type===`rewrite-range`){if(!d.branchName||!d.branchSha)throw new e(`OPERATION_FAILED`,`rewrite-range target requires a source that records branchName and branchSha (branch or commit-range).`);let t;try{t=await c(n,`${i.rangeFrom}^`)}catch{throw new e(`OPERATION_FAILED`,`rewrite-range target's rangeFrom (${i.rangeFrom.slice(0,8)}) has no parent (root commit) — rewriting the root commit is not currently supported.`)}let{headSha:a,shaMap:u}=await m({git:n,baseSha:t,commits:o,hunks:s,signing:h,authorAttribution:g}),f=await _(n,t,a,l);if(!f.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${f.error}`);let y=r.type===`commit-range`&&r.includeWorkdir!=null,b=await p({git:n,branch:i.branch,rangeFrom:i.rangeFrom,rangeTo:i.rangeTo,newHeadSha:a,originalBranchSha:d.branchSha,stashLabel:v,leftoverPatch:x,includeWorkdirActive:y});return{commitShas:Object.fromEntries(u),mutations:T(b.refMovements),stashConflict:b.stashConflict}}if(d.branchName&&!d.mergeBaseSha)throw new e(`OPERATION_FAILED`,`Branch source is missing mergeBaseSha — cannot determine base commit`);let S=d.mergeBaseSha??await c(n,`HEAD`),w=D(r,i);if(i.type===`auto`&&a.length>1)return y(n,S,a,s,l,d,w,h,g);if(i.type===`auto`&&a.length===1&&a[0].partitioning)return b(n,S,a[0],s,l,d,w,h,g);let{headSha:k,shaMap:A}=await m({git:n,baseSha:S,commits:o,hunks:s,signing:h,authorAttribution:g}),j=Object.fromEntries(A),M=await _(n,S,k,l);if(!M.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${M.error}`);if(i.type===`branch`)return O({git:n,source:r,target:i,newHeadSha:k,collection:d,stashLabel:v,leftoverPatch:x,commitShas:j});if(i.type===`auto`){let e=a[0]?.branchGroup.name||d.branchName;if(e){let t=!w&&d.branchName&&d.branchSha?{originalBranchName:d.branchName,originalBranchSha:d.branchSha}:{},r={branchName:e,title:a[0]?.branchGroup.title??``,description:a[0]?.branchGroup.description??``,commitIds:o.map(e=>e.id)};await u({git:n,branches:[{partitions:[r],tipSha:k}],...t});let i=E([r],j,t.originalBranchName,t.originalBranchSha);return{partitions:[r],commitShas:j,mutations:i}}let t=await f({git:n,newHeadSha:k,stashLabel:v});return{commitShas:j,mutations:C(),stashConflict:t.stashConflict}}let N=await f({git:n,newHeadSha:k,stashLabel:v,leftoverPatch:x});return{commitShas:j,mutations:C(),stashConflict:N.stashConflict}}async function y(t,n,i,a,s,c,l,d,f){let p=[],h=[],_=[],v=[],y={};for(let e of i){let r=x(e),{headSha:i,shaMap:o}=await m({git:t,baseSha:n,commits:r,hunks:a,signing:d,authorAttribution:f});_.push(i);for(let[e,t]of o)y[e]=t;if(e.partitioning&&e.partitioning.partitions.length>1){let t=S(e.partitioning.partitions,o);p.push(...t),h.push(t),v.push({partitions:t,tipSha:i})}else{let t={branchName:e.branchGroup.name,title:e.branchGroup.title,description:e.branchGroup.description,commitIds:r.map(e=>o.get(e.id)??e.id)};p.push(t),h.push([t]),v.push({partitions:[t],tipSha:i})}}let b=await g(t,n,_,s);if(!b.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${b.error}`);let C=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await u({git:t,branches:v,...C}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t)),{partitions:p,partitionGroups:h,commitShas:y,mutations:E(p,y,C.originalBranchName,C.originalBranchSha)}}async function b(t,n,i,a,s,c,l,d,p){let{headSha:h,shaMap:g}=await m({git:t,baseSha:n,commits:x(i),hunks:a,signing:d,authorAttribution:p}),v=await _(t,n,h,s);if(!v.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${v.error}`);let y=S(i.partitioning.partitions,g),b=Object.fromEntries(g);if(y.length>1){let e=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await u({git:t,branches:[{partitions:y,tipSha:h}],...e}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t,{force:!0,directories:!0})),{partitions:y,commitShas:b,mutations:E(y,b,e.originalBranchName,e.originalBranchSha)}}return await f({git:t,newHeadSha:h}),{partitions:y,commitShas:b,mutations:C()}}function x(t){return t.orderedCommitIds.map(n=>{let r=t.branchGroup.commits.find(e=>e.id===n);if(!r)throw new e(`OPERATION_FAILED`,`Commit ${n} not found in branch '${t.branchGroup.id}'`);return r})}function S(t,n){return t.map(t=>({...t,commitIds:t.commitIds.map(t=>{let r=n.get(t);if(!r)throw new e(`OPERATION_FAILED`,`No SHA found for commit ID '${t}'`);return r})}))}function C(){return{createdRefs:[],deletedRefs:[],movedRefs:[]}}function w(t){if(t.startsWith(`refs/heads/`))return{kind:`branch`,name:t.slice(11)};if(t.startsWith(`refs/tags/`))return{kind:`tag`,name:t.slice(10)};throw new e(`OPERATION_FAILED`,`Unexpected refname '${t}' in apply mutations — only refs/heads/* and refs/tags/* are tracked.`)}function T(e){let t=[];for(let n of e){let{kind:e,name:r}=w(n.refname);t.push({kind:e,name:r,previousSha:n.previousSha,newSha:n.newSha})}return{createdRefs:[],deletedRefs:[],movedRefs:t}}function E(e,t,n,r){let i=e.map(e=>{let n=e.commitIds[e.commitIds.length-1];return{kind:`branch`,name:e.branchName,sha:t[n]??n}}),a=[];return n&&r&&a.push({kind:`branch`,name:n,sha:r}),{createdRefs:i,deletedRefs:a,movedRefs:[]}}function D(e,t){return t.type!==`branch`&&t.type!==`auto`||e.type===`commit-range`?!0:t.preserveOriginal===!0}async function O(r){let{git:o,source:u,target:f,newHeadSha:p,collection:m,leftoverPatch:g,commitShas:_}=r,v=D(u,f),y=u.type===`branch`&&!v&&m.branchName!=null&&m.branchSha!=null;if(y&&f.name===m.branchName)throw new e(`OPERATION_FAILED`,`target 'branch' with name '${f.name}' conflicts with the source branch (preserveOriginal=false). Use target 'rewrite-range' to rewrite the branch in place under its existing name.`);let b={createdRefs:[{kind:`branch`,name:f.name,sha:p}],deletedRefs:[],movedRefs:[]},x=await i(o),S=await c(o,`HEAD`);if(await l(o,`refs/heads/${f.name}`,p),y){let e=m.branchSha,r=m.branchName,i=await d({git:o,oldToSha:e,newTopSha:p,sourceBranchName:void 0});for(let e of i.reparentedRefs){let{kind:t,name:n}=w(e.refname);b.movedRefs.push({kind:t,name:n,previousSha:e.oldSha,newSha:e.newSha})}let c=await h(o,e,p,`refs/heads/${r}`);for(let e of c){let{kind:t,name:n}=w(e.refname);b.movedRefs.push({kind:t,name:n,previousSha:e.previousSha,newSha:e.newSha})}x===r&&(await n(o,f.name),await s(o,p,`hard`),g&&await t(o,g)),await a(o,r,{force:!0}),b.deletedRefs.push({kind:`branch`,name:r,sha:e})}else u.type===`workdir`&&!v&&(await s(o,S,`hard`),g&&await t(o,g));return{commitShas:_,mutations:b}}export{v as applyToTarget};
1
+ import{GitError as e}from"../errors/git.js";import{applyPatchToWorkdir as t,checkout as n,cleanWorkdir as r,currentBranch as i,deleteBranch as a,discardWorkdirChanges as o,reset as s,revParse as c,revParseVerify as l,updateRef as u}from"./port-dispatch.js";import{applyBranchStructure as d}from"./apply-branches.js";import{reparentDescendants as f}from"./reparent.js";import{applyCommitChain as p,applyRangeRewrite as m,buildCommitChain as h,moveRefsAtSha as g}from"./commit-chain.js";import{verifyMultiBranchDiff as _,verifyResultingDiff as v}from"./safety.js";async function y(t){let{git:n,source:r,target:i,branchPlans:a,allOrderedCommits:o,hunks:s,snapshot:l,collection:u,signing:f,authorAttribution:g,stashLabel:_,leftoverPatch:y}=t;if(i.type===`rewrite-range`){if(!u.branchName||!u.branchSha)throw new e(`OPERATION_FAILED`,`rewrite-range target requires a source that records branchName and branchSha (branch or commit-range).`);let t;try{t=await c(n,`${i.rangeFrom}^`)}catch{throw new e(`OPERATION_FAILED`,`rewrite-range target's rangeFrom (${i.rangeFrom.slice(0,8)}) has no parent (root commit) — rewriting the root commit is not currently supported.`)}let{headSha:a,shaMap:d}=await h({git:n,baseSha:t,commits:o,hunks:s,signing:f,authorAttribution:g}),p=await v(n,t,a,l);if(!p.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${p.error}`);let b=r.type===`commit-range`&&r.includeWorkdir!=null,x=await m({git:n,branch:i.branch,rangeFrom:i.rangeFrom,rangeTo:i.rangeTo,newHeadSha:a,originalBranchSha:u.branchSha,stashLabel:_,leftoverPatch:y,includeWorkdirActive:b});return{commitShas:Object.fromEntries(d),mutations:E(x.refMovements),stashConflict:x.stashConflict}}if(u.branchName&&!u.mergeBaseSha)throw new e(`OPERATION_FAILED`,`Branch source is missing mergeBaseSha — cannot determine base commit`);let S=u.mergeBaseSha??await c(n,`HEAD`),C=O(r,i);if(i.type===`auto`&&a.length>1)return b(n,S,a,s,l,u,C,f,g);if(i.type===`auto`&&a.length===1&&a[0].partitioning)return x(n,S,a[0],s,l,u,C,f,g);let{headSha:T,shaMap:A}=await h({git:n,baseSha:S,commits:o,hunks:s,signing:f,authorAttribution:g}),j=Object.fromEntries(A),M=await v(n,S,T,l);if(!M.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${M.error}`);if(i.type===`branch`)return k({git:n,source:r,target:i,newHeadSha:T,collection:u,stashLabel:_,leftoverPatch:y,commitShas:j});if(i.type===`auto`){let e=a[0]?.branchGroup.name||u.branchName;if(e){let t=!C&&u.branchName&&u.branchSha?{originalBranchName:u.branchName,originalBranchSha:u.branchSha}:{},r={branchName:e,title:a[0]?.branchGroup.title??``,description:a[0]?.branchGroup.description??``,commitIds:o.map(e=>e.id)};await d({git:n,branches:[{partitions:[r],tipSha:T}],...t});let i=D([r],j,t.originalBranchName,t.originalBranchSha);return{partitions:[r],commitShas:j,mutations:i}}let t=await p({git:n,newHeadSha:T,stashLabel:_});return{commitShas:j,mutations:w(),stashConflict:t.stashConflict}}let N=await p({git:n,newHeadSha:T,stashLabel:_,leftoverPatch:y});return{commitShas:j,mutations:w(),stashConflict:N.stashConflict}}async function b(t,n,i,a,s,c,l,u,f){let p=[],m=[],g=[],v=[],y={};for(let e of i){let r=S(e),{headSha:i,shaMap:o}=await h({git:t,baseSha:n,commits:r,hunks:a,signing:u,authorAttribution:f});g.push(i);for(let[e,t]of o)y[e]=t;if(e.partitioning&&e.partitioning.partitions.length>1){let t=C(e.partitioning.partitions,o);p.push(...t),m.push(t),v.push({partitions:t,tipSha:i})}else{let t={branchName:e.branchGroup.name,title:e.branchGroup.title,description:e.branchGroup.description,commitIds:r.map(e=>o.get(e.id)??e.id)};p.push(t),m.push([t]),v.push({partitions:[t],tipSha:i})}}let b=await _(t,n,g,s);if(!b.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${b.error}`);let x=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await d({git:t,branches:v,...x}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t)),{partitions:p,partitionGroups:m,commitShas:y,mutations:D(p,y,x.originalBranchName,x.originalBranchSha)}}async function x(t,n,i,a,s,c,l,u,f){let{headSha:m,shaMap:g}=await h({git:t,baseSha:n,commits:S(i),hunks:a,signing:u,authorAttribution:f}),_=await v(t,n,m,s);if(!_.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${_.error}`);let y=C(i.partitioning.partitions,g),b=Object.fromEntries(g);if(y.length>1){let e=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await d({git:t,branches:[{partitions:y,tipSha:m}],...e}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t,{force:!0,directories:!0})),{partitions:y,commitShas:b,mutations:D(y,b,e.originalBranchName,e.originalBranchSha)}}return await p({git:t,newHeadSha:m}),{partitions:y,commitShas:b,mutations:w()}}function S(t){return t.orderedCommitIds.map(n=>{let r=t.branchGroup.commits.find(e=>e.id===n);if(!r)throw new e(`OPERATION_FAILED`,`Commit ${n} not found in branch '${t.branchGroup.id}'`);return r})}function C(t,n){return t.map(t=>({...t,commitIds:t.commitIds.map(t=>{let r=n.get(t);if(!r)throw new e(`OPERATION_FAILED`,`No SHA found for commit ID '${t}'`);return r})}))}function w(){return{createdRefs:[],deletedRefs:[],movedRefs:[],composedBranchCount:0}}function T(t){if(t.startsWith(`refs/heads/`))return{kind:`branch`,name:t.slice(11)};if(t.startsWith(`refs/tags/`))return{kind:`tag`,name:t.slice(10)};throw new e(`OPERATION_FAILED`,`Unexpected refname '${t}' in apply mutations — only refs/heads/* and refs/tags/* are tracked.`)}function E(e){let t=[];for(let n of e){let{kind:e,name:r}=T(n.refname);t.push({kind:e,name:r,previousSha:n.previousSha,newSha:n.newSha})}return{createdRefs:[],deletedRefs:[],movedRefs:t,composedBranchCount:1}}function D(e,t,n,r){let i=e.map(e=>{let n=e.commitIds[e.commitIds.length-1];return{kind:`branch`,name:e.branchName,sha:t[n]??n}}),a=[];return n&&r&&a.push({kind:`branch`,name:n,sha:r}),{createdRefs:i,deletedRefs:a,movedRefs:[],composedBranchCount:i.length}}function O(e,t){return t.type!==`branch`&&t.type!==`auto`||e.type===`commit-range`?!0:t.preserveOriginal===!0}async function k(r){let{git:o,source:d,target:p,newHeadSha:m,collection:h,leftoverPatch:_,commitShas:v}=r,y=O(d,p),b=d.type===`branch`&&!y&&h.branchName!=null&&h.branchSha!=null;if(b&&p.name===h.branchName)throw new e(`OPERATION_FAILED`,`target 'branch' with name '${p.name}' conflicts with the source branch (preserveOriginal=false). Use target 'rewrite-range' to rewrite the branch in place under its existing name.`);let x=await l(o,`refs/heads/${p.name}`),S=x?{createdRefs:[],deletedRefs:[],movedRefs:[{kind:`branch`,name:p.name,previousSha:x,newSha:m}],composedBranchCount:1}:{createdRefs:[{kind:`branch`,name:p.name,sha:m}],deletedRefs:[],movedRefs:[],composedBranchCount:1},C=await i(o),w=await c(o,`HEAD`);if(await u(o,`refs/heads/${p.name}`,m),b){let e=h.branchSha,r=h.branchName,i=await f({git:o,oldToSha:e,newTopSha:m,sourceBranchName:void 0});for(let e of i.reparentedRefs){let{kind:t,name:n}=T(e.refname);S.movedRefs.push({kind:t,name:n,previousSha:e.oldSha,newSha:e.newSha})}let c=await g(o,e,m,`refs/heads/${r}`);for(let e of c){let{kind:t,name:n}=T(e.refname);S.movedRefs.push({kind:t,name:n,previousSha:e.previousSha,newSha:e.newSha})}C===r&&(await n(o,p.name),await s(o,m,`hard`),_&&await t(o,_)),await a(o,r,{force:!0}),S.deletedRefs.push({kind:`branch`,name:r,sha:e})}else d.type===`workdir`&&!y&&(await s(o,w,`hard`),_&&await t(o,_));return{commitShas:v,mutations:S}}export{y as applyToTarget};
@@ -1,7 +1,6 @@
1
- import{GitError as e}from"../errors/git.js";import{applyPatchToIndex as t,applyPatchToWorkdir as n,checkout as r,commitTree as i,currentBranch as a,forEachRef as o,localeNeutralEnv as s,readTree as c,reset as l,revParse as u,stageAll as d,stashDrop as f,stashPop as p,stashPush as m,statusPorcelain as h,updateRef as g,writeTree as _}from"./port-dispatch.js";import{reparentDescendants as v}from"./reparent.js";import{buildPatchForCommit as y}from"./utils/patch-builder.js";import{randomUUID as b}from"node:crypto";import*as x from"node:fs";import*as S from"node:os";import*as C from"node:path";async function w(n){let{git:r,baseSha:a,commits:o,hunks:s,signing:l,authorAttribution:u=`plurality`}=n,d=C.join(S.tmpdir(),`compose-idx-${b()}`),f=a,p=new Map,m=new Set;try{for(let n of o){let a=n.hunkIndices.map(t=>{let n=s.find(e=>e.index===t);if(!n)throw new e(`OPERATION_FAILED`,`Hunk index ${t} not found`);return n}),o=y(a.map(e=>m.has(e.diffHeader)?{...e,diffHeader:B(e.diffHeader,e.fileName)}:e));if(!o.trim())throw new e(`OPERATION_FAILED`,`Empty patch for commit: ${n.message}`);let h={GIT_INDEX_FILE:d};await c(r,f,{indexEnv:h});try{await t(r,o,{indexEnv:h})}catch(t){let r=t instanceof Error?t.message:String(t);throw new e(`OPERATION_FAILED`,`git apply failed for commit '${n.message.split(`
2
- `)[0]}': ${r}`,{cause:t})}for(let e of a)e.originalFileName&&e.diffHeader.includes(`rename from`)&&m.add(e.diffHeader);let g=await _(r,{indexEnv:h}),v=I(a,u),b=R(n.message,v.coAuthors),x=await i(r,g,[f],b,{signing:l,author:v.author});p.set(n.id,x),f=x}return{headSha:f,shaMap:p}}finally{z(d)}}async function T(e){let{git:t,newHeadSha:i,branchName:a,stashLabel:o,leftoverPatch:s}=e,c=o??O,u=await k(t,c);try{a?await r(t,a,{createBranch:!0,startPoint:i}):await l(t,i,`hard`),s&&await n(t,s)}catch(e){throw u!=null&&await j(t,u,c),e}if(u!=null){let e=await j(t,u,c);if(e.conflicted)return{stashConflict:{stashLabel:c,conflictedFiles:e.conflictedFiles}}}return{}}async function E(e){let{git:t,branch:i,rangeTo:o,newHeadSha:s,originalBranchSha:c,stashLabel:u,leftoverPatch:d,includeWorkdirActive:f}=e,p=await a(t)===i,m=u??O,h=p&&!f?await k(t,m):null,_,y=s,b=[];try{let e=await v({git:t,oldToSha:o,newTopSha:s,sourceBranchName:i});for(let t of e.reparentedRefs)b.push({refname:t.refname,previousSha:t.oldSha,newSha:t.newSha});e.sourceBranchNewSha==null?(await g(t,`refs/heads/${i}`,s),b.push({refname:`refs/heads/${i}`,previousSha:c,newSha:s}),y=s):y=e.sourceBranchNewSha;let a=await D(t,o,s,`refs/heads/${i}`);b.push(...a),p&&(await r(t,i),await l(t,y,`hard`),h==null&&d&&await n(t,d))}catch(e){throw h!=null&&await j(t,h,m),e}if(h!=null){let e=await j(t,h,m);e.conflicted&&(_={stashLabel:m,conflictedFiles:e.conflictedFiles})}return{finalTipSha:y,refMovements:b,stashConflict:_}}async function D(e,t,n,r){let i=await o(e,{format:`%(refname) %(objectname)`,pointsAt:t}),a=[];for(let o of i.split(`
3
- `)){let i=o.trim();if(!i)continue;let s=i.indexOf(` `);if(s<0)continue;let c=i.slice(0,s),l=i.slice(s+1).trim();c!==r&&(!c.startsWith(`refs/heads/`)&&!c.startsWith(`refs/tags/`)||l===t&&(await g(e,c,n),a.push({refname:c,previousSha:t,newSha:n})))}return a}const O=`compose backup`;async function k(e,t){if(!(await h(e)).trim())return null;await m(e,{message:t,includeUntracked:!0});try{let t=(await u(e,`stash@{0}`)).trim();return t.length>0?t:null}catch{return null}}async function A(e,t){if(!e.exec)return null;let n;try{n=await e.exec([`stash`,`list`,`--format=%H %gd`],{env:s})}catch{return null}for(let e of n.split(`
4
- `)){let n=e.indexOf(` `);if(!(n<0)&&e.slice(0,n)===t){let t=e.slice(n+1).trim();return t.length>0?t:null}}return null}async function j(e,t,n){try{let n=(await u(e,`${t}^{tree}`)).trim(),r=await M(e);if(n.length>0&&n===r){let n=await A(e,t);if(n!=null)try{await f(e,n)}catch{}return{conflicted:!1,conflictedFiles:[]}}}catch{}let r=await A(e,t);if(r==null)return{conflicted:!0,conflictedFiles:[]};let i;try{return await p(e,{withIndex:!0,ref:r}),{conflicted:!1,conflictedFiles:[]}}catch(e){i=e}let a=await F(e);if(a.length>0)return{conflicted:!0,conflictedFiles:a};if(N(i)){let n=await A(e,t);if(n!=null)try{await f(e,n)}catch{}return{conflicted:!1,conflictedFiles:[]}}return{conflicted:!0,conflictedFiles:[]}}async function M(e){let t=C.join(S.tmpdir(),`compose-equiv-${b()}`);try{let n={GIT_INDEX_FILE:t};return await c(e,`HEAD`,{indexEnv:n}),await d(e,{indexEnv:n}),(await _(e,{indexEnv:n})).trim()}finally{z(t)}}function N(e){let t=P(e);return t.includes(`already exists, no checkout`)||t.includes(`could not restore untracked files from stash`)}function P(e){if(e==null)return``;if(e instanceof Error){let t=e.stderr;return t!=null&&t.length>0?`${e.message}\n${t}`:e.message}return String(e)}async function F(e){try{let t=await h(e),n=[];for(let e of t.split(`
1
+ import{GitError as e}from"../errors/git.js";import{applyPatchToIndex as t,applyPatchToWorkdir as n,checkout as r,commitTree as i,currentBranch as a,localeNeutralEnv as o,readTree as s,reset as c,revParse as l,stageAll as u,stashDrop as d,stashPop as f,stashPush as p,statusPorcelain as m,updateRef as h,writeTree as g}from"./port-dispatch.js";import{listRefsWithPeeledSha as _}from"./utils/refs.js";import{reparentDescendants as v}from"./reparent.js";import{buildPatchForCommit as y}from"./utils/patch-builder.js";import{randomUUID as b}from"node:crypto";import*as x from"node:fs";import*as S from"node:os";import*as C from"node:path";async function w(n){let{git:r,baseSha:a,commits:o,hunks:c,signing:l,authorAttribution:u=`plurality`}=n,d=C.join(S.tmpdir(),`compose-idx-${b()}`),f=a,p=new Map,m=new Set;try{for(let n of o){let a=n.hunkIndices.map(t=>{let n=c.find(e=>e.index===t);if(!n)throw new e(`OPERATION_FAILED`,`Hunk index ${t} not found`);return n}),o=y(a.map(e=>m.has(e.diffHeader)?{...e,diffHeader:B(e.diffHeader,e.fileName)}:e));if(!o.trim())throw new e(`OPERATION_FAILED`,`Empty patch for commit: ${n.message}`);let h={GIT_INDEX_FILE:d};await s(r,f,{indexEnv:h});try{await t(r,o,{indexEnv:h})}catch(t){let r=t instanceof Error?t.message:String(t);throw new e(`OPERATION_FAILED`,`git apply failed for commit '${n.message.split(`
2
+ `)[0]}': ${r}`,{cause:t})}for(let e of a)e.originalFileName&&e.diffHeader.includes(`rename from`)&&m.add(e.diffHeader);let _=await g(r,{indexEnv:h}),v=I(a,u),b=R(n.message,v.coAuthors),x=await i(r,_,[f],b,{signing:l,author:v.author});p.set(n.id,x),f=x}return{headSha:f,shaMap:p}}finally{z(d)}}async function T(e){let{git:t,newHeadSha:i,branchName:a,stashLabel:o,leftoverPatch:s}=e,l=o??O,u=await k(t,l);try{a?await r(t,a,{createBranch:!0,startPoint:i}):await c(t,i,`hard`),s&&await n(t,s)}catch(e){throw u!=null&&await j(t,u,l),e}if(u!=null){let e=await j(t,u,l);if(e.conflicted)return{stashConflict:{stashLabel:l,conflictedFiles:e.conflictedFiles}}}return{}}async function E(e){let{git:t,branch:i,rangeTo:o,newHeadSha:s,originalBranchSha:l,stashLabel:u,leftoverPatch:d,includeWorkdirActive:f}=e,p=await a(t)===i,m=u??O,g=p&&!f?await k(t,m):null,_,y=s,b=[];try{let e=await v({git:t,oldToSha:o,newTopSha:s,sourceBranchName:i});for(let t of e.reparentedRefs)b.push({refname:t.refname,previousSha:t.oldSha,newSha:t.newSha});e.sourceBranchNewSha==null?(await h(t,`refs/heads/${i}`,s),b.push({refname:`refs/heads/${i}`,previousSha:l,newSha:s}),y=s):y=e.sourceBranchNewSha;let a=await D(t,o,s,`refs/heads/${i}`);b.push(...a),p&&(await r(t,i),await c(t,y,`hard`),g==null&&d&&await n(t,d))}catch(e){throw g!=null&&await j(t,g,m),e}if(g!=null){let e=await j(t,g,m);e.conflicted&&(_={stashLabel:m,conflictedFiles:e.conflictedFiles})}return{finalTipSha:y,refMovements:b,stashConflict:_}}async function D(e,t,n,r){let i=[];for(let{refname:a,sha:o}of await _(e,{pointsAt:t}))a!==r&&(!a.startsWith(`refs/heads/`)&&!a.startsWith(`refs/tags/`)||o===t&&(await h(e,a,n),i.push({refname:a,previousSha:t,newSha:n})));return i}const O=`compose backup`;async function k(e,t){if(!(await m(e)).trim())return null;await p(e,{message:t,includeUntracked:!0});try{let t=(await l(e,`stash@{0}`)).trim();return t.length>0?t:null}catch{return null}}async function A(e,t){if(!e.exec)return null;let n;try{n=await e.exec([`stash`,`list`,`--format=%H %gd`],{env:o})}catch{return null}for(let e of n.split(`
3
+ `)){let n=e.indexOf(` `);if(!(n<0)&&e.slice(0,n)===t){let t=e.slice(n+1).trim();return t.length>0?t:null}}return null}async function j(e,t,n){try{let n=(await l(e,`${t}^{tree}`)).trim(),r=await M(e);if(n.length>0&&n===r){let n=await A(e,t);if(n!=null)try{await d(e,n)}catch{}return{conflicted:!1,conflictedFiles:[]}}}catch{}let r=await A(e,t);if(r==null)return{conflicted:!0,conflictedFiles:[]};let i;try{return await f(e,{withIndex:!0,ref:r}),{conflicted:!1,conflictedFiles:[]}}catch(e){i=e}let a=await F(e);if(a.length>0)return{conflicted:!0,conflictedFiles:a};if(N(i)){let n=await A(e,t);if(n!=null)try{await d(e,n)}catch{}return{conflicted:!1,conflictedFiles:[]}}return{conflicted:!0,conflictedFiles:[]}}async function M(e){let t=C.join(S.tmpdir(),`compose-equiv-${b()}`);try{let n={GIT_INDEX_FILE:t};return await s(e,`HEAD`,{indexEnv:n}),await u(e,{indexEnv:n}),(await g(e,{indexEnv:n})).trim()}finally{z(t)}}function N(e){let t=P(e);return t.includes(`already exists, no checkout`)||t.includes(`could not restore untracked files from stash`)}function P(e){if(e==null)return``;if(e instanceof Error){let t=e.stderr;return t!=null&&t.length>0?`${e.message}\n${t}`:e.message}return String(e)}async function F(e){try{let t=await m(e),n=[];for(let e of t.split(`
5
4
  `))/^(UU|AA|DD|AU|UA|UD|DU) /.test(e)&&n.push(e.slice(3).trim());return n}catch{return[]}}function I(e,t){let n=e.filter(e=>e.author!=null);if(n.length===0)return{author:void 0,coAuthors:[]};let r;if(typeof t==`function`)r=t(e);else if(t===`first-hunk`)r=n[0].author;else{let e=new Map;for(let t of n){let n=L(t.author),r=e.get(n);r?r.count++:e.set(n,{identity:t.author,count:1})}let t=0;for(let{identity:n,count:i}of e.values())i>t&&(r=n,t=i)}let i=new Map;for(let t of e){t.author&&i.set(L(t.author),t.author);for(let e of t.coAuthors??[])i.set(L(e),e)}let a=r?L(r):null,o=[];for(let[e,t]of i)e!==a&&o.push(t);return{author:r,coAuthors:o}}function L(e){return`${e.name.toLowerCase()}\0${e.email.toLowerCase()}`}function R(e,t){if(t.length===0)return e;let n=t.map(e=>`Co-authored-by: ${e.name} <${e.email}>`).join(`
6
5
  `);return`${e.trimEnd()}\n\n${n}\n`}function z(e){try{x.unlinkSync(e)}catch{}}function B(e,t){let n=e.split(`
7
6
  `),r=[];for(let e of n)e.startsWith(`diff --git `)?r.push(`diff --git a/${t} b/${t}`):e.startsWith(`similarity index`)||e.startsWith(`rename from`)||e.startsWith(`rename to`)||(e.startsWith(`--- a/`)?r.push(`--- a/${t}`):r.push(e));return r.join(`
@@ -1,5 +1,4 @@
1
- import{GitError as e}from"../errors/git.js";import{commitTree as t,forEachRef as n,log as r,mergeBaseIsAncestor as i,updateRef as a}from"./port-dispatch.js";async function o(n){let{git:r,oldToSha:i,newTopSha:o,sourceBranchName:u,signing:d}=n,f=await s(r,i);if(f.length===0)return{reparentedRefs:[]};let p=await c(r,i,f.map(e=>e.sha)),m=new Map;m.set(i,o);for(let e of p){if(m.has(e))continue;let n=await l(r,e),i=n.parents.map(e=>m.get(e)??e),a=await t(r,n.tree,i,n.message,{author:n.author,signing:d});m.set(e,a)}let h=u?`refs/heads/${u}`:void 0,g,_=[];for(let t of f){let n=m.get(t.sha);if(n==null)throw new e(`OPERATION_FAILED`,`Internal error during descendant re-parenting: ref ${t.refname} at ${t.sha.slice(0,8)} did not appear in the downstream walk.`);await a(r,t.refname,n),_.push({refname:t.refname,oldSha:t.sha,newSha:n}),h!=null&&t.refname===h&&(g=n)}return{reparentedRefs:_,sourceBranchNewSha:g}}async function s(e,t){let r=await n(e,{format:`%(refname) %(objectname)`}),a=[];for(let n of r.split(`
2
- `)){let r=n.trim();if(!r)continue;let o=r.indexOf(` `);if(o<0)continue;let s=r.slice(0,o),c=r.slice(o+1).trim();!s.startsWith(`refs/heads/`)&&!s.startsWith(`refs/tags/`)||c!==t&&await i(e,t,c)&&a.push({refname:s,sha:c})}return a}async function c(e,t,n){return(await r(e,{format:`%H`,range:`${n.join(` `)} ^${t}`,reverse:!0,topoOrder:!0})).trim().split(`
3
- `).filter(Boolean)}async function l(t,n){let i=(await r(t,{format:`%T%n%P%n%an%n%ae%n%aI%n%B`,sha:n})).split(`
4
- `);if(i.length<5)throw new e(`OPERATION_FAILED`,`Unable to read commit info for ${n.slice(0,8)}`);let a=i[0].trim(),o=i[1].trim(),s=o?o.split(/\s+/):[],c=i[2],l=i[3],u=i[4],d=i.slice(5).join(`
1
+ import{GitError as e}from"../errors/git.js";import{commitTree as t,log as n,mergeBaseIsAncestor as r,updateRef as i}from"./port-dispatch.js";import{listRefsWithPeeledSha as a}from"./utils/refs.js";async function o(n){let{git:r,oldToSha:a,newTopSha:o,sourceBranchName:u,signing:d}=n,f=await s(r,a);if(f.length===0)return{reparentedRefs:[]};let p=await c(r,a,f.map(e=>e.sha)),m=new Map;m.set(a,o);for(let e of p){if(m.has(e))continue;let n=await l(r,e),i=n.parents.map(e=>m.get(e)??e),a=await t(r,n.tree,i,n.message,{author:n.author,signing:d});m.set(e,a)}let h=u?`refs/heads/${u}`:void 0,g,_=[];for(let t of f){let n=m.get(t.sha);if(n==null)throw new e(`OPERATION_FAILED`,`Internal error during descendant re-parenting: ref ${t.refname} at ${t.sha.slice(0,8)} did not appear in the downstream walk.`);await i(r,t.refname,n),_.push({refname:t.refname,oldSha:t.sha,newSha:n}),h!=null&&t.refname===h&&(g=n)}return{reparentedRefs:_,sourceBranchNewSha:g}}async function s(e,t){let n=[];for(let{refname:i,sha:o}of await a(e))!i.startsWith(`refs/heads/`)&&!i.startsWith(`refs/tags/`)||o!==t&&await r(e,t,o)&&n.push({refname:i,sha:o});return n}async function c(e,t,r){return(await n(e,{format:`%H`,range:`${r.join(` `)} ^${t}`,reverse:!0,topoOrder:!0})).trim().split(`
2
+ `).filter(Boolean)}async function l(t,r){let i=(await n(t,{format:`%T%n%P%n%an%n%ae%n%aI%n%B`,sha:r})).split(`
3
+ `);if(i.length<5)throw new e(`OPERATION_FAILED`,`Unable to read commit info for ${r.slice(0,8)}`);let a=i[0].trim(),o=i[1].trim(),s=o?o.split(/\s+/):[],c=i[2],l=i[3],u=i[4],d=i.slice(5).join(`
5
4
  `).trimEnd(),f=u?new Date(u):void 0;return{tree:a,parents:s,author:{name:c,email:l,date:f&&!Number.isNaN(f.getTime())?f:void 0},message:d}}export{o as reparentDescendants};
@@ -1,5 +1,5 @@
1
1
  import { ComposeGitPort } from "../ports/git.js";
2
- import { ComposeSource, ComposeTarget } from "../workflows/types.js";
2
+ import { ComposeSource, ComposeTarget, OverrideSafetyFailures } from "../workflows/types.js";
3
3
 
4
4
  //#region src/git/safety-range.d.ts
5
5
  interface InteriorForkViolation {
@@ -19,6 +19,8 @@ interface ValidateRewriteRangeSafetyParams {
19
19
  git: ComposeGitPort;
20
20
  source: ComposeSource;
21
21
  target: ComposeTarget;
22
+ /** Opt-out switches for individual checks. When `interiorRefs` is set, the interior-ref check is skipped. */
23
+ overrides?: OverrideSafetyFailures;
22
24
  }
23
25
  /**
24
26
  * Run the interior-fork and interior-ref checks against the source range.
@@ -27,7 +29,17 @@ interface ValidateRewriteRangeSafetyParams {
27
29
  *
28
30
  * No-op when the target doesn't rewrite history (`head`, `branch` with
29
31
  * `preserveOriginal: true`, `auto` with `preserveOriginal: true`).
32
+ *
33
+ * `overrides.interiorRefs` suppresses the interior-ref check only; the
34
+ * interior-fork check always runs (its violations can truly orphan commits).
30
35
  */
31
36
  declare function validateRewriteRangeSafety(params: ValidateRewriteRangeSafetyParams): Promise<void>;
37
+ /**
38
+ * Report the refs (branches, tags, or detached HEAD) pointing at commits inside the range a rewrite
39
+ * of `source` would replace — the interior-ref violations {@link validateRewriteRangeSafety} would
40
+ * raise, returned instead of thrown so a consumer can warn before composing with the `interiorRefs`
41
+ * override set. Empty when the source carries no range (e.g. workdir).
42
+ */
43
+ declare function detectInteriorRefs(git: ComposeGitPort, source: ComposeSource): Promise<InteriorRefViolation[]>;
32
44
  //#endregion
33
- export { InteriorForkViolation, InteriorRefViolation, RewriteSafetyViolation, ValidateRewriteRangeSafetyParams, validateRewriteRangeSafety };
45
+ export { InteriorForkViolation, InteriorRefViolation, RewriteSafetyViolation, ValidateRewriteRangeSafetyParams, detectInteriorRefs, validateRewriteRangeSafety };
@@ -1,5 +1,4 @@
1
- import{ComposeWorkflowInputError as e}from"../errors/workflow.js";import{currentBranch as t,forEachRef as n,log as r,mergeBase as i,mergeBaseIsAncestor as a,revListChildren as o,revParse as s}from"./port-dispatch.js";async function c(t){let{git:n,source:r,target:i}=t;if(!l(r,i))return;let o=await u(n,r);if(o==null)return;let{sourceBranchName:s,parentOfFromSha:c,toSha:m}=o;if(!await a(n,c,m))return;let h=[],g=await d(n,c,m);h.push(...g);let _=await f(n,c,m,s);if(h.push(..._),h.length>0)throw new e(p(h))}function l(e,t){return t.type===`rewrite-range`?!0:(t.type===`branch`||t.type===`auto`)&&e.type===`branch`?!t.preserveOriginal:!1}async function u(t,n){if(n.type===`workdir`)return null;if(n.type===`branch`){let e=n.mergeTarget??`main`,r=await s(t,n.name),a=await i(t,await s(t,e),r);return{sourceBranchName:n.name,parentOfFromSha:a,toSha:r}}let r=await s(t,n.from),a=await s(t,n.to),o;try{o=await s(t,`${r}^`)}catch{throw new e(`Cannot rewrite commit ${r.slice(0,8)} — it has no parent (root commit). Rewriting the root commit is not currently supported.`)}return{sourceBranchName:n.branch,parentOfFromSha:o,toSha:a}}async function d(e,t,n){let i=await r(e,{format:`%H`,range:`${t}..${n}`}),a=new Set(i.trim().split(`
2
- `).filter(Boolean)),s=await o(e,{range:`${t}..${n}`}),c=[];for(let e of s){if(e.sha===n||!a.has(e.sha))continue;if(e.children.length!==1){c.push({kind:`interior-fork`,sha:e.sha,children:e.children,reason:`multiple-children`});continue}let t=e.children[0];a.has(t)||c.push({kind:`interior-fork`,sha:e.sha,children:e.children,reason:`child-outside-range`})}return c}async function f(e,i,a,o){let c=await r(e,{format:`%H`,range:`${i}..${a}`}),l=new Set(c.trim().split(`
3
- `).filter(Boolean));l.delete(a);let u=await n(e,{format:`%(refname) %(objectname)`}),d=`refs/heads/${o}`,f=[];for(let e of u.split(`
4
- `)){let t=e.trim();if(!t)continue;let n=t.indexOf(` `);if(n<0)continue;let r=t.slice(0,n),i=t.slice(n+1).trim();r!==d&&(!r.startsWith(`refs/heads/`)&&!r.startsWith(`refs/tags/`)||l.has(i)&&f.push({kind:`interior-ref`,refname:r,sha:i}))}if(await t(e)==null){let t=await s(e,`HEAD`);l.has(t)&&f.push({kind:`interior-ref`,refname:`HEAD (detached)`,sha:t})}return f}function p(e){let t=[`Cannot safely rewrite the source range — applying the rewrite would orphan refs or lose history outside the range.`,``],n=e.filter(e=>e.kind===`interior-fork`),r=e.filter(e=>e.kind===`interior-ref`);if(n.length>0){t.push(`Interior fork(s) detected — every commit in the rewritten range (except the tip) must have exactly one child within the range:`);for(let e of n){let n=e.children.length>0?e.children.map(e=>e.slice(0,8)).join(`, `):`(none)`,r=e.reason===`multiple-children`?`has ${e.children.length} children`:`has a child that is outside the rewritten range`;t.push(` - ${e.sha.slice(0,8)}: ${r} (${n})`)}t.push(``)}if(r.length>0){t.push(`Refs pointing at interior commits — these would be orphaned by the rewrite:`);for(let e of r)t.push(` - ${e.refname} → ${e.sha.slice(0,8)}`);t.push(``),t.push(`To proceed: delete the offending refs (or move them outside the range), or check out a stable ref if HEAD is detached at an interior commit, then re-run.`)}return t.join(`
5
- `)}export{c as validateRewriteRangeSafety};
1
+ import{ComposeWorkflowInputError as e}from"../errors/workflow.js";import{currentBranch as t,log as n,mergeBase as r,mergeBaseIsAncestor as i,revListChildren as a,revParse as o}from"./port-dispatch.js";import{listRefsWithPeeledSha as s}from"./utils/refs.js";async function c(t){let{git:n,source:r,target:a,overrides:o}=t;if(!u(r,a))return;let s=await d(n,r);if(s==null)return;let{sourceBranchName:c,parentOfFromSha:l,toSha:h}=s;if(!await i(n,l,h))return;let g=[],_=await f(n,l,h);if(g.push(..._),!o?.interiorRefs){let e=await p(n,l,h,c);g.push(...e)}if(g.length>0)throw new e(m(g))}async function l(e,t){let n=await d(e,t);return n==null?[]:p(e,n.parentOfFromSha,n.toSha,n.sourceBranchName)}function u(e,t){return t.type===`rewrite-range`?!0:(t.type===`branch`||t.type===`auto`)&&e.type===`branch`?!t.preserveOriginal:!1}async function d(t,n){if(n.type===`workdir`)return null;if(n.type===`branch`){let e=n.mergeTarget??`main`,i=await o(t,n.name),a=await r(t,await o(t,e),i);return{sourceBranchName:n.name,parentOfFromSha:a,toSha:i}}let i=await o(t,n.from),a=await o(t,n.to),s;try{s=await o(t,`${i}^`)}catch{throw new e(`Cannot rewrite commit ${i.slice(0,8)} — it has no parent (root commit). Rewriting the root commit is not currently supported.`)}return{sourceBranchName:n.branch,parentOfFromSha:s,toSha:a}}async function f(e,t,r){let i=await n(e,{format:`%H`,range:`${t}..${r}`}),o=new Set(i.trim().split(`
2
+ `).filter(Boolean)),s=await a(e,{range:`${t}..${r}`}),c=[];for(let e of s){if(e.sha===r||!o.has(e.sha))continue;if(e.children.length!==1){c.push({kind:`interior-fork`,sha:e.sha,children:e.children,reason:`multiple-children`});continue}let t=e.children[0];o.has(t)||c.push({kind:`interior-fork`,sha:e.sha,children:e.children,reason:`child-outside-range`})}return c}async function p(e,r,i,a){let c=await n(e,{format:`%H`,range:`${r}..${i}`}),l=new Set(c.trim().split(`
3
+ `).filter(Boolean));l.delete(i);let u=`refs/heads/${a}`,d=[];for(let{refname:t,sha:n}of await s(e))t!==u&&(!t.startsWith(`refs/heads/`)&&!t.startsWith(`refs/tags/`)||l.has(n)&&d.push({kind:`interior-ref`,refname:t,sha:n}));if(await t(e)==null){let t=await o(e,`HEAD`);l.has(t)&&d.push({kind:`interior-ref`,refname:`HEAD (detached)`,sha:t})}return d}function m(e){let t=[`Cannot safely rewrite the source range — applying the rewrite would orphan refs or lose history outside the range.`,``],n=e.filter(e=>e.kind===`interior-fork`),r=e.filter(e=>e.kind===`interior-ref`);if(n.length>0){t.push(`Interior fork(s) detected — every commit in the rewritten range (except the tip) must have exactly one child within the range:`);for(let e of n){let n=e.children.length>0?e.children.map(e=>e.slice(0,8)).join(`, `):`(none)`,r=e.reason===`multiple-children`?`has ${e.children.length} children`:`has a child that is outside the rewritten range`;t.push(` - ${e.sha.slice(0,8)}: ${r} (${n})`)}t.push(``)}if(r.length>0){t.push(`Refs pointing at interior commits — these would have deviating history and would not follow the new commits after the rewrite:`);for(let e of r)t.push(` - ${e.refname} → ${e.sha.slice(0,8)}`);t.push(``),t.push(`To proceed: delete the offending refs (or move them outside the range), or check out a stable ref if HEAD is detached at an interior commit, then re-run.`)}return t.join(`
4
+ `)}export{l as detectInteriorRefs,c as validateRewriteRangeSafety};
@@ -34,6 +34,14 @@ interface ApplyMutations {
34
34
  createdRefs: RefSnapshot[];
35
35
  deletedRefs: RefSnapshot[];
36
36
  movedRefs: RefMovement[];
37
+ /**
38
+ * Number of branches the compose operation produced as its output. Cannot be derived from
39
+ * `movedRefs`: when a source branch is deleted (or a range rewritten), `movedRefs` also
40
+ * accumulates reparented descendants and tip-refs that moved alongside the source — those are
41
+ * collateral repositioning, not composed output. This count is set where that distinction is
42
+ * still known, so the undo manifest description doesn't report "5 branches" for a 1-branch compose.
43
+ */
44
+ composedBranchCount: number;
37
45
  }
38
46
  /** Reported when the apply path's stash pop hit conflicts. The apply itself succeeded. */
39
47
  interface StashConflict {
@@ -0,0 +1,18 @@
1
+ import { ComposeGitPort } from "../../ports/git.js";
2
+
3
+ //#region src/git/utils/refs.d.ts
4
+ /**
5
+ * List refs as `{ refname, sha }`, resolving each to the commit it ultimately points at.
6
+ * `%(*objectname)` is the peeled target of an annotated tag (the commit); it's empty for lightweight
7
+ * tags and branches, where `%(objectname)` is already the commit. Reading `%(objectname)` alone
8
+ * yields the tag *object's* SHA for annotated tags, which never matches a commit — so any caller
9
+ * comparing refs against commits must peel. Pass `pointsAt` to filter to refs at one object.
10
+ */
11
+ declare function listRefsWithPeeledSha(git: ComposeGitPort, opts?: {
12
+ pointsAt?: string;
13
+ }): Promise<{
14
+ refname: string;
15
+ sha: string;
16
+ }[]>;
17
+ //#endregion
18
+ export { listRefsWithPeeledSha };
@@ -0,0 +1,2 @@
1
+ import{forEachRef as e}from"../port-dispatch.js";async function t(t,n){let r=await e(t,{format:`%(refname) %(objectname) %(*objectname)`,pointsAt:n?.pointsAt}),i=[];for(let e of r.split(`
2
+ `)){let t=e.trim().split(/\s+/).filter(Boolean);t.length<2||i.push({refname:t[0],sha:t[2]??t[1]})}return i}export{t as listRefsWithPeeledSha};
package/dist/index.d.ts CHANGED
@@ -14,9 +14,10 @@ import { OrderInput, OrderOutput, OrderingResult } from "./tasks/order/types.js"
14
14
  import { QuickComposeInput, QuickComposeOutput, QuickComposeResult } from "./tasks/quick-compose/types.js";
15
15
  import { ComposeSession } from "./tasks/session.js";
16
16
  import { PartitionInput, PartitionOutput, PartitionResult, PartitionStrategy } from "./tasks/partition/types.js";
17
- import { ComposeContext, ComposeSource, ComposeTarget } from "./workflows/types.js";
17
+ import { ComposeContext, ComposeSource, ComposeTarget, OverrideSafetyFailures } from "./workflows/types.js";
18
18
  import { StashConflict } from "./git/types.js";
19
19
  import { ComposeBranchPlan } from "./git/apply-target.js";
20
+ import { InteriorRefViolation, detectInteriorRefs } from "./git/safety-range.js";
20
21
  import { analyze } from "./tasks/analyze/analyze.js";
21
22
  import { ComposeGroupInput, ComposeGroupOutput } from "./tasks/compose-group/types.js";
22
23
  import { composeGroup } from "./tasks/compose-group/compose-group.js";
@@ -24,8 +25,9 @@ import { group } from "./tasks/group/group.js";
24
25
  import { order } from "./tasks/order/order.js";
25
26
  import { partition } from "./tasks/partition/partition.js";
26
27
  import { quickCompose } from "./tasks/quick-compose/quick-compose.js";
27
- import { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, ComposeDepth, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, ComposeStrategy, applyComposePlan, compose, composePlan, defaultPhaseMessages } from "./workflows/compose.js";
28
+ import { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, ComposeDepth, ComposeDiffStats, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, ComposeStrategy, applyComposePlan, compose, composePlan, defaultPhaseMessages } from "./workflows/compose.js";
29
+ import { RefinePlanInput, RefinePlanResult, RefineProgressEvent, refinePlan } from "./workflows/refine.js";
28
30
  import { SplitInput, SplitPlan, SplitProgressEvent, SplitResult, split } from "./workflows/split.js";
29
31
  import { UndoComposeInput, ValidateUndoComposeInput, listUndoIds, undoCompose, validateUndoCompose } from "./workflows/undo.js";
30
32
  import { UndoForceOptions } from "@gitkraken/shared-tools";
31
- export { AIError, type AIErrorCode, type AfterModelCallInfo, type AnalysisResult, type AnalyzeInput, type AnalyzeOutput, type ApplyComposePlanInput, type ApplyComposePlanResult, type ApplyPatchOptions, type BeforeModelCallInfo, type BeforePromptInfo, type BranchGroup, type BranchOrdering, type BranchPartition, type CheckoutOptions, type CleanOptions, type CommitDetail, type CommitSuggestion, type CommitTreeOptions, type ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, type ComposeDepth, type ComposeGitOps, type ComposeGitPort, ComposeGitPortMissingOpError, type ComposeGroupInput, type ComposeGroupOutput, type ComposeHunk, type ComposeInput, type ComposePlan, type ComposePlanInput, type ComposePlanResult, type ComposeProgressEvent, type ComposeResult, type ComposeSession, type ComposeSource, type ComposeStrategy, type ComposeTarget, type ComposeTaskName, ComposeWorkflowError, type ComposeWorkflowErrorCode, ComposeWorkflowInputError, type Concern, type ConcernDependency, type ConventionDescriptor, type DeleteBranchOptions, type DiffTreeOptions, type ForEachRefOptions, type GitCommitIdentity, GitError, type GitErrorCode, type GroupInput, type GroupOutput, type GroupingResult, type GroupingStrategy, type HunkAnnotation, type HunkOrderingConstraint, type IndexScopedOptions, type LogOptions, type OnAfterModelCall, type OnBeforeModelCall, type OnBeforePrompt, type OpOptions, type OrderInput, type OrderOutput, type OrderingResult, type PartitionInput, type PartitionOutput, type PartitionResult, type PartitionStrategy, type PhaseDetail, type ProgressEvent, type PromptOverride, type QuickComposeInput, type QuickComposeOutput, type QuickComposeResult, REDACTED_HUNK_CONTENT, type RedactHunkContent, type SigningConfig, type SplitInput, type SplitPlan, type SplitProgressEvent, type SplitResult, type StageAllOptions, type StashConflict, type StashPushOptions, TaskInputError, type TokenUsage, type UndoComposeInput, type UndoForceOptions, type UpdateRefOptions, type ValidateUndoComposeInput, analyze, applyComposePlan, compose, composeGroup, composePlan, defaultPhaseMessages, estimatedCharactersPerToken, group, listUndoIds, order, partition, quickCompose, split, undoCompose, validateUndoCompose };
33
+ export { AIError, type AIErrorCode, type AfterModelCallInfo, type AnalysisResult, type AnalyzeInput, type AnalyzeOutput, type ApplyComposePlanInput, type ApplyComposePlanResult, type ApplyPatchOptions, type BeforeModelCallInfo, type BeforePromptInfo, type BranchGroup, type BranchOrdering, type BranchPartition, type CheckoutOptions, type CleanOptions, type CommitDetail, type CommitSuggestion, type CommitTreeOptions, type ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, type ComposeDepth, type ComposeDiffStats, type ComposeGitOps, type ComposeGitPort, ComposeGitPortMissingOpError, type ComposeGroupInput, type ComposeGroupOutput, type ComposeHunk, type ComposeInput, type ComposePlan, type ComposePlanInput, type ComposePlanResult, type ComposeProgressEvent, type ComposeResult, type ComposeSession, type ComposeSource, type ComposeStrategy, type ComposeTarget, type ComposeTaskName, ComposeWorkflowError, type ComposeWorkflowErrorCode, ComposeWorkflowInputError, type Concern, type ConcernDependency, type ConventionDescriptor, type DeleteBranchOptions, type DiffTreeOptions, type ForEachRefOptions, type GitCommitIdentity, GitError, type GitErrorCode, type GroupInput, type GroupOutput, type GroupingResult, type GroupingStrategy, type HunkAnnotation, type HunkOrderingConstraint, type IndexScopedOptions, type InteriorRefViolation, type LogOptions, type OnAfterModelCall, type OnBeforeModelCall, type OnBeforePrompt, type OpOptions, type OrderInput, type OrderOutput, type OrderingResult, type OverrideSafetyFailures, type PartitionInput, type PartitionOutput, type PartitionResult, type PartitionStrategy, type PhaseDetail, type ProgressEvent, type PromptOverride, type QuickComposeInput, type QuickComposeOutput, type QuickComposeResult, REDACTED_HUNK_CONTENT, type RedactHunkContent, type RefinePlanInput, type RefinePlanResult, type RefineProgressEvent, type SigningConfig, type SplitInput, type SplitPlan, type SplitProgressEvent, type SplitResult, type StageAllOptions, type StashConflict, type StashPushOptions, TaskInputError, type TokenUsage, type UndoComposeInput, type UndoForceOptions, type UpdateRefOptions, type ValidateUndoComposeInput, analyze, applyComposePlan, compose, composeGroup, composePlan, defaultPhaseMessages, detectInteriorRefs, estimatedCharactersPerToken, group, listUndoIds, order, partition, quickCompose, refinePlan, split, undoCompose, validateUndoCompose };
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{AIError as e}from"./errors/ai.js";import{GitError as t}from"./errors/git.js";import{TaskInputError as n}from"./errors/task.js";import{ComposeWorkflowError as r,ComposeWorkflowInputError as i}from"./errors/workflow.js";import{ComposeGitPortMissingOpError as a}from"./ports/git.js";import{estimatedCharactersPerToken as o}from"./tasks/ai-loop.js";import{REDACTED_HUNK_CONTENT as s}from"./tasks/utils/redact.js";import{analyze as c}from"./tasks/analyze/analyze.js";import{composeGroup as l}from"./tasks/compose-group/compose-group.js";import{group as u}from"./tasks/group/group.js";import{order as d}from"./tasks/order/order.js";import{partition as f}from"./tasks/partition/partition.js";import{quickCompose as p}from"./tasks/quick-compose/quick-compose.js";import{applyComposePlan as m,compose as h,composePlan as g,defaultPhaseMessages as _}from"./workflows/compose.js";import{split as v}from"./workflows/split.js";import{listUndoIds as y,undoCompose as b,validateUndoCompose as x}from"./workflows/undo.js";export{e as AIError,a as ComposeGitPortMissingOpError,r as ComposeWorkflowError,i as ComposeWorkflowInputError,t as GitError,s as REDACTED_HUNK_CONTENT,n as TaskInputError,c as analyze,m as applyComposePlan,h as compose,l as composeGroup,g as composePlan,_ as defaultPhaseMessages,o as estimatedCharactersPerToken,u as group,y as listUndoIds,d as order,f as partition,p as quickCompose,v as split,b as undoCompose,x as validateUndoCompose};
1
+ import{AIError as e}from"./errors/ai.js";import{GitError as t}from"./errors/git.js";import{TaskInputError as n}from"./errors/task.js";import{ComposeWorkflowError as r,ComposeWorkflowInputError as i}from"./errors/workflow.js";import{ComposeGitPortMissingOpError as a}from"./ports/git.js";import{detectInteriorRefs as o}from"./git/safety-range.js";import{estimatedCharactersPerToken as s}from"./tasks/ai-loop.js";import{REDACTED_HUNK_CONTENT as c}from"./tasks/utils/redact.js";import{analyze as l}from"./tasks/analyze/analyze.js";import{composeGroup as u}from"./tasks/compose-group/compose-group.js";import{group as d}from"./tasks/group/group.js";import{order as f}from"./tasks/order/order.js";import{partition as p}from"./tasks/partition/partition.js";import{quickCompose as m}from"./tasks/quick-compose/quick-compose.js";import{applyComposePlan as h,compose as g,composePlan as _,defaultPhaseMessages as v}from"./workflows/compose.js";import{refinePlan as y}from"./workflows/refine.js";import{split as b}from"./workflows/split.js";import{listUndoIds as x,undoCompose as S,validateUndoCompose as C}from"./workflows/undo.js";export{e as AIError,a as ComposeGitPortMissingOpError,r as ComposeWorkflowError,i as ComposeWorkflowInputError,t as GitError,c as REDACTED_HUNK_CONTENT,n as TaskInputError,l as analyze,h as applyComposePlan,g as compose,u as composeGroup,_ as composePlan,v as defaultPhaseMessages,o as detectInteriorRefs,s as estimatedCharactersPerToken,d as group,x as listUndoIds,f as order,p as partition,m as quickCompose,y as refinePlan,b as split,S as undoCompose,C as validateUndoCompose};
@@ -0,0 +1,31 @@
1
+ import { CommitSuggestion } from "../types.js";
2
+
3
+ //#region src/tasks/refine/prompt.d.ts
4
+ interface BuildRefinementPromptParams {
5
+ instructions?: string;
6
+ lockedCommits: readonly CommitSuggestion[];
7
+ clientContext?: string;
8
+ hunkCount: number;
9
+ mode: 'commits' | 'branches';
10
+ }
11
+ /**
12
+ * Build the user-prompt for the refine task. Sections in order:
13
+ *
14
+ * 1. Framing — short, directive, emphasizes that EVERY commit must include id, message,
15
+ * explanation, AND hunks. The "validator already accepted" language is intentionally
16
+ * omitted because some models read it as license to emit a delta-only summary.
17
+ * 2. Client-supplied context (e.g. UI-ordering hint) — verbatim, before the locked block
18
+ * so the AI has the framing before reading positional references.
19
+ * 3. Locked commits — plain-text descriptions (NOT JSON snippets). Earlier versions listed
20
+ * locked commits as JSON objects and the model started emitting output in the locked-
21
+ * list's shape, dropping the wrapper and the `hunks` field on non-locked commits. Plain
22
+ * text gives the AI nothing to mistake for a target schema.
23
+ * 4. User instructions — placed near the end of the prompt so the model attends to them
24
+ * strongly (recency bias).
25
+ * 5. Output format + concrete worked example — a real-looking JSON example with all four
26
+ * required fields per commit. No JS-comment placeholders inside the template, because
27
+ * the model interprets those as "leave that key out."
28
+ */
29
+ declare function buildRefinementPrompt(params: BuildRefinementPromptParams): string;
30
+ //#endregion
31
+ export { buildRefinementPrompt };
@@ -0,0 +1,41 @@
1
+ function e(e){let t=[];if(t.push(`The user has reviewed the commit plan you produced in your prior turn (above in our conversation) and is requesting refinements. Produce a complete, updated plan that incorporates their changes — every commit in your output must be fully specified with all required fields (id, message, explanation, and hunks).`),e.clientContext?.trim()&&t.push(e.clientContext.trim()),e.lockedCommits.length>0){let n=e.lockedCommits.map(e=>{let t=e.message.split(`
2
+ `)[0];return` - Commit id "${e.id}" — message starts: "${t}" — hunks: [${e.hunkIndices.join(`, `)}]`}).join(`
3
+ `);t.push(`LOCKED COMMITS (must appear in your output unchanged — same id, same message, same explanation, same hunks):
4
+
5
+ ${n}
6
+
7
+ If any locked commit's id is missing from your output, the validator will reject your response. Locked commits' hunk indices must remain exclusively in those commits — do NOT move them into other commits. You may reposition locked entries in the ordering only if the user explicitly asks; otherwise keep them roughly where they were.
8
+
9
+ The other commits (and their hunks) may be freely restructured (split, merged, reordered, re-messaged) per the user's request below.`)}e.instructions?.trim()?t.push(`USER'S REFINEMENT REQUEST:\n${e.instructions.trim()}`):t.push(`The user did not include specific instructions on this turn. Review your prior plan critically and produce an improved version that addresses any obvious issues with grouping, ordering, or commit messages.`);let n=e.mode===`branches`?`across every commit, across every branch`:`across every commit`,r=e.lockedCommits.length>0?`
10
+ - Every locked commit listed above appears in your output as its own entry, with id/message/hunks unchanged.`:``;return t.push(`OUTPUT FORMAT — emit JSON inside a single <output> tag. Every commit object MUST have ALL FOUR of these keys: "id" (a unique string), "message" (a string), "explanation" (a string describing what/why), and "hunks" (an array of integer indices). Omitting any of these keys — especially "hunks" — is a validation error.
11
+
12
+ This is a concrete example of the structure. Your actual ids, messages, explanations, and hunk assignments will differ — but EVERY commit entry must follow this shape:
13
+
14
+ <output>
15
+ {
16
+ "branches": [
17
+ {
18
+ "id": "default",
19
+ "name": "",
20
+ "title": "",
21
+ "description": "",
22
+ "commits": [
23
+ {"id": "commit-a", "message": "Add changelog entry", "explanation": "Adds a CHANGELOG entry for the new feature.", "hunks": [0, 1]},
24
+ {"id": "commit-b", "message": "Refactor logger", "explanation": "Extracts shared logging code into a helper.", "hunks": [2, 3, 4]}
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+ </output>
30
+
31
+ Constraints (the validator enforces all of these):
32
+ - Each commit object contains "id" (unique string), "message" (string), "explanation" (string), and "hunks" (array of integers). All four keys are required.
33
+ - Each hunk index appears EXACTLY ONCE ${n}.
34
+ - Every hunk index 0..${String(e.hunkCount-1)} appears in some commit's hunks array. Total = ${String(e.hunkCount)} indices.
35
+ - Each commit contains at least one hunk (no empty commits).${r}
36
+
37
+ Before emitting, recount: the union of every commit's "hunks" array must contain exactly the integers 0..${String(e.hunkCount-1)}.
38
+
39
+ Return only the <output> tag and no other text.`),t.join(`
40
+
41
+ `)}export{e as buildRefinementPrompt};
@@ -0,0 +1,19 @@
1
+ import { RefineInput, RefineOutput } from "./types.js";
2
+
3
+ //#region src/tasks/refine/refine.d.ts
4
+ /**
5
+ * Refine a previously-generated plan via a single AI continuation call.
6
+ *
7
+ * The task assumes the input session already carries the prior conversation (the hunks the
8
+ * plan was produced from + the prior plan as an assistant message). It threads the session
9
+ * unchanged into `runAiLoop`, runs ONE model call with a refinement-specific user prompt,
10
+ * and validates the response with locked-commits enforcement + content substitution.
11
+ *
12
+ * Refine intentionally does NOT cache its result into `completedSteps` — each refinement is
13
+ * a fresh chat turn, not a resumable pipeline step. The returned session has the new turn
14
+ * appended (so a follow-up refine can continue the conversation) but `completedSteps` is
15
+ * carried through from the input session unchanged.
16
+ */
17
+ declare function refine(input: RefineInput): Promise<RefineOutput>;
18
+ //#endregion
19
+ export { refine };
@@ -0,0 +1 @@
1
+ import{TaskInputError as e}from"../../errors/task.js";import{runAiLoop as t}from"../ai-loop.js";import{applyUserPromptOverride as n}from"../types.js";import{buildRefinementPrompt as r}from"./prompt.js";import{validateRefineResponse as i}from"./validator.js";async function a(a){if(a.session.messages.length===0)throw new e(`refine: session must contain prior messages from a previous compose call.`);if(a.hunkCount<=0)throw new e(`refine: hunkCount must be a positive integer.`);let o=a.mode??`branches`,s=new Map;for(let e of a.lockedCommits??[])s.set(e.id,e);let c=n(r({instructions:a.instructions,lockedCommits:a.lockedCommits??[],clientContext:a.clientContext,hunkCount:a.hunkCount,mode:o}),a.promptOverride),l=await t({model:a.model,systemPrompt:void 0,userPrompt:c,session:a.session,maxRetries:a.maxRetries,maxTokens:a.maxTokens,onProgress:a.onProgress,cancellation:a.cancellation,onBeforeModelCall:a.onBeforeModelCall,onAfterModelCall:a.onAfterModelCall,onBeforePrompt:a.onBeforePrompt,validate:e=>i(e,{hunkCount:a.hunkCount,mode:o,lockedCommits:s,lockedRuns:a.lockedRuns}),taskName:`refine`}),u={...l.session,completedSteps:a.session.completedSteps};return{result:l.result,session:u,usage:l.usage,attempts:l.attempts,durationMs:l.durationMs,callIds:l.callIds}}export{a as refine};
@@ -0,0 +1,76 @@
1
+ import { ProgressEvent, TokenUsage } from "../../ai/types.js";
2
+ import { CommitSuggestion, PromptOverride } from "../types.js";
3
+ import { OnAfterModelCall, OnBeforeModelCall, OnBeforePrompt } from "../ai-loop.js";
4
+ import { GroupingResult } from "../group/types.js";
5
+ import { ComposeSession } from "../session.js";
6
+ import { AiModelPort } from "@gitkraken/shared-tools";
7
+
8
+ //#region src/tasks/refine/types.d.ts
9
+ /**
10
+ * Input for the `refine` task — a chat-style continuation that updates a previously generated
11
+ * plan based on new user instructions.
12
+ *
13
+ * The task does NOT compose from a diff. It assumes the prior session already contains the
14
+ * hunks + the prior plan in conversation, and instructs the AI to emit an updated plan in
15
+ * the same shape. The task layer handles the AI call, the locked-commits substitution, and
16
+ * validation; the workflow layer materializes locked commits from a `ComposePlan` and lifts
17
+ * the task's `GroupingResult` back into a full `ComposePlan`.
18
+ */
19
+ interface RefineInput {
20
+ /** Session from a prior compose task. MUST contain at least one message — refinement of
21
+ * a cold session is undefined behavior. The task threads this through `runAiLoop`
22
+ * unchanged so the prior conversation (hunks + prior plan) remains the AI's context. */
23
+ session: ComposeSession;
24
+ /** Locked commits the AI must preserve verbatim in the output. The task lists them in the
25
+ * prompt and enforces their presence + substitutes their content (id, message,
26
+ * explanation, hunkIndices) into the AI's response after validation. */
27
+ lockedCommits?: readonly CommitSuggestion[];
28
+ /**
29
+ * The locked commits partitioned into the maximal runs of *consecutive* locked commits
30
+ * (no commit between them) within each branch of the prior plan, in that plan's original
31
+ * order — one entry per run, singletons included. Flattening yields the locked commits'
32
+ * original relative order.
33
+ *
34
+ * The validator uses this to undo AI reshuffling in the post-substitution cleanup: it
35
+ * restores the locked commits' relative order within each output branch and pulls each
36
+ * multi-commit run back together contiguously. Adjacency can't be derived from
37
+ * `lockedCommits` alone (which carries no positional context), so callers driving the task
38
+ * directly must supply it; the workflow computes it from the prior plan. Omit for no
39
+ * reordering.
40
+ */
41
+ lockedRuns?: readonly (readonly string[])[];
42
+ /** Per-call user instructions appended to the refinement prompt. */
43
+ instructions?: string;
44
+ /** Free-text consumer-supplied context (e.g. UI-ordering notes) injected verbatim into
45
+ * the prompt. Library-opaque; the consumer is responsible for content. */
46
+ clientContext?: string;
47
+ /** Total number of hunks the prior plan was produced from. Used to validate that the
48
+ * refined plan still covers every hunk index in `0..hunkCount-1` exactly once. */
49
+ hunkCount: number;
50
+ /** Output mode — must match the mode the prior plan was produced in (the task's locked-
51
+ * commits substitution and re-validation rely on this). Defaults to `'branches'`. */
52
+ mode?: 'commits' | 'branches';
53
+ model: AiModelPort;
54
+ maxRetries?: number;
55
+ /** Output-token cap forwarded to `runAiLoop`. Default 16384. */
56
+ maxTokens?: number;
57
+ onProgress?: (event: ProgressEvent) => void;
58
+ cancellation?: AbortSignal;
59
+ onBeforeModelCall?: OnBeforeModelCall;
60
+ onAfterModelCall?: OnAfterModelCall;
61
+ onBeforePrompt?: OnBeforePrompt;
62
+ /** Optional prompt override for the refine task. */
63
+ promptOverride?: PromptOverride;
64
+ }
65
+ interface RefineOutput {
66
+ /** The refined plan as a `GroupingResult` — same shape `group` / `composeGroup` produce,
67
+ * so workflows can lift it into a `ComposePlan` identically. */
68
+ result: GroupingResult;
69
+ session: ComposeSession;
70
+ usage?: TokenUsage;
71
+ attempts: number;
72
+ durationMs: number;
73
+ callIds: string[];
74
+ }
75
+ //#endregion
76
+ export { RefineInput, RefineOutput };
@@ -0,0 +1 @@
1
+ export{};
@@ -0,0 +1,35 @@
1
+ import { CommitSuggestion } from "../types.js";
2
+ import { ValidationResult } from "../ai-loop.js";
3
+ import { GroupingResult } from "../group/types.js";
4
+
5
+ //#region src/tasks/refine/validator.d.ts
6
+ interface ValidateRefineParams {
7
+ hunkCount: number;
8
+ mode: 'commits' | 'branches';
9
+ lockedCommits: Map<string, CommitSuggestion>;
10
+ /** Maximal runs of consecutive locked commits per prior-plan branch, in original order.
11
+ * See {@link import('./types.js').RefineInput.lockedRuns}. */
12
+ lockedRuns?: readonly (readonly string[])[];
13
+ }
14
+ /**
15
+ * Two-stage validation for the refine task:
16
+ *
17
+ * 1. Base structural validation via `validateGroupResponse` — same invariants the rest of
18
+ * the library enforces (hunk index uniqueness + coverage, non-empty commits, unique ids).
19
+ * 2. Locked-commits check + content substitution + post-substitution invariant check +
20
+ * locked-order restoration (see {@link restoreLockedOrdering}).
21
+ *
22
+ * The substitution step is what makes refine resilient to minor AI drift: when the AI emits
23
+ * a commit with a locked id but slightly different message text, we silently restore the
24
+ * prior message rather than failing validation. The retry path is reserved for the case
25
+ * the AI actually drops a locked commit (or moves its hunks elsewhere such that substitution
26
+ * produces a structurally invalid plan).
27
+ *
28
+ * Note: the post-substitution check operates directly on the `GroupingResult` (which uses
29
+ * `hunkIndices`, the internal field name) — we don't round-trip through `validateGroupResponse`
30
+ * because that validator parses an `<output>`-wrapped JSON whose schema uses `hunks` as the
31
+ * field name. Round-tripping would surface every commit's `hunks` as undefined.
32
+ */
33
+ declare function validateRefineResponse(responseText: string, params: ValidateRefineParams): ValidationResult<GroupingResult>;
34
+ //#endregion
35
+ export { validateRefineResponse };
@@ -0,0 +1 @@
1
+ import{validateGroupResponse as e}from"../group/validator.js";function t(t,r){let i=e(t,r.hunkCount,r.mode);if(!i.valid||i.result===void 0)return i;let o=i.result;if(r.lockedCommits.size===0)return i;let s=new Map;for(let e of o.branches)for(let t=0;t<e.commits.length;t++)s.set(e.commits[t].id,{branch:e,commitIndex:t});let c=[];for(let e of r.lockedCommits.keys())s.has(e)||c.push(e);if(c.length>0){let e=c.map(e=>`'${e}'`).join(`, `);return{valid:!1,errors:[`Missing locked commits: ${e}`],retryFeedback:`Your response is missing the following LOCKED commits, which must always appear in the output: ${e}. Re-emit the plan with these commits present (preserving their id, message, explanation, and hunks exactly).`}}for(let[e,t]of r.lockedCommits){let n=s.get(e);if(n==null)continue;let r=n.branch.commits[n.commitIndex];n.branch.commits[n.commitIndex]={...r,id:t.id,message:t.message,explanation:t.explanation,hunkIndices:[...t.hunkIndices]}}let l=a(o,r.hunkCount);return l==null?(n(o,r.lockedRuns),{valid:!0,result:o}):{valid:!1,errors:[l],retryFeedback:`After restoring the locked commits' original hunk assignments, the resulting plan is structurally invalid: ${l}. This means you assigned hunks belonging to a locked commit to a different commit. Locked commits' hunks must remain exclusive to them — do not move those hunks elsewhere.`}}function n(e,t){if(t==null||t.length===0)return;let n=t.flat(),a=new Map(n.map((e,t)=>[e,t])),o=new Set(n);for(let t of e.branches)r(t,o,a);for(let n of t)n.length>=2&&i(e,n)}function r(e,t,n){let r=[],i=[];for(let n=0;n<e.commits.length;n++)t.has(e.commits[n].id)&&(r.push(n),i.push(e.commits[n]));if(!(i.length<2)){i.sort((e,t)=>(n.get(e.id)??0)-(n.get(t.id)??0));for(let t=0;t<r.length;t++)e.commits[r[t]]=i[t]}}function i(e,t){let n=new Set(t),r=e.branches.filter(e=>e.commits.some(e=>n.has(e.id)));if(r.length!==1)return;let i=r[0],a=[];for(let e of t){let t=i.commits.find(t=>t.id===e);t!=null&&a.push(t)}if(a.length<2)return;let o=Math.min(...a.map(e=>i.commits.indexOf(e))),s=i.commits.slice(0,o).filter(e=>!n.has(e.id)).length,c=i.commits.filter(e=>!n.has(e.id));c.splice(s,0,...a),i.commits=c}function a(e,t){let n=new Set,r=[],i=[],a=new Set,o=[];for(let t of e.branches)for(let e of t.commits){if(a.has(e.id)&&o.push(e.id),a.add(e.id),e.hunkIndices.length===0){i.push(e.id);continue}for(let t of e.hunkIndices)n.has(t)&&r.push(t),n.add(t)}if(o.length>0)return`duplicate commit ids after substitution: ${o.join(`, `)}`;if(i.length>0)return`empty commits after substitution (no hunks assigned): ${i.join(`, `)}`;if(r.length>0)return`hunk indices appear in more than one commit: ${[...new Set(r)].join(`, `)}`;let s=[];for(let e=0;e<t;e++)n.has(e)||s.push(e);if(s.length>0)return`hunk indices missing from the plan: ${s.join(`, `)}`;let c=[...n].filter(e=>e>=t||e<0);if(c.length>0)return`hunk indices outside the valid range 0..${String(t-1)}: ${c.join(`, `)}`}export{t as validateRefineResponse};
@@ -74,7 +74,7 @@ interface BranchPartition {
74
74
  * value passed to `runAiLoop` and the phase label surfaced on {@link
75
75
  * import('../ai/types.js').PhaseDetail}.
76
76
  */
77
- type ComposeTaskName = 'analyze' | 'group' | 'compose-group' | 'order' | 'partition' | 'quick-compose';
77
+ type ComposeTaskName = 'analyze' | 'group' | 'compose-group' | 'order' | 'partition' | 'quick-compose' | 'refine';
78
78
  /**
79
79
  * Per-task prompt override. Every field is optional; undefined fields fall
80
80
  * back to the library's built-in prompt.
@@ -9,7 +9,7 @@ import { GroupingResult, GroupingStrategy } from "../tasks/group/types.js";
9
9
  import { OrderingResult } from "../tasks/order/types.js";
10
10
  import { ComposeSession } from "../tasks/session.js";
11
11
  import { PartitionStrategy } from "../tasks/partition/types.js";
12
- import { ComposeContext, ComposeSource, ComposeTarget } from "./types.js";
12
+ import { ComposeContext, ComposeSource, ComposeTarget, OverrideSafetyFailures } from "./types.js";
13
13
  import { SafetySnapshot, StashConflict } from "../git/types.js";
14
14
  import { ComposeBranchPlan } from "../git/apply-target.js";
15
15
  import { AiModelPort, UndoId } from "@gitkraken/shared-tools";
@@ -47,6 +47,19 @@ interface ComposePlanInput {
47
47
  session?: ComposeSession;
48
48
  /** See {@link ComposeInput.promptOverrides}. */
49
49
  promptOverrides?: ComposeInput['promptOverrides'];
50
+ /** See {@link ComposeInput.overrideSafetyFailures}. */
51
+ overrideSafetyFailures?: ComposeInput['overrideSafetyFailures'];
52
+ }
53
+ /**
54
+ * Size of the diff the AI operated on — files, hunks, and per-line counts.
55
+ * Carried on {@link ComposeResult.diffStats}, {@link ComposePlanResult.diffStats},
56
+ * and on the post-collection {@link ComposeProgressEvent} `collecting` emit's `stats` field.
57
+ */
58
+ interface ComposeDiffStats {
59
+ fileCount: number;
60
+ hunkCount: number;
61
+ addedLines: number;
62
+ removedLines: number;
50
63
  }
51
64
  /** Result of {@link composePlan}. Contains everything needed to later call {@link applyComposePlan}. */
52
65
  interface ComposePlanResult {
@@ -60,6 +73,8 @@ interface ComposePlanResult {
60
73
  feedbackTargetCallId?: string;
61
74
  /** See {@link ComposeResult.hunksFilteredCount}. */
62
75
  hunksFilteredCount?: number;
76
+ /** See {@link ComposeResult.diffStats}. */
77
+ diffStats?: ComposeDiffStats;
63
78
  }
64
79
  /**
65
80
  * Pure-apply input — takes a previously computed plan and writes it to the repository.
@@ -88,6 +103,8 @@ interface ApplyComposePlanInput {
88
103
  * diff hash never equals the cached filtered one). See {@link ComposeInput.hunkFilter}.
89
104
  */
90
105
  hunkFilter?: ComposeInput['hunkFilter'];
106
+ /** See {@link ComposeInput.overrideSafetyFailures}. */
107
+ overrideSafetyFailures?: ComposeInput['overrideSafetyFailures'];
91
108
  }
92
109
  /** Result of {@link applyComposePlan}. */
93
110
  interface ApplyComposePlanResult {
@@ -266,6 +283,11 @@ interface ComposeInput {
266
283
  applyCommitIds?: string[];
267
284
  /** Stash message used by the apply path when stashing pending workdir state. Defaults to `'compose backup'`. */
268
285
  stashLabel?: string;
286
+ /**
287
+ * Opt out of individual rewrite-range safety checks. Optional, as is each flag within it.
288
+ * Currently only `interiorRefs` is supported — see {@link OverrideSafetyFailures}.
289
+ */
290
+ overrideSafetyFailures?: OverrideSafetyFailures;
269
291
  }
270
292
  interface ComposePlan {
271
293
  analysis?: AnalysisResult;
@@ -285,12 +307,17 @@ interface ComposePlan {
285
307
  * a typical run; individual numbers are approximate and may be tuned over
286
308
  * library releases. Treat as a hint, not a promise.
287
309
  * - `detail`/`event`: phase-specific extra info (git state, AI attempt, etc.).
310
+ *
311
+ * The `collecting` phase emits twice: once at the start as a spinner trigger
312
+ * (no `stats`), then again after collection and hunk-filter complete with
313
+ * `stats` populated so consumers can render live file/hunk/line counts.
288
314
  */
289
315
  type ComposeProgressEvent = {
290
316
  phase: 'collecting';
291
317
  message: string;
292
318
  weight: number;
293
319
  detail?: string;
320
+ stats?: ComposeDiffStats;
294
321
  } | {
295
322
  phase: 'composing';
296
323
  message: string;
@@ -365,12 +392,7 @@ interface ComposeResult {
365
392
  * Size of the input the AI operated on. Useful for consumers building
366
393
  * telemetry events — no need to recount hunks and lines from the source.
367
394
  */
368
- diffStats: {
369
- fileCount: number;
370
- hunkCount: number;
371
- addedLines: number;
372
- removedLines: number;
373
- };
395
+ diffStats: ComposeDiffStats;
374
396
  commitShas?: Record<string, string>;
375
397
  /**
376
398
  * Identifier for undoing this operation.
@@ -447,4 +469,4 @@ declare function composePlan(input: ComposePlanInput): Promise<ComposePlanResult
447
469
  */
448
470
  declare function applyComposePlan(input: ApplyComposePlanInput): Promise<ApplyComposePlanResult>;
449
471
  //#endregion
450
- export { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, ComposeDepth, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, type ComposeSource, ComposeStrategy, type ComposeTarget, type StashConflict, applyComposePlan, compose, composePlan, defaultPhaseMessages };
472
+ export { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, ComposeDepth, ComposeDiffStats, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, type ComposeSource, ComposeStrategy, type ComposeTarget, type OverrideSafetyFailures, type StashConflict, applyComposePlan, compose, composePlan, defaultPhaseMessages };
@@ -1,3 +1,3 @@
1
- import{GitError as e}from"../errors/git.js";import{ComposeWorkflowError as t,ComposeWorkflowInputError as n}from"../errors/workflow.js";import{addUsage as r,emptyUsage as i}from"../ai/types.js";import{analyze as a}from"../tasks/analyze/analyze.js";import{composeGroup as o}from"../tasks/compose-group/compose-group.js";import{group as s}from"../tasks/group/group.js";import{order as c}from"../tasks/order/order.js";import{partition as l}from"../tasks/partition/partition.js";import{applyPatchToIndex as u,currentBranch as d,diffTree as f,forEachRef as p,log as m,mergeBase as h,readTree as g,revParse as _,writeTree as v}from"../git/port-dispatch.js";import{buildPatchForCommit as y}from"../git/utils/patch-builder.js";import{sha256 as b}from"../git/utils/crypto.js";import{collectFromSource as x}from"../git/diff-collector.js";import{verifySnapshot as S}from"../git/safety.js";import{applyToTarget as C}from"../git/apply-target.js";import{requireExec as w}from"../git/require-exec.js";import{validateRewriteRangeSafety as T}from"../git/safety-range.js";import{extractConventions as E}from"../git/utils/conventions.js";import{rethrowAsWorkflowError as D}from"../utils/cancellation.js";import{randomUUID as O}from"node:crypto";import{unlinkSync as k}from"node:fs";import{tmpdir as A}from"node:os";import{join as j}from"node:path";import{UndoStore as M,capturePreOperationState as N,generateUndoId as P}from"@gitkraken/shared-tools";const F={collecting:{message:`Collecting changes…`,weight:5},composing:{message:`Composing commits…`,weight:40},"compose-grouping":{message:`Grouping changes into commits…`,weight:40},analyzing:{message:`Analyzing changes…`,weight:20},grouping:{message:`Grouping changes into commits…`,weight:25},ordering:{message:`Ordering commits…`,weight:15},partitioning:{message:`Subdividing branches…`,weight:20},verifying:{message:`Verifying…`,weight:5},applying:{message:`Applying to repository…`,weight:10}};async function I(r){if(!r.applyPlan&&!r.model)throw new n(`Either 'model' or 'applyPlan' must be provided`);if(r.applyPlan)return re(r);let{git:i,model:a,source:o={type:`workdir`},onProgress:s,onBeforeApply:c,cancellation:l}=r,u=a,d=r.depth??`balanced`,f=r.target??await X(i,o);Q(o,f),await K(i,r.applyCommitIds,r.hunkFilter,o,f),await T({git:i,source:o,target:f});let p=[];s?.({phase:`collecting`,...F.collecting});let m=Date.now(),h=await x(i,o);if(p.push({name:`collecting`,durationMs:Date.now()-m,attempts:1}),h.hunks.length===0)throw new e(`NO_CHANGES`,Z(o));let{hunks:g,filteredCount:_}=await U(h.hunks,r.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Z(o));let v=_!==void 0&&_>0?await W(i,h.snapshot,g):h.snapshot,y={hunks:g,collection:h,conventions:r.conventions??await E({git:i,author:r.conventionAuthor}),existingBranchNames:await ie(i),groupMode:f.type===`auto`?`branches`:`commits`,target:f,model:u,instructions:r.instructions,context:r.context,strategy:r.strategy,maxRetries:r.maxRetries,maxTokens:r.maxTokens,onProgress:s,cancellation:l,onBeforeModelCall:r.onBeforeModelCall,onAfterModelCall:r.onAfterModelCall,onBeforePrompt:r.onBeforePrompt,session:r.session,promptOverrides:r.promptOverrides,redactHunkContent:r.redactHunkContent},b;try{b=await ee(d,y)}catch(e){D(e,l)}let O={analysis:b.analysis,grouping:b.grouping,ordering:b.ordering,branches:b.branchPlans,allOrderedCommits:b.allOrderedCommits};if(p.push(...b.phases),c&&!await c(O))return{plan:O,snapshot:v,session:b.session,usage:b.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:V(g),feedbackTargetCallId:H(p),hunksFilteredCount:_};let k=await N(w(i,`capturing pre-operation state`));s?.({phase:`verifying`,...F.verifying});let A=Date.now(),j=await S(i,h.snapshot,G(o,h));if(!j.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${j.error}`);p.push({name:`verifying`,durationMs:Date.now()-A,attempts:1});let M=q(b.allOrderedCommits,r.applyCommitIds),{leftoverHunks:P,leftoverPatch:I}=Y(M,h.hunks),L=P.length>0?await W(i,v,J(M,h.hunks)):v;s?.({phase:`applying`,...F.applying});let R=Date.now(),z=await C({git:i,source:o,target:f,branchPlans:b.branchPlans,allOrderedCommits:M,hunks:h.hunks,snapshot:L,collection:h,signing:r.signing,authorAttribution:r.authorAttribution,stashLabel:r.stashLabel,leftoverPatch:I});p.push({name:`applying`,durationMs:Date.now()-R,attempts:1});let B=await ae(i,k,z.mutations,f,o);return{plan:O,snapshot:L,partitions:z.partitions,partitionGroups:z.partitionGroups,session:b.session,usage:b.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:V(g),commitShas:z.commitShas,undoId:B,feedbackTargetCallId:H(p),hunksFilteredCount:_,appliedCommitIds:M.map(e=>e.id),unappliedHunkIndices:P.length>0?P.map(e=>e.index):void 0,stashConflict:z.stashConflict}}function ee(e,t){switch(e){case`quick`:return L(t);case`balanced`:return R(t);case`deep`:return B(t)}}async function L(e){return z(e,{lightweight:!0,skipOrder:!0})}async function R(e){return z(e,{includeAnalysis:!0})}async function z(e,t){let{hunks:n,collection:a,conventions:s,existingBranchNames:u,groupMode:d,target:f,model:p,instructions:m,context:h,strategy:g,maxRetries:_,maxTokens:v,onProgress:y,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=i(),T=[],E=await o({hunks:n,commitMessages:a.commitMessages,commitOrder:a.commitOrder,branchName:a.branchName,baseBranch:a.baseBranch,issueUrl:h?.issueUrl,pullRequestUrl:h?.pullRequestUrl,instructions:m,conventions:s,mode:d,strategy:g?.grouping,existingBranchNames:d===`branches`?u:void 0,includeAnalysis:t.includeAnalysis,lightweight:t.lightweight,condensed:t.condensed,redactHunkContent:e.redactHunkContent,model:p,session:e.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`compose-grouping`,...F[`compose-grouping`],event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.[`compose-group`]});E.usage&&r(w,E.usage),T.push({name:`compose-grouping`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=E.session,O,k;if(t.skipOrder)O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:e.commits.map(e=>e.id)})),k={branches:O.map(e=>({branchId:e.branchGroup.id,orderedCommitIds:e.orderedCommitIds})),rationale:``};else{let t=await c({branches:E.result.branches,hunks:n,instructions:m,model:p,session:E.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});t.usage&&r(w,t.usage),T.push({name:`ordering`,durationMs:t.durationMs,attempts:t.attempts,usage:t.usage,callIds:t.callIds}),D=t.session,k=t.result,O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:k.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)}))}if(f.type===`auto`){let t=new Set(u);for(let e of E.result.branches)e.name&&t.add(e.name);for(let n of O){let i=await l({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:m,conventions:s,strategy:g?.partition,existingBranchNames:t,model:p,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&r(w,i.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:i.durationMs,attempts:i.attempts,usage:i.usage,callIds:i.callIds}),n.partitioning=i.result,D=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let A=O.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:void 0,grouping:E.result,ordering:k,branchPlans:O,allOrderedCommits:A,session:D,usage:w,phases:T}}async function B(e){let{hunks:t,collection:n,conventions:o,existingBranchNames:u,groupMode:d,target:f,model:p,instructions:m,context:h,strategy:g,maxRetries:_,maxTokens:v,onProgress:y,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=i(),T=[],E=await a({hunks:t,commitMessages:n.commitMessages,commitOrder:n.commitOrder,branchName:n.branchName,baseBranch:n.baseBranch,issueUrl:h?.issueUrl,pullRequestUrl:h?.pullRequestUrl,instructions:m,redactHunkContent:e.redactHunkContent,model:p,session:e.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`analyzing`,...F.analyzing,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.analyze});E.usage&&r(w,E.usage),T.push({name:`analyzing`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=await s({hunks:t,commitMessages:n.commitMessages,instructions:m,conventions:o,mode:d,strategy:g?.grouping,existingBranchNames:d===`branches`?u:void 0,redactHunkContent:e.redactHunkContent,model:p,session:E.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`grouping`,...F.grouping,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.group});D.usage&&r(w,D.usage),T.push({name:`grouping`,durationMs:D.durationMs,attempts:D.attempts,usage:D.usage,callIds:D.callIds});let O=await c({branches:D.result.branches,hunks:t,instructions:m,model:p,session:D.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});O.usage&&r(w,O.usage),T.push({name:`ordering`,durationMs:O.durationMs,attempts:O.attempts,usage:O.usage,callIds:O.callIds});let k=D.result.branches.map(e=>({branchGroup:e,orderedCommitIds:O.result.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)})),A=O.session;if(f.type===`auto`){let t=new Set(u);for(let e of D.result.branches)e.name&&t.add(e.name);for(let n of k){let i=await l({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:m,conventions:o,strategy:g?.partition,existingBranchNames:t,model:p,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&r(w,i.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:i.durationMs,attempts:i.attempts,usage:i.usage,callIds:i.callIds}),n.partitioning=i.result,A=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let j=k.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:E.result,grouping:D.result,ordering:O.result,branchPlans:k,allOrderedCommits:j,session:A,usage:w,phases:T}}async function te(e){let t=await I({...e,onBeforeApply:async()=>!1});return{plan:t.plan,snapshot:t.snapshot,source:t.source,session:t.session,usage:t.usage,phases:t.phases,feedbackTargetCallId:t.feedbackTargetCallId,hunksFilteredCount:t.hunksFilteredCount}}async function ne(e){let t=await I({git:e.git,applyPlan:e.applyPlan,source:e.source,target:e.target,onProgress:e.onProgress,signing:e.signing,authorAttribution:e.authorAttribution,applyCommitIds:e.applyCommitIds,stashLabel:e.stashLabel,hunkFilter:e.hunkFilter});return{commitShas:t.commitShas,partitions:t.partitions,partitionGroups:t.partitionGroups,phases:t.phases,undoId:t.undoId,appliedCommitIds:t.appliedCommitIds,unappliedHunkIndices:t.unappliedHunkIndices,stashConflict:t.stashConflict}}async function re(n){let{git:r,applyPlan:a,onProgress:o}=n,{plan:s,snapshot:c,source:l}=a,u=n.source??l,d=n.target??a?.target??await X(r,u);Q(u,d),await K(r,n.applyCommitIds,n.hunkFilter,u,d),await T({git:r,source:u,target:d});let f=await N(w(r,`capturing pre-operation state`)),p=[];o?.({phase:`collecting`,...F.collecting});let m=Date.now(),h=await x(r,u);if(p.push({name:`collecting`,durationMs:Date.now()-m,attempts:1}),h.hunks.length===0)throw new e(`NO_CHANGES`,Z(u));let{hunks:g}=await U(h.hunks,n.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Z(u));let _=g.length<h.hunks.length?await W(r,h.snapshot,g):h.snapshot,v={...h,hunks:g,snapshot:_};if(v.snapshot.diffHash!==c.diffHash)throw new t(`SAFETY_CHECK_FAILED`,`Working directory has changed since the plan was created. Re-run compose to generate a new plan.`);o?.({phase:`verifying`,...F.verifying});let y=Date.now(),b=await S(r,v.snapshot,G(u,v));if(!b.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${b.error}`);p.push({name:`verifying`,durationMs:Date.now()-y,attempts:1});let E=q(s.allOrderedCommits,n.applyCommitIds),{leftoverHunks:D,leftoverPatch:O}=Y(E,v.hunks),k=D.length>0?await W(r,v.snapshot,J(E,v.hunks)):v.snapshot;o?.({phase:`applying`,...F.applying});let A=Date.now(),j=await C({git:r,source:u,target:d,branchPlans:s.branches,allOrderedCommits:E,hunks:v.hunks,snapshot:k,collection:v,signing:n.signing,authorAttribution:n.authorAttribution,stashLabel:n.stashLabel,leftoverPatch:O});p.push({name:`applying`,durationMs:Date.now()-A,attempts:1});let M=await ae(r,f,j.mutations,d,u);return{plan:s,snapshot:k,partitions:j.partitions,partitionGroups:j.partitionGroups,session:{messages:[],systemPrompt:``,completedSteps:{}},usage:i(),phases:p,source:{type:u.type===`branch`?`branch`:`workdir`,hunks:v.hunks,commitMessages:v.commitMessages,branchName:v.branchName,baseBranch:v.baseBranch},diffStats:V(v.hunks),commitShas:j.commitShas,undoId:M,appliedCommitIds:E.map(e=>e.id),unappliedHunkIndices:D.length>0?D.map(e=>e.index):void 0,stashConflict:j.stashConflict}}function V(e){let t=new Set,n=0,r=0;for(let i of e)t.add(i.fileName),n+=i.additions,r+=i.deletions;return{fileCount:t.size,hunkCount:e.length,addedLines:n,removedLines:r}}function H(e){for(let t=e.length-1;t>=0;t--){let n=e[t].callIds;if(n&&n.length>0)return n[n.length-1]}}async function U(e,t){if(!t)return{hunks:e,filteredCount:void 0};let n=await t(e);return{hunks:n,filteredCount:e.length-n.length}}async function W(e,t,n){let r=t.baseSha??t.headSha,i=j(A(),`compose-filtered-idx-${O()}`),a={GIT_INDEX_FILE:i};try{await g(e,r,{indexEnv:a});let i=y(n);i.length>0&&await u(e,i,{indexEnv:a});let o=await f(e,r,await v(e,{indexEnv:a}));return{...t,diffHash:b(o)}}finally{try{k(i)}catch{}}}function G(e,t){return e.type===`workdir`?{includeUnstaged:!e.stagedOnly,includeUntracked:t.untrackedIncluded}:e.type===`commit-range`&&e.includeWorkdir?{includeStaged:e.includeWorkdir.includeStaged??!0,includeUnstaged:e.includeWorkdir.includeUnstaged??!0,includeUntracked:e.includeWorkdir.includeUntracked??!0}:{includeUnstaged:!1,includeUntracked:!1}}async function K(e,t,r,i,a){if(r==null&&t==null||i.type===`workdir`||!(a.type===`rewrite-range`||(a.type===`branch`||a.type===`auto`)&&a.preserveOriginal!==!0))return;let o=i.type===`branch`?await _(e,i.name):await _(e,i.to),s=await _(e,`HEAD`);if(await d(e)==null)throw new n(`Filtering and partial apply require HEAD to be on a branch (non-detached) when rewriting existing commits. Check out the source branch before composing.`);if(s!==o)throw new n(`Filtering and partial apply require HEAD to be at the source's tip (${o.slice(0,8)}); HEAD is at ${s.slice(0,8)}.`)}function q(e,t){if(t==null)return e;let r=new Set(t),i=e.filter(e=>r.has(e.id)),a=new Set(i.map(e=>e.id)),o=t.filter(e=>!a.has(e));if(o.length>0)throw new n(`applyCommitIds contains commit ids not found in plan.allOrderedCommits: ${o.join(`, `)}`);return i}function J(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices));return t.filter(e=>n.has(e.index))}function Y(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices)),r=t.filter(e=>!n.has(e.index));if(r.length===0)return{leftoverHunks:[],leftoverPatch:void 0};let i=y(r);return{leftoverHunks:r,leftoverPatch:i.length>0?i:void 0}}async function X(e,t){if(t.type===`branch`){let r=await _(e,t.name),i=(await m(e,{format:`%H`,range:`${await h(e,await _(e,t.mergeTarget??`main`),r)}..${r}`})).trim().split(`
2
- `).at(-1);if(!i)throw new n(`Cannot recompose branch '${t.name}': no commits between merge-base and branch tip.`);return{type:`rewrite-range`,branch:t.name,rangeFrom:i,rangeTo:r}}return t.type===`commit-range`?{type:`rewrite-range`,branch:t.branch,rangeFrom:t.from,rangeTo:t.to}:{type:`head`}}function Z(e){switch(e.type){case`workdir`:return`No changes found in working directory`;case`branch`:return`No changes found on branch '${e.name}'`;case`commit-range`:return`No changes found in range '${e.from}..${e.to}' on branch '${e.branch}'`}}function Q(e,t){if(t.type===`rewrite-range`&&e.type===`workdir`)throw new n(`target 'rewrite-range' is not valid with 'workdir' source`);if(t.type===`head`&&e.type!==`workdir`)throw new n(`target 'head' is not valid with '${e.type}' source — use 'branch', 'auto', or 'rewrite-range'`)}function $(e,n){return n.map(n=>{let r=e.commits.find(e=>e.id===n);if(!r)throw new t(`INTERNAL`,`Commit ${n} not found in branch '${e.id}'`);return r})}async function ie(e){let t=(await p(e,{format:`%(refname:short)`,prefix:`refs/heads/`})).trim().split(`
3
- `).filter(Boolean);return new Set(t)}async function ae(e,t,n,r,i){let a=P(),o=n.createdRefs.filter(e=>e.kind===`branch`).length||1,s=`compose: ${o} branch${o===1?``:`es`} from ${i.type}`,c=[...n.movedRefs];if(r.type===`head`&&t.headBranch){let n=await _(e,`HEAD`);n!==t.headSha&&c.push({kind:`branch`,name:t.headBranch,previousSha:t.headSha,newSha:n})}let l={version:1,id:a,createdAt:new Date().toISOString(),description:s,previousHead:{sha:t.headSha,branch:t.headBranch},workdirClean:t.workdirClean,workdirBackupSha:t.workdirBackupSha,createdRefs:n.createdRefs,deletedRefs:n.deletedRefs,movedRefs:c};return await new M(w(e,`undo manifest persistence`)).save(l),a}export{ne as applyComposePlan,I as compose,te as composePlan,F as defaultPhaseMessages};
1
+ import{GitError as e}from"../errors/git.js";import{ComposeWorkflowError as t,ComposeWorkflowInputError as n}from"../errors/workflow.js";import{applyPatchToIndex as r,currentBranch as i,diffTree as a,forEachRef as o,log as s,mergeBase as c,readTree as l,revParse as u,writeTree as d}from"../git/port-dispatch.js";import{validateRewriteRangeSafety as f}from"../git/safety-range.js";import{addUsage as p,emptyUsage as m}from"../ai/types.js";import{analyze as h}from"../tasks/analyze/analyze.js";import{composeGroup as g}from"../tasks/compose-group/compose-group.js";import{group as _}from"../tasks/group/group.js";import{order as v}from"../tasks/order/order.js";import{partition as y}from"../tasks/partition/partition.js";import{buildPatchForCommit as b}from"../git/utils/patch-builder.js";import{sha256 as x}from"../git/utils/crypto.js";import{collectFromSource as S}from"../git/diff-collector.js";import{verifySnapshot as C}from"../git/safety.js";import{applyToTarget as w}from"../git/apply-target.js";import{requireExec as T}from"../git/require-exec.js";import{extractConventions as E}from"../git/utils/conventions.js";import{rethrowAsWorkflowError as D}from"../utils/cancellation.js";import{randomUUID as O}from"node:crypto";import{unlinkSync as k}from"node:fs";import{tmpdir as A}from"node:os";import{join as j}from"node:path";import{UndoStore as M,capturePreOperationState as N,generateUndoId as P}from"@gitkraken/shared-tools";const F={collecting:{message:`Collecting changes…`,weight:5},composing:{message:`Composing commits…`,weight:40},"compose-grouping":{message:`Grouping changes into commits…`,weight:40},analyzing:{message:`Analyzing changes…`,weight:20},grouping:{message:`Grouping changes into commits…`,weight:25},ordering:{message:`Ordering commits…`,weight:15},partitioning:{message:`Subdividing branches…`,weight:20},verifying:{message:`Verifying…`,weight:5},applying:{message:`Applying to repository…`,weight:10}};async function I(r){if(!r.applyPlan&&!r.model)throw new n(`Either 'model' or 'applyPlan' must be provided`);if(r.applyPlan)return ne(r);let{git:i,model:a,source:o={type:`workdir`},onProgress:s,onBeforeApply:c,cancellation:l}=r,u=a,d=r.depth??`balanced`,p=r.target??await Z(i,o);re(o,p),await q(i,r.applyCommitIds,r.hunkFilter,o,p),await f({git:i,source:o,target:p,overrides:r.overrideSafetyFailures});let m=[];s?.({phase:`collecting`,...F.collecting});let h=Date.now(),g=await S(i,o);if(m.push({name:`collecting`,durationMs:Date.now()-h,attempts:1}),g.hunks.length===0)throw new e(`NO_CHANGES`,Q(o));let{hunks:_,filteredCount:v}=await W(g.hunks,r.hunkFilter);if(_.length===0)throw new e(`NO_CHANGES`,Q(o));let y=H(_);s?.({phase:`collecting`,...F.collecting,stats:y});let b=v!==void 0&&v>0?await G(i,g.snapshot,_):g.snapshot,x={hunks:_,collection:g,conventions:r.conventions??await E({git:i,author:r.conventionAuthor}),existingBranchNames:await ie(i),groupMode:p.type===`auto`?`branches`:`commits`,target:p,model:u,instructions:r.instructions,context:r.context,strategy:r.strategy,maxRetries:r.maxRetries,maxTokens:r.maxTokens,onProgress:s,cancellation:l,onBeforeModelCall:r.onBeforeModelCall,onAfterModelCall:r.onAfterModelCall,onBeforePrompt:r.onBeforePrompt,session:r.session,promptOverrides:r.promptOverrides,redactHunkContent:r.redactHunkContent},O;try{O=await ee(d,x)}catch(e){D(e,l)}let k={analysis:O.analysis,grouping:O.grouping,ordering:O.ordering,branches:O.branchPlans,allOrderedCommits:O.allOrderedCommits};if(m.push(...O.phases),c&&!await c(k))return{plan:k,snapshot:b,session:O.session,usage:O.usage,phases:m,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:_,commitMessages:g.commitMessages,branchName:g.branchName,baseBranch:g.baseBranch},diffStats:y,feedbackTargetCallId:U(m),hunksFilteredCount:v};let A=await N(T(i,`capturing pre-operation state`));s?.({phase:`verifying`,...F.verifying});let j=Date.now(),M=await C(i,g.snapshot,K(o,g));if(!M.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${M.error}`);m.push({name:`verifying`,durationMs:Date.now()-j,attempts:1});let P=J(O.allOrderedCommits,r.applyCommitIds),{leftoverHunks:I,leftoverPatch:L}=X(P,g.hunks),R=I.length>0?await G(i,b,Y(P,g.hunks)):b;s?.({phase:`applying`,...F.applying});let z=Date.now(),B=await w({git:i,source:o,target:p,branchPlans:O.branchPlans,allOrderedCommits:P,hunks:g.hunks,snapshot:R,collection:g,signing:r.signing,authorAttribution:r.authorAttribution,stashLabel:r.stashLabel,leftoverPatch:L});m.push({name:`applying`,durationMs:Date.now()-z,attempts:1});let V=await ae(i,A,B.mutations,p,o);return{plan:k,snapshot:R,partitions:B.partitions,partitionGroups:B.partitionGroups,session:O.session,usage:O.usage,phases:m,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:_,commitMessages:g.commitMessages,branchName:g.branchName,baseBranch:g.baseBranch},diffStats:y,commitShas:B.commitShas,undoId:V,feedbackTargetCallId:U(m),hunksFilteredCount:v,appliedCommitIds:P.map(e=>e.id),unappliedHunkIndices:I.length>0?I.map(e=>e.index):void 0,stashConflict:B.stashConflict}}function ee(e,t){switch(e){case`quick`:return L(t);case`balanced`:return R(t);case`deep`:return B(t)}}async function L(e){return z(e,{lightweight:!0,skipOrder:!0})}async function R(e){return z(e,{includeAnalysis:!0})}async function z(e,t){let{hunks:n,collection:r,conventions:i,existingBranchNames:a,groupMode:o,target:s,model:c,instructions:l,context:u,strategy:d,maxRetries:f,maxTokens:h,onProgress:_,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=m(),T=[],E=await g({hunks:n,commitMessages:r.commitMessages,commitOrder:r.commitOrder,branchName:r.branchName,baseBranch:r.baseBranch,issueUrl:u?.issueUrl,pullRequestUrl:u?.pullRequestUrl,instructions:l,conventions:i,mode:o,strategy:d?.grouping,existingBranchNames:o===`branches`?a:void 0,includeAnalysis:t.includeAnalysis,lightweight:t.lightweight,condensed:t.condensed,redactHunkContent:e.redactHunkContent,model:c,session:e.session,maxRetries:f,maxTokens:h,onProgress:e=>_?.({phase:`compose-grouping`,...F[`compose-grouping`],event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.[`compose-group`]});E.usage&&p(w,E.usage),T.push({name:`compose-grouping`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=E.session,O,k;if(t.skipOrder)O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:e.commits.map(e=>e.id)})),k={branches:O.map(e=>({branchId:e.branchGroup.id,orderedCommitIds:e.orderedCommitIds})),rationale:``};else{let t=await v({branches:E.result.branches,hunks:n,instructions:l,model:c,session:E.session,maxRetries:f,maxTokens:h,onProgress:e=>_?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});t.usage&&p(w,t.usage),T.push({name:`ordering`,durationMs:t.durationMs,attempts:t.attempts,usage:t.usage,callIds:t.callIds}),D=t.session,k=t.result,O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:k.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)}))}if(s.type===`auto`){let t=new Set(a);for(let e of E.result.branches)e.name&&t.add(e.name);for(let n of O){let r=await y({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:l,conventions:i,strategy:d?.partition,existingBranchNames:t,model:c,maxRetries:f,maxTokens:h,onProgress:e=>_?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});r.usage&&p(w,r.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:r.durationMs,attempts:r.attempts,usage:r.usage,callIds:r.callIds}),n.partitioning=r.result,D=r.session;for(let e of r.result.partitions)t.add(e.branchName)}}let A=O.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:void 0,grouping:E.result,ordering:k,branchPlans:O,allOrderedCommits:A,session:D,usage:w,phases:T}}async function B(e){let{hunks:t,collection:n,conventions:r,existingBranchNames:i,groupMode:a,target:o,model:s,instructions:c,context:l,strategy:u,maxRetries:d,maxTokens:f,onProgress:g,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=m(),T=[],E=await h({hunks:t,commitMessages:n.commitMessages,commitOrder:n.commitOrder,branchName:n.branchName,baseBranch:n.baseBranch,issueUrl:l?.issueUrl,pullRequestUrl:l?.pullRequestUrl,instructions:c,redactHunkContent:e.redactHunkContent,model:s,session:e.session,maxRetries:d,maxTokens:f,onProgress:e=>g?.({phase:`analyzing`,...F.analyzing,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.analyze});E.usage&&p(w,E.usage),T.push({name:`analyzing`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=await _({hunks:t,commitMessages:n.commitMessages,instructions:c,conventions:r,mode:a,strategy:u?.grouping,existingBranchNames:a===`branches`?i:void 0,redactHunkContent:e.redactHunkContent,model:s,session:E.session,maxRetries:d,maxTokens:f,onProgress:e=>g?.({phase:`grouping`,...F.grouping,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.group});D.usage&&p(w,D.usage),T.push({name:`grouping`,durationMs:D.durationMs,attempts:D.attempts,usage:D.usage,callIds:D.callIds});let O=await v({branches:D.result.branches,hunks:t,instructions:c,model:s,session:D.session,maxRetries:d,maxTokens:f,onProgress:e=>g?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});O.usage&&p(w,O.usage),T.push({name:`ordering`,durationMs:O.durationMs,attempts:O.attempts,usage:O.usage,callIds:O.callIds});let k=D.result.branches.map(e=>({branchGroup:e,orderedCommitIds:O.result.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)})),A=O.session;if(o.type===`auto`){let t=new Set(i);for(let e of D.result.branches)e.name&&t.add(e.name);for(let n of k){let i=await y({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:c,conventions:r,strategy:u?.partition,existingBranchNames:t,model:s,maxRetries:d,maxTokens:f,onProgress:e=>g?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&p(w,i.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:i.durationMs,attempts:i.attempts,usage:i.usage,callIds:i.callIds}),n.partitioning=i.result,A=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let j=k.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:E.result,grouping:D.result,ordering:O.result,branchPlans:k,allOrderedCommits:j,session:A,usage:w,phases:T}}async function V(e){let t=await I({...e,onBeforeApply:async()=>!1});return{plan:t.plan,snapshot:t.snapshot,source:t.source,session:t.session,usage:t.usage,phases:t.phases,feedbackTargetCallId:t.feedbackTargetCallId,hunksFilteredCount:t.hunksFilteredCount,diffStats:t.diffStats}}async function te(e){let t=await I({git:e.git,applyPlan:e.applyPlan,source:e.source,target:e.target,onProgress:e.onProgress,signing:e.signing,authorAttribution:e.authorAttribution,applyCommitIds:e.applyCommitIds,stashLabel:e.stashLabel,hunkFilter:e.hunkFilter,overrideSafetyFailures:e.overrideSafetyFailures});return{commitShas:t.commitShas,partitions:t.partitions,partitionGroups:t.partitionGroups,phases:t.phases,undoId:t.undoId,appliedCommitIds:t.appliedCommitIds,unappliedHunkIndices:t.unappliedHunkIndices,stashConflict:t.stashConflict}}async function ne(n){let{git:r,applyPlan:i,onProgress:a}=n,{plan:o,snapshot:s,source:c}=i,l=n.source??c,u=n.target??i?.target??await Z(r,l);re(l,u),await q(r,n.applyCommitIds,n.hunkFilter,l,u),await f({git:r,source:l,target:u,overrides:n.overrideSafetyFailures});let d=await N(T(r,`capturing pre-operation state`)),p=[];a?.({phase:`collecting`,...F.collecting});let h=Date.now(),g=await S(r,l);if(p.push({name:`collecting`,durationMs:Date.now()-h,attempts:1}),g.hunks.length===0)throw new e(`NO_CHANGES`,Q(l));let{hunks:_}=await W(g.hunks,n.hunkFilter);if(_.length===0)throw new e(`NO_CHANGES`,Q(l));let v=H(_);a?.({phase:`collecting`,...F.collecting,stats:v});let y=_.length<g.hunks.length?await G(r,g.snapshot,_):g.snapshot,b={...g,hunks:_,snapshot:y};if(b.snapshot.diffHash!==s.diffHash)throw new t(`SAFETY_CHECK_FAILED`,`Working directory has changed since the plan was created. Re-run compose to generate a new plan.`);a?.({phase:`verifying`,...F.verifying});let x=Date.now(),E=await C(r,b.snapshot,K(l,b));if(!E.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${E.error}`);p.push({name:`verifying`,durationMs:Date.now()-x,attempts:1});let D=J(o.allOrderedCommits,n.applyCommitIds),{leftoverHunks:O,leftoverPatch:k}=X(D,b.hunks),A=O.length>0?await G(r,b.snapshot,Y(D,b.hunks)):b.snapshot;a?.({phase:`applying`,...F.applying});let j=Date.now(),M=await w({git:r,source:l,target:u,branchPlans:o.branches,allOrderedCommits:D,hunks:b.hunks,snapshot:A,collection:b,signing:n.signing,authorAttribution:n.authorAttribution,stashLabel:n.stashLabel,leftoverPatch:k});p.push({name:`applying`,durationMs:Date.now()-j,attempts:1});let P=await ae(r,d,M.mutations,u,l);return{plan:o,snapshot:A,partitions:M.partitions,partitionGroups:M.partitionGroups,session:{messages:[],systemPrompt:``,completedSteps:{}},usage:m(),phases:p,source:{type:l.type===`branch`?`branch`:`workdir`,hunks:b.hunks,commitMessages:b.commitMessages,branchName:b.branchName,baseBranch:b.baseBranch},diffStats:v,commitShas:M.commitShas,undoId:P,appliedCommitIds:D.map(e=>e.id),unappliedHunkIndices:O.length>0?O.map(e=>e.index):void 0,stashConflict:M.stashConflict}}function H(e){let t=new Set,n=0,r=0;for(let i of e)t.add(i.fileName),n+=i.additions,r+=i.deletions;return{fileCount:t.size,hunkCount:e.length,addedLines:n,removedLines:r}}function U(e){for(let t=e.length-1;t>=0;t--){let n=e[t].callIds;if(n&&n.length>0)return n[n.length-1]}}async function W(e,t){if(!t)return{hunks:e,filteredCount:void 0};let n=await t(e);return{hunks:n,filteredCount:e.length-n.length}}async function G(e,t,n){let i=t.baseSha??t.headSha,o=j(A(),`compose-filtered-idx-${O()}`),s={GIT_INDEX_FILE:o};try{await l(e,i,{indexEnv:s});let o=b(n);o.length>0&&await r(e,o,{indexEnv:s});let c=await a(e,i,await d(e,{indexEnv:s}));return{...t,diffHash:x(c)}}finally{try{k(o)}catch{}}}function K(e,t){return e.type===`workdir`?{includeUnstaged:!e.stagedOnly,includeUntracked:t.untrackedIncluded}:e.type===`commit-range`&&e.includeWorkdir?{includeStaged:e.includeWorkdir.includeStaged??!0,includeUnstaged:e.includeWorkdir.includeUnstaged??!0,includeUntracked:e.includeWorkdir.includeUntracked??!0}:{includeUnstaged:!1,includeUntracked:!1}}async function q(e,t,r,a,o){if(r==null&&t==null||a.type===`workdir`||!(o.type===`rewrite-range`||(o.type===`branch`||o.type===`auto`)&&o.preserveOriginal!==!0))return;let s=a.type===`branch`?await u(e,a.name):await u(e,a.to),c=await u(e,`HEAD`);if(await i(e)==null)throw new n(`Filtering and partial apply require HEAD to be on a branch (non-detached) when rewriting existing commits. Check out the source branch before composing.`);if(c!==s)throw new n(`Filtering and partial apply require HEAD to be at the source's tip (${s.slice(0,8)}); HEAD is at ${c.slice(0,8)}.`)}function J(e,t){if(t==null)return e;let r=new Set(t),i=e.filter(e=>r.has(e.id)),a=new Set(i.map(e=>e.id)),o=t.filter(e=>!a.has(e));if(o.length>0)throw new n(`applyCommitIds contains commit ids not found in plan.allOrderedCommits: ${o.join(`, `)}`);return i}function Y(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices));return t.filter(e=>n.has(e.index))}function X(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices)),r=t.filter(e=>!n.has(e.index));if(r.length===0)return{leftoverHunks:[],leftoverPatch:void 0};let i=b(r);return{leftoverHunks:r,leftoverPatch:i.length>0?i:void 0}}async function Z(e,t){if(t.type===`branch`){let r=await u(e,t.name),i=(await s(e,{format:`%H`,range:`${await c(e,await u(e,t.mergeTarget??`main`),r)}..${r}`})).trim().split(`
2
+ `).at(-1);if(!i)throw new n(`Cannot recompose branch '${t.name}': no commits between merge-base and branch tip.`);return{type:`rewrite-range`,branch:t.name,rangeFrom:i,rangeTo:r}}return t.type===`commit-range`?{type:`rewrite-range`,branch:t.branch,rangeFrom:t.from,rangeTo:t.to}:{type:`head`}}function Q(e){switch(e.type){case`workdir`:return`No changes found in working directory`;case`branch`:return`No changes found on branch '${e.name}'`;case`commit-range`:return`No changes found in range '${e.from}..${e.to}' on branch '${e.branch}'`}}function re(e,t){if(t.type===`rewrite-range`&&e.type===`workdir`)throw new n(`target 'rewrite-range' is not valid with 'workdir' source`);if(t.type===`head`&&e.type!==`workdir`)throw new n(`target 'head' is not valid with '${e.type}' source — use 'branch', 'auto', or 'rewrite-range'`)}function $(e,n){return n.map(n=>{let r=e.commits.find(e=>e.id===n);if(!r)throw new t(`INTERNAL`,`Commit ${n} not found in branch '${e.id}'`);return r})}async function ie(e){let t=(await o(e,{format:`%(refname:short)`,prefix:`refs/heads/`})).trim().split(`
3
+ `).filter(Boolean);return new Set(t)}async function ae(e,t,n,r,i){let a=P(),o=n.composedBranchCount||1,s=`compose: ${o} branch${o===1?``:`es`} from ${i.type}`,c=[...n.movedRefs];if(r.type===`head`&&t.headBranch){let n=await u(e,`HEAD`);n!==t.headSha&&c.push({kind:`branch`,name:t.headBranch,previousSha:t.headSha,newSha:n})}let l={version:1,id:a,createdAt:new Date().toISOString(),description:s,previousHead:{sha:t.headSha,branch:t.headBranch},workdirClean:t.workdirClean,workdirBackupSha:t.workdirBackupSha,createdRefs:n.createdRefs,deletedRefs:n.deletedRefs,movedRefs:c};return await new M(T(e,`undo manifest persistence`)).save(l),a}export{te as applyComposePlan,I as compose,V as composePlan,F as defaultPhaseMessages};
@@ -0,0 +1,96 @@
1
+ import { PhaseDetail, ProgressEvent, TokenUsage } from "../ai/types.js";
2
+ import { PromptOverride } from "../tasks/types.js";
3
+ import { OnAfterModelCall, OnBeforeModelCall, OnBeforePrompt } from "../tasks/ai-loop.js";
4
+ import { ComposeSession } from "../tasks/session.js";
5
+ import { ComposePlan } from "./compose.js";
6
+ import { AiModelPort } from "@gitkraken/shared-tools";
7
+
8
+ //#region src/workflows/refine.d.ts
9
+ /** Progress event for {@link refinePlan} — single phase, mirrors the workflow ProgressEvent
10
+ * shape used elsewhere (phase label + human-readable message + AI-loop sub-event). */
11
+ type RefineProgressEvent = {
12
+ phase: 'refining';
13
+ message: string;
14
+ weight: number;
15
+ event?: ProgressEvent;
16
+ };
17
+ /**
18
+ * Input for {@link refinePlan} — the workflow-level wrapper around the `refine` task.
19
+ *
20
+ * The workflow takes a higher-level shape than the task does — it accepts a full
21
+ * {@link ComposePlan} and a list of `lockedCommits` *as ids*, materializes those ids into
22
+ * `CommitSuggestion` objects from the prior plan, derives the hunk count from the prior
23
+ * plan's commits, and lifts the task's `GroupingResult` back into a full `ComposePlan`. The
24
+ * task itself stays lower-level and reusable.
25
+ */
26
+ interface RefinePlanInput {
27
+ /**
28
+ * Session from a prior `composePlan` (or `refinePlan`) call. MUST contain `messages` —
29
+ * the AI's prior plan + the hunks the plan was produced from live there. Passed
30
+ * unchanged to the refine task.
31
+ */
32
+ session: ComposeSession;
33
+ /** The plan to be refined. Used to resolve `lockedCommits` ids to their fixed content
34
+ * and to derive total hunk count + output mode (commits vs branches) for validation. */
35
+ priorPlan: ComposePlan;
36
+ model: AiModelPort;
37
+ /** The user's refinement instructions. If absent, the prompt uses a generic "review your
38
+ * prior plan critically and produce an improved version" fallback. */
39
+ instructions?: string;
40
+ /**
41
+ * Commit ids from `priorPlan.allOrderedCommits` that must be preserved verbatim — same
42
+ * id, message, explanation, and hunk assignments. The validator (inside the task)
43
+ * requires every locked id to appear in the AI's output; if any locked id is missing,
44
+ * the AI loop retries with feedback. When all locked ids ARE present, the task
45
+ * substitutes the prior plan's content for each locked id in the AI's response, so
46
+ * minor AI drift on message text is invisibly normalized.
47
+ */
48
+ lockedCommits?: readonly string[];
49
+ /**
50
+ * Free-text the caller injects into the refinement prompt — e.g. UI-ordering hints,
51
+ * external context about how the user views the plan. The library doesn't interpret
52
+ * this string; it's appended verbatim ahead of the locked-commits and user-instructions
53
+ * blocks.
54
+ */
55
+ clientContext?: string;
56
+ maxRetries?: number;
57
+ maxTokens?: number;
58
+ onProgress?: (event: RefineProgressEvent) => void;
59
+ cancellation?: AbortSignal;
60
+ onBeforeModelCall?: OnBeforeModelCall;
61
+ onAfterModelCall?: OnAfterModelCall;
62
+ onBeforePrompt?: OnBeforePrompt;
63
+ /** Prompt override forwarded to the refine task. */
64
+ promptOverride?: PromptOverride;
65
+ }
66
+ interface RefinePlanResult {
67
+ plan: ComposePlan;
68
+ session: ComposeSession;
69
+ usage: TokenUsage;
70
+ phases: PhaseDetail[];
71
+ /** See `ComposeResult.feedbackTargetCallId`. */
72
+ feedbackTargetCallId?: string;
73
+ }
74
+ /**
75
+ * Refine a previously-generated plan via a single AI continuation.
76
+ *
77
+ * Workflow-level orchestration only: validates the prior plan, materializes `lockedCommits`
78
+ * ids into `CommitSuggestion` objects, calls the `refine` task, and lifts the returned
79
+ * `GroupingResult` into a full `ComposePlan`. No AI invocation lives here — that's the
80
+ * task's job (see `tasks/refine/refine.ts`).
81
+ *
82
+ * Unlike `composePlan`, refinePlan does NOT touch git, run `collectFromSource`, or invoke
83
+ * any of the compose pipeline tasks. The caller reuses the snapshot from the original
84
+ * compose for apply-time validation.
85
+ */
86
+ declare function refinePlan(input: RefinePlanInput): Promise<RefinePlanResult>;
87
+ /**
88
+ * Partition the prior plan's locked commits into the maximal runs of *consecutive* locked
89
+ * commits (no commit between them) within each branch, in the plan's original order. Each run
90
+ * — singletons included — is one entry; the flattened result is the locked commits' original
91
+ * relative order. Feeds the refine task's post-output cleanup, which restores ordering the AI
92
+ * reshuffled. See {@link import('../tasks/refine/types.js').RefineInput.lockedRuns}.
93
+ */
94
+ declare function computeLockedRuns(plan: ComposePlan, lockedCommitIds: readonly string[]): string[][];
95
+ //#endregion
96
+ export { RefinePlanInput, RefinePlanResult, RefineProgressEvent, computeLockedRuns, refinePlan };
@@ -0,0 +1 @@
1
+ import{ComposeWorkflowInputError as e}from"../errors/workflow.js";import{emptyUsage as t}from"../ai/types.js";import{rethrowAsWorkflowError as n}from"../utils/cancellation.js";import{refine as r}from"../tasks/refine/refine.js";async function i(i){if(i.session.messages.length===0)throw new e(`refinePlan: session must contain prior messages from a previous compose call.`);if(i.priorPlan.allOrderedCommits.length===0)throw new e(`refinePlan: priorPlan must contain at least one commit.`);let c=[];if(i.lockedCommits?.length)for(let t of i.lockedCommits){let n=i.priorPlan.allOrderedCommits.find(e=>e.id===t);if(n==null)throw new e(`refinePlan: lockedCommits id '${t}' not found in priorPlan.allOrderedCommits.`);c.push(n)}let l=o(i.priorPlan),u=s(i.priorPlan),d=i.lockedCommits?.length?a(i.priorPlan,i.lockedCommits):void 0,f=Date.now(),p;try{p=await r({session:i.session,lockedCommits:c,lockedRuns:d,instructions:i.instructions,clientContext:i.clientContext,hunkCount:l,mode:u,model:i.model,maxRetries:i.maxRetries,maxTokens:i.maxTokens,cancellation:i.cancellation,onBeforeModelCall:i.onBeforeModelCall,onAfterModelCall:i.onAfterModelCall,onBeforePrompt:i.onBeforePrompt,promptOverride:i.promptOverride,onProgress:i.onProgress?e=>i.onProgress?.({phase:`refining`,message:`Refining commits…`,weight:100,event:e}):void 0})}catch(e){n(e,i.cancellation)}let m=p.result,h=m.branches.map(e=>({branchGroup:e,orderedCommitIds:e.commits.map(e=>e.id)})),g={branches:h.map(e=>({branchId:e.branchGroup.id,orderedCommitIds:e.orderedCommitIds})),rationale:``},_=h.flatMap(e=>e.branchGroup.commits);return{plan:{analysis:i.session.completedSteps.analyze,grouping:m,ordering:g,branches:h,allOrderedCommits:_},session:p.session,usage:p.usage??t(),phases:[{name:`refine`,durationMs:Date.now()-f,attempts:p.attempts,usage:p.usage,callIds:p.callIds}],feedbackTargetCallId:p.callIds.length>0?p.callIds[p.callIds.length-1]:void 0}}function a(e,t){let n=new Set(t);if(n.size===0)return[];let r=[];for(let t of e.branches){let e=[];for(let i of t.branchGroup.commits)n.has(i.id)?e.push(i.id):e.length>0&&(r.push(e),e=[]);e.length>0&&r.push(e)}return r}function o(e){let t=new Set;for(let n of e.allOrderedCommits)for(let e of n.hunkIndices)t.add(e);return t.size}function s(e){return e.branches.length===1&&e.branches[0].branchGroup.id===`default`&&e.branches[0].branchGroup.name===``?`commits`:`branches`}export{a as computeLockedRuns,i as refinePlan};
@@ -1,3 +1,3 @@
1
- import{GitError as e}from"../errors/git.js";import{addUsage as t,emptyUsage as n}from"../ai/types.js";import{analyze as r}from"../tasks/analyze/analyze.js";import{partition as i}from"../tasks/partition/partition.js";import{forEachRef as a}from"../git/port-dispatch.js";import{createStackedBranches as o}from"../git/apply-branches.js";import{parseUnifiedDiff as s}from"../git/utils/hunk-parser.js";import{collectCommitsForSplit as c}from"../git/diff-collector.js";import{requireExec as l}from"../git/require-exec.js";import{rethrowAsWorkflowError as u}from"../utils/cancellation.js";import{defaultPhaseMessages as d}from"./compose.js";import{UndoStore as f,capturePreOperationState as p,generateUndoId as m}from"@gitkraken/shared-tools";async function h(a){let{git:f,model:m,branchName:h,baseBranch:v=`main`,instructions:y,maxRetries:b,maxTokens:x,onProgress:S,onBeforeApply:C,cancellation:w,onBeforeModelCall:T,onAfterModelCall:E,onBeforePrompt:D}=a,O=n(),k=[];S?.({phase:`collecting`,...d.collecting});let A=Date.now(),{commits:j,branchSha:M}=await c(f,h,v);if(k.push({name:`collecting`,durationMs:Date.now()-A,attempts:1}),j.length===0)throw new e(`NO_CHANGES`,`No commits found between '${v}' and '${h}'`);if(j.length<2)throw new e(`NO_CHANGES`,`Too few commits to split. A branch needs at least 2 commits.`);let N=s(j.map(e=>e.diff).join(`
2
- `)),P=j.map(e=>({id:e.sha,message:e.message,explanation:``,hunkIndices:[]})),F;try{F=await r({hunks:N,commitMessages:j.map(e=>e.message),commitOrder:j.map(e=>e.sha),branchName:h,baseBranch:v,instructions:y,redactHunkContent:a.redactHunkContent,model:m,maxRetries:b,maxTokens:x,onProgress:e=>S?.({phase:`analyzing`,...d.analyzing,event:e}),cancellation:w,onBeforeModelCall:T,onAfterModelCall:E,onBeforePrompt:D})}catch(e){u(e,w)}F.usage&&t(O,F.usage),k.push({name:`analyzing`,durationMs:F.durationMs,attempts:F.attempts,usage:F.usage,callIds:F.callIds});let I=j.map(e=>e.sha),L=await g(f),R;try{R=await i({commits:P,orderedCommitIds:I,sourceBranchName:h,baseBranch:v,instructions:y,existingBranchNames:L,model:m,session:F.session,maxRetries:b,maxTokens:x,onProgress:e=>S?.({phase:`partitioning`,...d.partitioning,event:e}),cancellation:w,onBeforeModelCall:T,onAfterModelCall:E,onBeforePrompt:D})}catch(e){u(e,w)}R.usage&&t(O,R.usage),k.push({name:`partitioning`,durationMs:R.durationMs,attempts:R.attempts,usage:R.usage,callIds:R.callIds});let z={type:`branch`,hunks:N,commitMessages:j.map(e=>e.message),branchName:h,baseBranch:v},B={analysis:F.result,partitioning:R.result,commits:j};if(R.result.partitions.length<=1||C&&!await C(B))return{plan:B,partitions:R.result.partitions,session:R.session,usage:O,phases:k,source:z};let V=await p(l(f,`capturing pre-operation state`));S?.({phase:`applying`,...d.applying});let H=Date.now();await o({git:f,partitions:R.result.partitions,originalBranchName:h,originalBranchSha:M}),k.push({name:`applying`,durationMs:Date.now()-H,attempts:1});let U=await _(f,V,R.result.partitions,h,M);return{plan:B,partitions:R.result.partitions,session:R.session,usage:O,phases:k,source:z,undoId:U}}async function g(e){let t=(await a(e,{format:`%(refname:short)`,prefix:`refs/heads/`})).trim().split(`
3
- `).filter(Boolean);return new Set(t)}async function _(e,t,n,r,i){let a=m(),o=n.map(e=>({kind:`branch`,name:e.branchName,sha:e.commitIds[e.commitIds.length-1]})),s={version:1,id:a,createdAt:new Date().toISOString(),description:`split: ${n.length} branches from '${r}'`,previousHead:{sha:t.headSha,branch:t.headBranch},workdirClean:t.workdirClean,workdirBackupSha:t.workdirBackupSha,createdRefs:o,deletedRefs:[{kind:`branch`,name:r,sha:i}],movedRefs:[]};return await new f(l(e,`undo manifest persistence`)).save(s),a}export{h as split};
1
+ import{GitError as e}from"../errors/git.js";import{forEachRef as t}from"../git/port-dispatch.js";import{addUsage as n,emptyUsage as r}from"../ai/types.js";import{analyze as i}from"../tasks/analyze/analyze.js";import{partition as a}from"../tasks/partition/partition.js";import{createStackedBranches as o}from"../git/apply-branches.js";import{parseUnifiedDiff as s}from"../git/utils/hunk-parser.js";import{collectCommitsForSplit as c}from"../git/diff-collector.js";import{requireExec as l}from"../git/require-exec.js";import{rethrowAsWorkflowError as u}from"../utils/cancellation.js";import{defaultPhaseMessages as d}from"./compose.js";import{UndoStore as f,capturePreOperationState as p,generateUndoId as m}from"@gitkraken/shared-tools";async function h(t){let{git:f,model:m,branchName:h,baseBranch:v=`main`,instructions:y,maxRetries:b,maxTokens:x,onProgress:S,onBeforeApply:C,cancellation:w,onBeforeModelCall:T,onAfterModelCall:E,onBeforePrompt:D}=t,O=r(),k=[];S?.({phase:`collecting`,...d.collecting});let A=Date.now(),{commits:j,branchSha:M}=await c(f,h,v);if(k.push({name:`collecting`,durationMs:Date.now()-A,attempts:1}),j.length===0)throw new e(`NO_CHANGES`,`No commits found between '${v}' and '${h}'`);if(j.length<2)throw new e(`NO_CHANGES`,`Too few commits to split. A branch needs at least 2 commits.`);let N=s(j.map(e=>e.diff).join(`
2
+ `)),P=j.map(e=>({id:e.sha,message:e.message,explanation:``,hunkIndices:[]})),F;try{F=await i({hunks:N,commitMessages:j.map(e=>e.message),commitOrder:j.map(e=>e.sha),branchName:h,baseBranch:v,instructions:y,redactHunkContent:t.redactHunkContent,model:m,maxRetries:b,maxTokens:x,onProgress:e=>S?.({phase:`analyzing`,...d.analyzing,event:e}),cancellation:w,onBeforeModelCall:T,onAfterModelCall:E,onBeforePrompt:D})}catch(e){u(e,w)}F.usage&&n(O,F.usage),k.push({name:`analyzing`,durationMs:F.durationMs,attempts:F.attempts,usage:F.usage,callIds:F.callIds});let I=j.map(e=>e.sha),L=await g(f),R;try{R=await a({commits:P,orderedCommitIds:I,sourceBranchName:h,baseBranch:v,instructions:y,existingBranchNames:L,model:m,session:F.session,maxRetries:b,maxTokens:x,onProgress:e=>S?.({phase:`partitioning`,...d.partitioning,event:e}),cancellation:w,onBeforeModelCall:T,onAfterModelCall:E,onBeforePrompt:D})}catch(e){u(e,w)}R.usage&&n(O,R.usage),k.push({name:`partitioning`,durationMs:R.durationMs,attempts:R.attempts,usage:R.usage,callIds:R.callIds});let z={type:`branch`,hunks:N,commitMessages:j.map(e=>e.message),branchName:h,baseBranch:v},B={analysis:F.result,partitioning:R.result,commits:j};if(R.result.partitions.length<=1||C&&!await C(B))return{plan:B,partitions:R.result.partitions,session:R.session,usage:O,phases:k,source:z};let V=await p(l(f,`capturing pre-operation state`));S?.({phase:`applying`,...d.applying});let H=Date.now();await o({git:f,partitions:R.result.partitions,originalBranchName:h,originalBranchSha:M}),k.push({name:`applying`,durationMs:Date.now()-H,attempts:1});let U=await _(f,V,R.result.partitions,h,M);return{plan:B,partitions:R.result.partitions,session:R.session,usage:O,phases:k,source:z,undoId:U}}async function g(e){let n=(await t(e,{format:`%(refname:short)`,prefix:`refs/heads/`})).trim().split(`
3
+ `).filter(Boolean);return new Set(n)}async function _(e,t,n,r,i){let a=m(),o=n.map(e=>({kind:`branch`,name:e.branchName,sha:e.commitIds[e.commitIds.length-1]})),s={version:1,id:a,createdAt:new Date().toISOString(),description:`split: ${n.length} branches from '${r}'`,previousHead:{sha:t.headSha,branch:t.headBranch},workdirClean:t.workdirClean,workdirBackupSha:t.workdirBackupSha,createdRefs:o,deletedRefs:[{kind:`branch`,name:r,sha:i}],movedRefs:[]};return await new f(l(e,`undo manifest persistence`)).save(s),a}export{h as split};
@@ -87,5 +87,18 @@ interface ComposeContext {
87
87
  issueUrl?: string;
88
88
  pullRequestUrl?: string;
89
89
  }
90
+ /**
91
+ * Opt-out switches for individual rewrite-range safety checks. Each flag, when `true`, suppresses
92
+ * one specific pre-apply validation; omitting the object (or a flag) keeps that check active.
93
+ */
94
+ interface OverrideSafetyFailures {
95
+ /**
96
+ * Skip the interior-ref check. When `true`, a rewrite proceeds even if branches or tags point
97
+ * at commits inside the rewritten range. Those refs are left untouched: they stay on the
98
+ * pre-rewrite commits (which remain reachable through them) rather than following the rewrite,
99
+ * so the caller is accepting that they diverge from the recomposed history.
100
+ */
101
+ interiorRefs?: boolean;
102
+ }
90
103
  //#endregion
91
- export { ComposeContext, ComposeSource, ComposeTarget };
104
+ export { ComposeContext, ComposeSource, ComposeTarget, OverrideSafetyFailures };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitkraken/compose-tools",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "AI-powered organization of git changes into clean, atomic commits and branches.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Axosoft, LLC dba GitKraken",