@gitkraken/compose-tools 0.4.0 → 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,19 @@ 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
+
9
22
  ## [0.4.0] - 2026-06-26
10
23
 
11
24
  ### Added
@@ -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};
@@ -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";
@@ -29,4 +30,4 @@ import { RefinePlanInput, RefinePlanResult, RefineProgressEvent, refinePlan } fr
29
30
  import { SplitInput, SplitPlan, SplitProgressEvent, SplitResult, split } from "./workflows/split.js";
30
31
  import { UndoComposeInput, ValidateUndoComposeInput, listUndoIds, undoCompose, validateUndoCompose } from "./workflows/undo.js";
31
32
  import { UndoForceOptions } from "@gitkraken/shared-tools";
32
- 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 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 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, estimatedCharactersPerToken, group, listUndoIds, order, partition, quickCompose, refinePlan, 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{refinePlan as v}from"./workflows/refine.js";import{split as y}from"./workflows/split.js";import{listUndoIds as b,undoCompose as x,validateUndoCompose as S}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,b as listUndoIds,d as order,f as partition,p as quickCompose,v as refinePlan,y as split,x as undoCompose,S 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};
@@ -1 +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}),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};
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};
@@ -25,6 +25,20 @@ interface RefineInput {
25
25
  * prompt and enforces their presence + substitutes their content (id, message,
26
26
  * explanation, hunkIndices) into the AI's response after validation. */
27
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[])[];
28
42
  /** Per-call user instructions appended to the refinement prompt. */
29
43
  instructions?: string;
30
44
  /** Free-text consumer-supplied context (e.g. UI-ordering notes) injected verbatim into
@@ -7,13 +7,17 @@ interface ValidateRefineParams {
7
7
  hunkCount: number;
8
8
  mode: 'commits' | 'branches';
9
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[])[];
10
13
  }
11
14
  /**
12
15
  * Two-stage validation for the refine task:
13
16
  *
14
17
  * 1. Base structural validation via `validateGroupResponse` — same invariants the rest of
15
18
  * the library enforces (hunk index uniqueness + coverage, non-empty commits, unique ids).
16
- * 2. Locked-commits check + content substitution + post-substitution invariant check.
19
+ * 2. Locked-commits check + content substitution + post-substitution invariant check +
20
+ * locked-order restoration (see {@link restoreLockedOrdering}).
17
21
  *
18
22
  * The substitution step is what makes refine resilient to minor AI drift: when the AI emits
19
23
  * a commit with a locked id but slightly different message text, we silently restore the
@@ -1 +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 a=i.result;if(r.lockedCommits.size===0)return i;let o=new Map;for(let e of a.branches)for(let t=0;t<e.commits.length;t++)o.set(e.commits[t].id,{branch:e,commitIndex:t});let s=[];for(let e of r.lockedCommits.keys())o.has(e)||s.push(e);if(s.length>0){let e=s.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=o.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 c=n(a,r.hunkCount);return c==null?{valid:!0,result:a}:{valid:!1,errors:[c],retryFeedback:`After restoring the locked commits' original hunk assignments, the resulting plan is structurally invalid: ${c}. 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){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};
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};
@@ -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,8 @@ 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'];
50
52
  }
51
53
  /**
52
54
  * Size of the diff the AI operated on — files, hunks, and per-line counts.
@@ -101,6 +103,8 @@ interface ApplyComposePlanInput {
101
103
  * diff hash never equals the cached filtered one). See {@link ComposeInput.hunkFilter}.
102
104
  */
103
105
  hunkFilter?: ComposeInput['hunkFilter'];
106
+ /** See {@link ComposeInput.overrideSafetyFailures}. */
107
+ overrideSafetyFailures?: ComposeInput['overrideSafetyFailures'];
104
108
  }
105
109
  /** Result of {@link applyComposePlan}. */
106
110
  interface ApplyComposePlanResult {
@@ -279,6 +283,11 @@ interface ComposeInput {
279
283
  applyCommitIds?: string[];
280
284
  /** Stash message used by the apply path when stashing pending workdir state. Defaults to `'compose backup'`. */
281
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;
282
291
  }
283
292
  interface ComposePlan {
284
293
  analysis?: AnalysisResult;
@@ -460,4 +469,4 @@ declare function composePlan(input: ComposePlanInput): Promise<ComposePlanResult
460
469
  */
461
470
  declare function applyComposePlan(input: ApplyComposePlanInput): Promise<ApplyComposePlanResult>;
462
471
  //#endregion
463
- export { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, ComposeDepth, ComposeDiffStats, 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 ne(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 Z(i,o);re(o,f),await q(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`,Q(o));let{hunks:g,filteredCount:_}=await W(h.hunks,r.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Q(o));let v=H(g);s?.({phase:`collecting`,...F.collecting,stats:v});let y=_!==void 0&&_>0?await G(i,h.snapshot,g):h.snapshot,b={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},O;try{O=await ee(d,b)}catch(e){D(e,l)}let k={analysis:O.analysis,grouping:O.grouping,ordering:O.ordering,branches:O.branchPlans,allOrderedCommits:O.allOrderedCommits};if(p.push(...O.phases),c&&!await c(k))return{plan:k,snapshot:y,session:O.session,usage:O.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:v,feedbackTargetCallId:U(p),hunksFilteredCount:_};let A=await N(w(i,`capturing pre-operation state`));s?.({phase:`verifying`,...F.verifying});let j=Date.now(),M=await S(i,h.snapshot,K(o,h));if(!M.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${M.error}`);p.push({name:`verifying`,durationMs:Date.now()-j,attempts:1});let P=J(O.allOrderedCommits,r.applyCommitIds),{leftoverHunks:I,leftoverPatch:L}=X(P,h.hunks),R=I.length>0?await G(i,y,Y(P,h.hunks)):y;s?.({phase:`applying`,...F.applying});let z=Date.now(),B=await C({git:i,source:o,target:f,branchPlans:O.branchPlans,allOrderedCommits:P,hunks:h.hunks,snapshot:R,collection:h,signing:r.signing,authorAttribution:r.authorAttribution,stashLabel:r.stashLabel,leftoverPatch:L});p.push({name:`applying`,durationMs:Date.now()-z,attempts:1});let V=await ae(i,A,B.mutations,f,o);return{plan:k,snapshot:R,partitions:B.partitions,partitionGroups:B.partitionGroups,session:O.session,usage:O.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:v,commitShas:B.commitShas,undoId:V,feedbackTargetCallId:U(p),hunksFilteredCount:_,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: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 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});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:a,onProgress:o}=n,{plan:s,snapshot:c,source:l}=a,u=n.source??l,d=n.target??a?.target??await Z(r,u);re(u,d),await q(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`,Q(u));let{hunks:g}=await W(h.hunks,n.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Q(u));let _=H(g);o?.({phase:`collecting`,...F.collecting,stats:_});let v=g.length<h.hunks.length?await G(r,h.snapshot,g):h.snapshot,y={...h,hunks:g,snapshot:v};if(y.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 b=Date.now(),E=await S(r,y.snapshot,K(u,y));if(!E.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${E.error}`);p.push({name:`verifying`,durationMs:Date.now()-b,attempts:1});let D=J(s.allOrderedCommits,n.applyCommitIds),{leftoverHunks:O,leftoverPatch:k}=X(D,y.hunks),A=O.length>0?await G(r,y.snapshot,Y(D,y.hunks)):y.snapshot;o?.({phase:`applying`,...F.applying});let j=Date.now(),M=await C({git:r,source:u,target:d,branchPlans:s.branches,allOrderedCommits:D,hunks:y.hunks,snapshot:A,collection:y,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,f,M.mutations,d,u);return{plan:s,snapshot:A,partitions:M.partitions,partitionGroups:M.partitionGroups,session:{messages:[],systemPrompt:``,completedSteps:{}},usage:i(),phases:p,source:{type:u.type===`branch`?`branch`:`workdir`,hunks:y.hunks,commitMessages:y.commitMessages,branchName:y.branchName,baseBranch:y.baseBranch},diffStats:_,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 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 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,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 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=y(r);return{leftoverHunks:r,leftoverPatch:i.length>0?i:void 0}}async function Z(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 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 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.composedBranchCount||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{te as applyComposePlan,I as compose,V 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};
@@ -84,5 +84,13 @@ interface RefinePlanResult {
84
84
  * compose for apply-time validation.
85
85
  */
86
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[][];
87
95
  //#endregion
88
- export { RefinePlanInput, RefinePlanResult, RefineProgressEvent, refinePlan };
96
+ export { RefinePlanInput, RefinePlanResult, RefineProgressEvent, computeLockedRuns, refinePlan };
@@ -1 +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 s=[];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.`);s.push(n)}let c=a(i.priorPlan),l=o(i.priorPlan),u=Date.now(),d;try{d=await r({session:i.session,lockedCommits:s,instructions:i.instructions,clientContext:i.clientContext,hunkCount:c,mode:l,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 f=d.result,p=f.branches.map(e=>({branchGroup:e,orderedCommitIds:e.commits.map(e=>e.id)})),m={branches:p.map(e=>({branchId:e.branchGroup.id,orderedCommitIds:e.orderedCommitIds})),rationale:``},h=p.flatMap(e=>e.branchGroup.commits);return{plan:{analysis:i.session.completedSteps.analyze,grouping:f,ordering:m,branches:p,allOrderedCommits:h},session:d.session,usage:d.usage??t(),phases:[{name:`refine`,durationMs:Date.now()-u,attempts:d.attempts,usage:d.usage,callIds:d.callIds}],feedbackTargetCallId:d.callIds.length>0?d.callIds[d.callIds.length-1]:void 0}}function a(e){let t=new Set;for(let n of e.allOrderedCommits)for(let e of n.hunkIndices)t.add(e);return t.size}function o(e){return e.branches.length===1&&e.branches[0].branchGroup.id===`default`&&e.branches[0].branchGroup.name===``?`commits`:`branches`}export{i as refinePlan};
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.4.0",
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",