@gitkraken/compose-tools 0.1.0 → 0.3.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +109 -22
  3. package/dist/errors/workflow.d.ts +16 -6
  4. package/dist/errors/workflow.js +1 -1
  5. package/dist/git/apply-branches.d.ts +0 -2
  6. package/dist/git/apply-branches.js +1 -1
  7. package/dist/git/apply-target.d.ts +10 -3
  8. package/dist/git/apply-target.js +1 -1
  9. package/dist/git/commit-chain.d.ts +47 -33
  10. package/dist/git/commit-chain.js +7 -5
  11. package/dist/git/diff-collector.d.ts +69 -19
  12. package/dist/git/diff-collector.js +6 -7
  13. package/dist/git/index.d.ts +2 -2
  14. package/dist/git/index.js +1 -1
  15. package/dist/git/port-dispatch.d.ts +11 -3
  16. package/dist/git/port-dispatch.js +4 -3
  17. package/dist/git/reparent.d.ts +39 -0
  18. package/dist/git/reparent.js +5 -0
  19. package/dist/git/require-exec.js +1 -1
  20. package/dist/git/safety-range.d.ts +33 -0
  21. package/dist/git/safety-range.js +5 -0
  22. package/dist/git/safety.d.ts +3 -3
  23. package/dist/git/safety.js +1 -1
  24. package/dist/git/types.d.ts +16 -15
  25. package/dist/git/utils/authorship.d.ts +22 -0
  26. package/dist/git/utils/authorship.js +1 -0
  27. package/dist/git/utils/conventions.d.ts +0 -1
  28. package/dist/git/utils/conventions.js +4 -4
  29. package/dist/git/utils/hunk-parser.d.ts +1 -1
  30. package/dist/git/utils/hunk-parser.js +1 -1
  31. package/dist/git/utils/temp-index.d.ts +1 -1
  32. package/dist/git/utils/temp-index.js +1 -1
  33. package/dist/index.d.ts +5 -2
  34. package/dist/index.js +1 -1
  35. package/dist/ports/git.d.ts +48 -7
  36. package/dist/tasks/ai-loop.d.ts +9 -15
  37. package/dist/tasks/ai-loop.js +2 -2
  38. package/dist/tasks/analyze/analyze.js +1 -1
  39. package/dist/tasks/analyze/prompt.d.ts +2 -0
  40. package/dist/tasks/analyze/prompt.js +5 -5
  41. package/dist/tasks/analyze/types.d.ts +6 -1
  42. package/dist/tasks/analyze/validator.js +1 -1
  43. package/dist/tasks/compose-group/compose-group.js +1 -1
  44. package/dist/tasks/compose-group/prompt.d.ts +2 -0
  45. package/dist/tasks/compose-group/prompt.js +52 -46
  46. package/dist/tasks/compose-group/types.d.ts +6 -1
  47. package/dist/tasks/group/group.js +1 -1
  48. package/dist/tasks/group/prompt.d.ts +2 -0
  49. package/dist/tasks/group/prompt.js +45 -39
  50. package/dist/tasks/group/types.d.ts +6 -1
  51. package/dist/tasks/group/validator.js +9 -4
  52. package/dist/tasks/order/order.js +1 -1
  53. package/dist/tasks/order/types.d.ts +3 -1
  54. package/dist/tasks/order/validator.js +1 -1
  55. package/dist/tasks/partition/partition.js +1 -1
  56. package/dist/tasks/partition/types.d.ts +3 -1
  57. package/dist/tasks/partition/validator.js +1 -1
  58. package/dist/tasks/quick-compose/prompt.d.ts +2 -0
  59. package/dist/tasks/quick-compose/prompt.js +24 -15
  60. package/dist/tasks/quick-compose/quick-compose.js +1 -1
  61. package/dist/tasks/quick-compose/types.d.ts +6 -1
  62. package/dist/tasks/quick-compose/validator.js +10 -2
  63. package/dist/tasks/types.d.ts +10 -9
  64. package/dist/tasks/utils/redact.d.ts +14 -0
  65. package/dist/tasks/utils/redact.js +1 -0
  66. package/dist/utils/cancellation.d.ts +26 -0
  67. package/dist/utils/cancellation.js +1 -0
  68. package/dist/workflows/compose.d.ts +89 -10
  69. package/dist/workflows/compose.js +3 -2
  70. package/dist/workflows/split.d.ts +14 -3
  71. package/dist/workflows/split.js +3 -3
  72. package/dist/workflows/types.d.ts +46 -18
  73. package/dist/workflows/undo.d.ts +18 -11
  74. package/dist/workflows/undo.js +1 -1
  75. package/package.json +2 -2
  76. package/dist/cancellation.d.ts +0 -20
  77. package/dist/cancellation.js +0 -1
@@ -0,0 +1,26 @@
1
+ //#region src/utils/cancellation.d.ts
2
+ /**
3
+ * True for native `AbortError` thrown by signal-aware adapters
4
+ * (e.g. shared-tools' AI loop, `child_process.spawn` with a signal,
5
+ * `fetch` with a signal). Workflow files use this in their try/catch
6
+ * to translate native abort errors into `ComposeWorkflowError('CANCELLED')`.
7
+ */
8
+ declare function isAbortError(err: unknown): boolean;
9
+ /**
10
+ * Translate a caught error into the workflow-domain shape, then throw.
11
+ *
12
+ * - `ComposeWorkflowError` is rethrown unchanged (already in the right shape).
13
+ * - Native `AbortError` (or any error caught while the cancellation token is
14
+ * already aborted) becomes `ComposeWorkflowError('CANCELLED')`.
15
+ * - Anything else is rethrown unchanged.
16
+ *
17
+ * Workflows that delegate AI work to a task wrap that single task call in
18
+ * `try { ... } catch (err) { rethrowAsWorkflowError(err, input.cancellation); }`.
19
+ * Git operations elsewhere in the workflow do not pass `cancellation` into
20
+ * underlying ops — they run to completion regardless — and so cannot
21
+ * surface `AbortError`. Returns `never` so callers don't have to add an
22
+ * unreachable trailing throw.
23
+ */
24
+ declare function rethrowAsWorkflowError(err: unknown, cancellation?: AbortSignal): never;
25
+ //#endregion
26
+ export { isAbortError, rethrowAsWorkflowError };
@@ -0,0 +1 @@
1
+ import{ComposeWorkflowError as e}from"../errors/workflow.js";function t(e){return e instanceof Error&&(e.name===`AbortError`||e.name===`DOMException`)}function n(n,r){throw n instanceof e?n:t(n)||r?.aborted?new e(`CANCELLED`,n instanceof Error?n.message:`Operation cancelled`,{cause:n}):n}export{t as isAbortError,n as rethrowAsWorkflowError};
@@ -3,13 +3,14 @@ import { ComposeGitPort, SigningConfig } from "../ports/git.js";
3
3
  import { BranchPartition, CommitSuggestion, ComposeHunk, ComposeTaskName, PromptOverride } from "../tasks/types.js";
4
4
  import { ConventionDescriptor } from "../git/utils/conventions.js";
5
5
  import { OnAfterModelCall, OnBeforeModelCall, OnBeforePrompt } from "../tasks/ai-loop.js";
6
+ import { RedactHunkContent } from "../tasks/utils/redact.js";
6
7
  import { AnalysisResult } from "../tasks/analyze/types.js";
7
8
  import { GroupingResult, GroupingStrategy } from "../tasks/group/types.js";
8
9
  import { OrderingResult } from "../tasks/order/types.js";
9
10
  import { ComposeSession } from "../tasks/session.js";
10
11
  import { PartitionStrategy } from "../tasks/partition/types.js";
11
12
  import { ComposeContext, ComposeSource, ComposeTarget } from "./types.js";
12
- import { SafetySnapshot } from "../git/types.js";
13
+ import { SafetySnapshot, StashConflict } from "../git/types.js";
13
14
  import { ComposeBranchPlan } from "../git/apply-target.js";
14
15
  import { AiModelPort, UndoId } from "@gitkraken/shared-tools";
15
16
 
@@ -31,13 +32,17 @@ interface ComposePlanInput {
31
32
  strategy?: ComposeStrategy;
32
33
  depth?: ComposeDepth;
33
34
  maxRetries?: number;
35
+ /** See {@link ComposeInput.maxTokens}. */
36
+ maxTokens?: number;
34
37
  onProgress?: (event: ComposeProgressEvent) => void;
35
- signal?: AbortSignal;
38
+ cancellation?: AbortSignal;
36
39
  onBeforeModelCall?: OnBeforeModelCall;
37
40
  onAfterModelCall?: OnAfterModelCall;
38
41
  onBeforePrompt?: OnBeforePrompt;
39
42
  /** See {@link ComposeInput.hunkFilter}. */
40
43
  hunkFilter?: ComposeInput['hunkFilter'];
44
+ /** See {@link ComposeInput.redactHunkContent}. */
45
+ redactHunkContent?: ComposeInput['redactHunkContent'];
41
46
  /** See {@link ComposeInput.session}. */
42
47
  session?: ComposeSession;
43
48
  /** See {@link ComposeInput.promptOverrides}. */
@@ -56,18 +61,33 @@ interface ComposePlanResult {
56
61
  /** See {@link ComposeResult.hunksFilteredCount}. */
57
62
  hunksFilteredCount?: number;
58
63
  }
59
- /** Pure-apply input — takes a previously computed plan and writes it to the repository. */
64
+ /**
65
+ * Pure-apply input — takes a previously computed plan and writes it to the repository.
66
+ *
67
+ * No `cancellation` — `applyComposePlan` is git-only and runs to completion.
68
+ */
60
69
  interface ApplyComposePlanInput {
61
70
  git: ComposeGitPort;
62
71
  applyPlan: ComposeApplyPlan;
63
72
  source?: ComposeSource;
64
73
  target?: ComposeTarget;
65
74
  onProgress?: (event: ComposeProgressEvent) => void;
66
- signal?: AbortSignal;
67
75
  /** GPG signing config for produced commits. See {@link ComposeInput.signing}. */
68
76
  signing?: SigningConfig;
69
77
  /** How to pick the primary author for each commit. See {@link ComposeInput.authorAttribution}. */
70
78
  authorAttribution?: ComposeInput['authorAttribution'];
79
+ /** See {@link ComposeInput.applyCommitIds}. */
80
+ applyCommitIds?: string[];
81
+ /** See {@link ComposeInput.stashLabel}. */
82
+ stashLabel?: string;
83
+ /**
84
+ * Same filter that was passed to {@link composePlan} when the cached plan was created.
85
+ * The plan's snapshot was rebuilt from the filtered hunks; the apply step re-collects
86
+ * from source and must re-apply this filter before comparing the diff hash, otherwise
87
+ * the comparison fails on every consumer that uses a filter (the fresh unfiltered
88
+ * diff hash never equals the cached filtered one). See {@link ComposeInput.hunkFilter}.
89
+ */
90
+ hunkFilter?: ComposeInput['hunkFilter'];
71
91
  }
72
92
  /** Result of {@link applyComposePlan}. */
73
93
  interface ApplyComposePlanResult {
@@ -76,6 +96,12 @@ interface ApplyComposePlanResult {
76
96
  partitionGroups?: BranchPartition[][];
77
97
  phases: PhaseDetail[];
78
98
  undoId?: UndoId;
99
+ /** Plan commit IDs actually written. A prefix of `plan.allOrderedCommits.map(c => c.id)` for partial apply. */
100
+ appliedCommitIds?: string[];
101
+ /** Hunk indices not covered by the applied commits. Empty for full apply. */
102
+ unappliedHunkIndices?: number[];
103
+ /** Set when the post-apply stash pop hit conflicts. Apply itself succeeded; the named stash is preserved. */
104
+ stashConflict?: StashConflict;
79
105
  }
80
106
  /** Controls how many AI pipeline steps run and how deeply each reasons. */
81
107
  type ComposeDepth = 'quick' | 'balanced' | 'deep';
@@ -115,13 +141,28 @@ interface ComposeInput {
115
141
  /** Controls AI pipeline depth. 'quick' = single AI call. 'balanced' = 2-3 calls (default). 'deep' = 4+ calls with full analysis. */
116
142
  depth?: ComposeDepth;
117
143
  maxRetries?: number;
144
+ /**
145
+ * Output-token cap forwarded to every AI task in the pipeline. Defaults to
146
+ * `runAiLoop`'s 16384. Bump on very large diffs whose structured output
147
+ * (e.g. analyze's per-hunk annotations) overflows the cap mid-JSON; the
148
+ * validator will surface that as a JSON-parse failure with a "Response
149
+ * tail" preview if it happens.
150
+ */
151
+ maxTokens?: number;
118
152
  onProgress?: (event: ComposeProgressEvent) => void;
119
153
  onBeforeApply?: (plan: ComposePlan) => Promise<boolean>;
120
154
  /**
121
- * Optional cancellation signal. Forwarded to every AI call and every git operation.
122
- * On abort, the workflow throws `WorkflowError('CANCELLED')`.
155
+ * Optional cancellation token. Forwarded only to the AI loop (via the
156
+ * tasks the workflow runs). Git operations &mdash; collection, snapshot
157
+ * verification, applying, undo manifest persistence &mdash; run to
158
+ * completion regardless of whether `cancellation` was aborted; mid-flight
159
+ * cancellation of a git op risks a half-finished mutation, so by design
160
+ * only the AI loop is cancellable.
161
+ *
162
+ * On AI-loop abort, the workflow translates the native `AbortError` into
163
+ * `ComposeWorkflowError('CANCELLED')`.
123
164
  */
124
- signal?: AbortSignal;
165
+ cancellation?: AbortSignal;
125
166
  /**
126
167
  * Called immediately before each model.generate invocation. Use for progress UI,
127
168
  * telemetry, or correlation. The library doesn't interpret the result.
@@ -131,7 +172,7 @@ interface ComposeInput {
131
172
  onAfterModelCall?: OnAfterModelCall;
132
173
  /**
133
174
  * Gating hook called immediately before each model.generate call. Returning
134
- * `false` aborts the workflow with `WorkflowError('CANCELLED')`. Use for
175
+ * `false` aborts the workflow with `ComposeWorkflowError('CANCELLED')`. Use for
135
176
  * large-prompt confirmations, cost warnings, org policy checks, etc.
136
177
  */
137
178
  onBeforePrompt?: OnBeforePrompt;
@@ -151,6 +192,16 @@ interface ComposeInput {
151
192
  * values so the plan still references the authoritative collection.
152
193
  */
153
194
  hunkFilter?: (hunks: ComposeHunk[]) => Promise<ComposeHunk[]> | ComposeHunk[];
195
+ /**
196
+ * Predicate that returns true for hunks whose patch body should be replaced with a
197
+ * placeholder in AI prompts. Matching hunks keep their filename, hunk header, index,
198
+ * line counts, and rename metadata; only the body is masked. The apply path still uses
199
+ * the real content.
200
+ *
201
+ * Intended for `.aiexclude`/`.aiignore`/`.cursorignore`-style features: the AI assigns
202
+ * matching hunks to commits by filename alone, never seeing the diff body.
203
+ */
204
+ redactHunkContent?: RedactHunkContent;
154
205
  /**
155
206
  * Per-task prompt overrides, keyed by {@link ComposeTaskName}. Consumers with
156
207
  * existing prompt template services (server-driven templates, provider-specific
@@ -204,6 +255,17 @@ interface ComposeInput {
204
255
  name: string;
205
256
  email: string;
206
257
  } | undefined);
258
+ /**
259
+ * Apply only the listed commit ids from `plan.allOrderedCommits`. Hunks belonging to omitted
260
+ * commits (plus unassigned hunks, e.g. those dropped by `hunkFilter`) become unstaged WIP on
261
+ * top of the rewritten branch. Undefined applies every commit.
262
+ *
263
+ * On destructive targets (`rewrite-range`, or `branch`/`auto` with `preserveOriginal: false`)
264
+ * with `branch`/`commit-range` sources, HEAD must be non-detached and at the source's tip.
265
+ */
266
+ applyCommitIds?: string[];
267
+ /** Stash message used by the apply path when stashing pending workdir state. Defaults to `'compose backup'`. */
268
+ stashLabel?: string;
207
269
  }
208
270
  interface ComposePlan {
209
271
  analysis?: AnalysisResult;
@@ -331,6 +393,12 @@ interface ComposeResult {
331
393
  * Undefined when no filter ran.
332
394
  */
333
395
  hunksFilteredCount?: number;
396
+ /** See {@link ApplyComposePlanResult.appliedCommitIds}. */
397
+ appliedCommitIds?: string[];
398
+ /** See {@link ApplyComposePlanResult.unappliedHunkIndices}. */
399
+ unappliedHunkIndices?: number[];
400
+ /** See {@link ApplyComposePlanResult.stashConflict}. */
401
+ stashConflict?: StashConflict;
334
402
  }
335
403
  /**
336
404
  * Compose code changes into a clean set of commits and branches.
@@ -347,6 +415,11 @@ interface ComposeResult {
347
415
  * Caller is responsible for the `model` lifecycle. If `applyPlan` is provided, the AI
348
416
  * phases are skipped and the previously computed plan is re-applied; the workdir
349
417
  * snapshot in the plan is verified before applying.
418
+ *
419
+ * Cancellation only flows into the AI loop (via the tasks the workflow runs).
420
+ * Git operations are not cancellable mid-flight by design — they always run to
421
+ * completion. When the AI loop aborts, the workflow translates the native
422
+ * `AbortError` into `ComposeWorkflowError('CANCELLED')`.
350
423
  */
351
424
  declare function compose(input: ComposeInput): Promise<ComposeResult>;
352
425
  /**
@@ -357,15 +430,21 @@ declare function compose(input: ComposeInput): Promise<ComposeResult>;
357
430
  * consumers whose UX lets the user edit the plan before committing.
358
431
  *
359
432
  * For single-shot "plan and apply in one go", use {@link compose} directly.
433
+ *
434
+ * Cancellation handling matches {@link compose} — only the AI loop is
435
+ * cancellable; git operations run to completion.
360
436
  */
361
437
  declare function composePlan(input: ComposePlanInput): Promise<ComposePlanResult>;
362
438
  /**
363
439
  * Apply a previously computed plan (from {@link composePlan}) to the repository.
364
440
  *
365
441
  * Verifies the snapshot against the current workdir before applying — if the
366
- * subsets the plan touches have drifted, throws `WorkflowError('SAFETY_CHECK_FAILED')`.
442
+ * subsets the plan touches have drifted, throws `ComposeWorkflowError('SAFETY_CHECK_FAILED')`.
367
443
  * On success, returns an `undoId` that {@link undoCompose} can reverse.
444
+ *
445
+ * No AI runs in this path, so cancellation has no effect — the apply phase
446
+ * runs to completion regardless of whether `cancellation` was aborted.
368
447
  */
369
448
  declare function applyComposePlan(input: ApplyComposePlanInput): Promise<ApplyComposePlanResult>;
370
449
  //#endregion
371
- export { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, ComposeDepth, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, type ComposeSource, ComposeStrategy, type ComposeTarget, applyComposePlan, compose, composePlan, defaultPhaseMessages };
450
+ export { ApplyComposePlanInput, ApplyComposePlanResult, ComposeApplyPlan, type ComposeBranchPlan, type ComposeContext, ComposeDepth, ComposeInput, ComposePlan, ComposePlanInput, ComposePlanResult, ComposeProgressEvent, ComposeResult, type ComposeSource, ComposeStrategy, type ComposeTarget, type StashConflict, applyComposePlan, compose, composePlan, defaultPhaseMessages };
@@ -1,2 +1,3 @@
1
- import{WorkflowError as e,WorkflowInputError as t}from"../errors/workflow.js";import{throwIfCancelled as n}from"../cancellation.js";import{GitError as r}from"../errors/git.js";import{addUsage as i,emptyUsage as a}from"../ai/types.js";import{analyze as o}from"../tasks/analyze/analyze.js";import{composeGroup as s}from"../tasks/compose-group/compose-group.js";import{group as c}from"../tasks/group/group.js";import{order as l}from"../tasks/order/order.js";import{partition as u}from"../tasks/partition/partition.js";import{applyPatchToIndex as d,diffTree as f,forEachRef as p,readTree as m,revParse as h,writeTree as g}from"../git/port-dispatch.js";import{buildPatchForCommit as _}from"../git/utils/patch-builder.js";import{sha256 as v}from"../git/utils/crypto.js";import{collectFromSource as y}from"../git/diff-collector.js";import{verifySnapshot as b}from"../git/safety.js";import{applyToTarget as x}from"../git/apply-target.js";import{requireExec as S}from"../git/require-exec.js";import{extractConventions as C}from"../git/utils/conventions.js";import{randomUUID as w}from"node:crypto";import{unlinkSync as T}from"node:fs";import{tmpdir as E}from"node:os";import{join as D}from"node:path";import{UndoStore as O,capturePreOperationState as k,generateUndoId as A}from"@gitkraken/shared-tools";const j={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 M(i){if(!i.applyPlan&&!i.model)throw new t(`Either 'model' or 'applyPlan' must be provided`);if(i.applyPlan)return B(i);let{git:a,model:o,source:s={type:`workdir`},onProgress:c,onBeforeApply:l,signal:u}=i;n(u);let d=o,f=i.depth??`balanced`,p=i.target??G(s);q(s,p);let m=[];c?.({phase:`collecting`,...j.collecting});let h=Date.now(),g=await y(a,s,{signal:u});if(m.push({name:`collecting`,durationMs:Date.now()-h,attempts:1}),g.hunks.length===0)throw new r(`NO_CHANGES`,K(s));let{hunks:_,filteredCount:v}=await U(g.hunks,i.hunkFilter);if(_.length===0)throw new r(`NO_CHANGES`,K(s));let w=v!==void 0&&v>0?await W(a,g.snapshot,_,u):g.snapshot,T=await N(f,{hunks:_,collection:g,conventions:i.conventions??await C({git:a,author:i.conventionAuthor,signal:u}),existingBranchNames:await Y(a,u),groupMode:p.type===`auto`?`branches`:`commits`,target:p,model:d,instructions:i.instructions,context:i.context,strategy:i.strategy,maxRetries:i.maxRetries,onProgress:c,signal:u,onBeforeModelCall:i.onBeforeModelCall,onAfterModelCall:i.onAfterModelCall,onBeforePrompt:i.onBeforePrompt,session:i.session,promptOverrides:i.promptOverrides}),E={analysis:T.analysis,grouping:T.grouping,ordering:T.ordering,branches:T.branchPlans,allOrderedCommits:T.allOrderedCommits};if(m.push(...T.phases),l&&!await l(E))return{plan:E,snapshot:w,session:T.session,usage:T.usage,phases:m,source:{type:s.type===`branch`?`branch`:`workdir`,hunks:_,commitMessages:g.commitMessages,branchName:g.branchName,baseBranch:g.baseBranch},diffStats:V(_),feedbackTargetCallId:H(m),hunksFilteredCount:v};let D=await k(S(a,`capturing pre-operation state`),{signal:u});c?.({phase:`verifying`,...j.verifying});let O=Date.now(),A=await b(a,g.snapshot,{includeUnstaged:s.type===`workdir`&&!s.stagedOnly,includeUntracked:g.untrackedIncluded,signal:u});if(!A.valid)throw new e(`SAFETY_CHECK_FAILED`,`Safety check failed: ${A.error}`);m.push({name:`verifying`,durationMs:Date.now()-O,attempts:1}),c?.({phase:`applying`,...j.applying});let M=Date.now(),P=await x({git:a,target:p,branchPlans:T.branchPlans,allOrderedCommits:T.allOrderedCommits,hunks:g.hunks,snapshot:w,collection:g,signal:u,signing:i.signing,authorAttribution:i.authorAttribution});m.push({name:`applying`,durationMs:Date.now()-M,attempts:1});let F=await X(a,D,P.mutations,p,s,u);return{plan:E,snapshot:w,partitions:P.partitions,partitionGroups:P.partitionGroups,session:T.session,usage:T.usage,phases:m,source:{type:s.type===`branch`?`branch`:`workdir`,hunks:_,commitMessages:g.commitMessages,branchName:g.branchName,baseBranch:g.baseBranch},diffStats:V(_),commitShas:P.commitShas,undoId:F,feedbackTargetCallId:H(m),hunksFilteredCount:v}}function N(e,t){switch(e){case`quick`:return P(t);case`balanced`:return F(t);case`deep`:return L(t)}}async function P(e){return I(e,{lightweight:!0,skipOrder:!0})}async function F(e){return I(e,{includeAnalysis:!0})}async function I(e,t){let{hunks:r,collection:o,conventions:c,existingBranchNames:d,groupMode:f,target:p,model:m,instructions:h,context:g,strategy:_,maxRetries:v,onProgress:y,signal:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e;n(b);let w=a(),T=[],E=await s({hunks:r,commitMessages:o.commitMessages,commitOrder:o.commitOrder,branchName:o.branchName,baseBranch:o.baseBranch,issueUrl:g?.issueUrl,pullRequestUrl:g?.pullRequestUrl,instructions:h,conventions:c,mode:f,strategy:_?.grouping,existingBranchNames:f===`branches`?d:void 0,includeAnalysis:t.includeAnalysis,lightweight:t.lightweight,condensed:t.condensed,model:m,session:e.session,maxRetries:v,onProgress:e=>y?.({phase:`compose-grouping`,...j[`compose-grouping`],event:e}),signal:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.[`compose-group`]});E.usage&&i(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 l({branches:E.result.branches,hunks:r,instructions:h,model:m,session:E.session,maxRetries:v,onProgress:e=>y?.({phase:`ordering`,...j.ordering,event:e}),signal:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});t.usage&&i(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(p.type===`auto`){let t=new Set(d);for(let e of E.result.branches)e.name&&t.add(e.name);for(let n of O){let r=await u({commits:J(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:h,conventions:c,strategy:_?.partition,existingBranchNames:t,model:m,maxRetries:v,onProgress:e=>y?.({phase:`partitioning`,...j.partitioning,branch:n.branchGroup.name,event:e}),signal:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});r.usage&&i(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=>J(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 L(e){let{hunks:t,collection:r,conventions:s,existingBranchNames:d,groupMode:f,target:p,model:m,instructions:h,context:g,strategy:_,maxRetries:v,onProgress:y,signal:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e;n(b);let w=a(),T=[],E=await o({hunks:t,commitMessages:r.commitMessages,commitOrder:r.commitOrder,branchName:r.branchName,baseBranch:r.baseBranch,issueUrl:g?.issueUrl,pullRequestUrl:g?.pullRequestUrl,instructions:h,model:m,session:e.session,maxRetries:v,onProgress:e=>y?.({phase:`analyzing`,...j.analyzing,event:e}),signal:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.analyze});E.usage&&i(w,E.usage),T.push({name:`analyzing`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=await c({hunks:t,commitMessages:r.commitMessages,instructions:h,conventions:s,mode:f,strategy:_?.grouping,existingBranchNames:f===`branches`?d:void 0,model:m,session:E.session,maxRetries:v,onProgress:e=>y?.({phase:`grouping`,...j.grouping,event:e}),signal:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.group});D.usage&&i(w,D.usage),T.push({name:`grouping`,durationMs:D.durationMs,attempts:D.attempts,usage:D.usage,callIds:D.callIds});let O=await l({branches:D.result.branches,hunks:t,instructions:h,model:m,session:D.session,maxRetries:v,onProgress:e=>y?.({phase:`ordering`,...j.ordering,event:e}),signal:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});O.usage&&i(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(p.type===`auto`){let t=new Set(d);for(let e of D.result.branches)e.name&&t.add(e.name);for(let n of k){let r=await u({commits:J(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:h,conventions:s,strategy:_?.partition,existingBranchNames:t,model:m,maxRetries:v,onProgress:e=>y?.({phase:`partitioning`,...j.partitioning,branch:n.branchGroup.name,event:e}),signal:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});r.usage&&i(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,A=r.session;for(let e of r.result.partitions)t.add(e.branchName)}}let M=k.flatMap(e=>J(e.branchGroup,e.orderedCommitIds));return{analysis:E.result,grouping:D.result,ordering:O.result,branchPlans:k,allOrderedCommits:M,session:A,usage:w,phases:T}}async function R(e){let t=await M({...e,onBeforeApply:async()=>!1});return{plan:t.plan,snapshot:t.snapshot,source:t.source,session:t.session,usage:t.usage,phases:t.phases,feedbackTargetCallId:t.feedbackTargetCallId,hunksFilteredCount:t.hunksFilteredCount}}async function z(e){let t=await M({git:e.git,applyPlan:e.applyPlan,source:e.source,target:e.target,onProgress:e.onProgress,signal:e.signal,signing:e.signing,authorAttribution:e.authorAttribution});return{commitShas:t.commitShas,partitions:t.partitions,partitionGroups:t.partitionGroups,phases:t.phases,undoId:t.undoId}}async function B(t){let{git:i,applyPlan:o,onProgress:s,signal:c}=t;n(c);let{plan:l,snapshot:u,source:d}=o,f=t.source??d,p=t.target??o?.target??G(f);q(f,p);let m=await k(S(i,`capturing pre-operation state`),{signal:c}),h=[];s?.({phase:`collecting`,...j.collecting});let g=Date.now(),_=await y(i,f,{signal:c});if(h.push({name:`collecting`,durationMs:Date.now()-g,attempts:1}),_.hunks.length===0)throw new r(`NO_CHANGES`,K(f));if(_.snapshot.diffHash!==u.diffHash)throw new e(`SAFETY_CHECK_FAILED`,`Working directory has changed since the plan was created. Re-run compose to generate a new plan.`);s?.({phase:`verifying`,...j.verifying});let v=Date.now(),C=await b(i,_.snapshot,{includeUnstaged:f.type===`workdir`&&!f.stagedOnly,includeUntracked:_.untrackedIncluded,signal:c});if(!C.valid)throw new e(`SAFETY_CHECK_FAILED`,`Safety check failed: ${C.error}`);h.push({name:`verifying`,durationMs:Date.now()-v,attempts:1}),s?.({phase:`applying`,...j.applying});let w=Date.now(),T=await x({git:i,target:p,branchPlans:l.branches,allOrderedCommits:l.allOrderedCommits,hunks:_.hunks,snapshot:_.snapshot,collection:_,signal:c,signing:t.signing,authorAttribution:t.authorAttribution});h.push({name:`applying`,durationMs:Date.now()-w,attempts:1});let E=await X(i,m,T.mutations,p,f,c);return{plan:l,snapshot:_.snapshot,partitions:T.partitions,partitionGroups:T.partitionGroups,session:{messages:[],systemPrompt:``,completedSteps:{}},usage:a(),phases:h,source:{type:f.type===`branch`?`branch`:`workdir`,hunks:_.hunks,commitMessages:_.commitMessages,branchName:_.branchName,baseBranch:_.baseBranch},diffStats:V(_.hunks),commitShas:T.commitShas,undoId:E}}function V(e){let t=new Set,n=0,r=0;for(let i of e)t.add(i.fileName),n+=i.additions,r+=i.deletions;return{fileCount:t.size,hunkCount:e.length,addedLines:n,removedLines:r}}function H(e){for(let t=e.length-1;t>=0;t--){let n=e[t].callIds;if(n&&n.length>0)return n[n.length-1]}}async function U(e,t){if(!t)return{hunks:e,filteredCount:void 0};let n=await t(e);return{hunks:n,filteredCount:e.length-n.length}}async function W(e,t,n,r){let i=D(E(),`compose-filtered-idx-${w()}`),a={GIT_INDEX_FILE:i};try{await m(e,t.headSha,{indexEnv:a,signal:r});let i=_(n);i.length>0&&await d(e,i,{indexEnv:a,signal:r});let o=await g(e,{indexEnv:a,signal:r}),s=await f(e,t.headSha,o,{signal:r});return{...t,diffHash:v(s)}}finally{try{T(i)}catch{}}}function G(e){return e.type===`branch`?{type:`branch-replace`}:e.type===`commit-range`?{type:`rewrite-range`,branch:e.branch,rangeFrom:e.from,rangeTo:e.to}:{type:`head`}}function K(e){switch(e.type){case`workdir`:return`No changes found in working directory`;case`branch`:return`No changes found on branch '${e.name}'`;case`commit-range`:return`No changes found in range '${e.from}..${e.to}' on branch '${e.branch}'`}}function q(e,n){if(n.type===`branch-replace`&&e.type!==`branch`)throw new t(`target 'branch-replace' requires source type 'branch'`);if(n.type===`rewrite-range`&&e.type!==`commit-range`&&e.type!==`branch`)throw new t(`target 'rewrite-range' requires source type 'commit-range' or 'branch'`)}function J(t,n){return n.map(n=>{let r=t.commits.find(e=>e.id===n);if(!r)throw new e(`INTERNAL`,`Commit ${n} not found in branch '${t.id}'`);return r})}async function Y(e,t){let n=(await p(e,{format:`%(refname:short)`,prefix:`refs/heads/`,signal:t})).trim().split(`
2
- `).filter(Boolean);return new Set(n)}async function X(e,t,n,r,i,a){let o=A(),s=n.createdBranches.length||1,c=`compose: ${s} branch${s===1?``:`es`} from ${i.type}`,l=[...n.movedBranches];if(r.type===`head`&&t.headBranch){let n=await h(e,`HEAD`,{signal:a});n!==t.headSha&&l.push({name:t.headBranch,previousSha:t.headSha,newSha:n})}let u={version:1,id:o,createdAt:new Date().toISOString(),description:c,previousHead:{sha:t.headSha,branch:t.headBranch},workdirClean:t.workdirClean,workdirBackupSha:t.workdirBackupSha,createdBranches:n.createdBranches,deletedBranches:n.deletedBranches,movedBranches:l};return await new O(S(e,`undo manifest persistence`)).save(u,{signal:a}),o}export{z as applyComposePlan,M as compose,R as composePlan,j as defaultPhaseMessages};
1
+ import{GitError as e}from"../errors/git.js";import{ComposeWorkflowError as t,ComposeWorkflowInputError as n}from"../errors/workflow.js";import{addUsage as r,emptyUsage as i}from"../ai/types.js";import{analyze as a}from"../tasks/analyze/analyze.js";import{composeGroup as o}from"../tasks/compose-group/compose-group.js";import{group as s}from"../tasks/group/group.js";import{order as c}from"../tasks/order/order.js";import{partition as l}from"../tasks/partition/partition.js";import{applyPatchToIndex as u,currentBranch as d,diffTree as f,forEachRef as p,log as m,mergeBase as h,readTree as g,revParse as _,writeTree as v}from"../git/port-dispatch.js";import{buildPatchForCommit as y}from"../git/utils/patch-builder.js";import{sha256 as b}from"../git/utils/crypto.js";import{collectFromSource as x}from"../git/diff-collector.js";import{verifySnapshot as S}from"../git/safety.js";import{applyToTarget as C}from"../git/apply-target.js";import{requireExec as w}from"../git/require-exec.js";import{validateRewriteRangeSafety as T}from"../git/safety-range.js";import{extractConventions as E}from"../git/utils/conventions.js";import{rethrowAsWorkflowError as D}from"../utils/cancellation.js";import{randomUUID as O}from"node:crypto";import{unlinkSync as k}from"node:fs";import{tmpdir as A}from"node:os";import{join as j}from"node:path";import{UndoStore as M,capturePreOperationState as N,generateUndoId as P}from"@gitkraken/shared-tools";const F={collecting:{message:`Collecting changes…`,weight:5},composing:{message:`Composing commits…`,weight:40},"compose-grouping":{message:`Grouping changes into commits…`,weight:40},analyzing:{message:`Analyzing changes…`,weight:20},grouping:{message:`Grouping changes into commits…`,weight:25},ordering:{message:`Ordering commits…`,weight:15},partitioning:{message:`Subdividing branches…`,weight:20},verifying:{message:`Verifying…`,weight:5},applying:{message:`Applying to repository…`,weight:10}};async function I(r){if(!r.applyPlan&&!r.model)throw new n(`Either 'model' or 'applyPlan' must be provided`);if(r.applyPlan)return re(r);let{git:i,model:a,source:o={type:`workdir`},onProgress:s,onBeforeApply:c,cancellation:l}=r,u=a,d=r.depth??`balanced`,f=r.target??await X(i,o);Q(o,f),await K(i,r.applyCommitIds,r.hunkFilter,o,f),await T({git:i,source:o,target:f});let p=[];s?.({phase:`collecting`,...F.collecting});let m=Date.now(),h=await x(i,o);if(p.push({name:`collecting`,durationMs:Date.now()-m,attempts:1}),h.hunks.length===0)throw new e(`NO_CHANGES`,Z(o));let{hunks:g,filteredCount:_}=await U(h.hunks,r.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Z(o));let v=_!==void 0&&_>0?await W(i,h.snapshot,g):h.snapshot,y={hunks:g,collection:h,conventions:r.conventions??await E({git:i,author:r.conventionAuthor}),existingBranchNames:await ie(i),groupMode:f.type===`auto`?`branches`:`commits`,target:f,model:u,instructions:r.instructions,context:r.context,strategy:r.strategy,maxRetries:r.maxRetries,maxTokens:r.maxTokens,onProgress:s,cancellation:l,onBeforeModelCall:r.onBeforeModelCall,onAfterModelCall:r.onAfterModelCall,onBeforePrompt:r.onBeforePrompt,session:r.session,promptOverrides:r.promptOverrides,redactHunkContent:r.redactHunkContent},b;try{b=await ee(d,y)}catch(e){D(e,l)}let O={analysis:b.analysis,grouping:b.grouping,ordering:b.ordering,branches:b.branchPlans,allOrderedCommits:b.allOrderedCommits};if(p.push(...b.phases),c&&!await c(O))return{plan:O,snapshot:v,session:b.session,usage:b.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:V(g),feedbackTargetCallId:H(p),hunksFilteredCount:_};let k=await N(w(i,`capturing pre-operation state`));s?.({phase:`verifying`,...F.verifying});let A=Date.now(),j=await S(i,h.snapshot,G(o,h));if(!j.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${j.error}`);p.push({name:`verifying`,durationMs:Date.now()-A,attempts:1});let M=q(b.allOrderedCommits,r.applyCommitIds),{leftoverHunks:P,leftoverPatch:I}=Y(M,h.hunks),L=P.length>0?await W(i,v,J(M,h.hunks)):v;s?.({phase:`applying`,...F.applying});let R=Date.now(),z=await C({git:i,source:o,target:f,branchPlans:b.branchPlans,allOrderedCommits:M,hunks:h.hunks,snapshot:L,collection:h,signing:r.signing,authorAttribution:r.authorAttribution,stashLabel:r.stashLabel,leftoverPatch:I});p.push({name:`applying`,durationMs:Date.now()-R,attempts:1});let B=await ae(i,k,z.mutations,f,o);return{plan:O,snapshot:L,partitions:z.partitions,partitionGroups:z.partitionGroups,session:b.session,usage:b.usage,phases:p,source:{type:o.type===`branch`?`branch`:`workdir`,hunks:g,commitMessages:h.commitMessages,branchName:h.branchName,baseBranch:h.baseBranch},diffStats:V(g),commitShas:z.commitShas,undoId:B,feedbackTargetCallId:H(p),hunksFilteredCount:_,appliedCommitIds:M.map(e=>e.id),unappliedHunkIndices:P.length>0?P.map(e=>e.index):void 0,stashConflict:z.stashConflict}}function ee(e,t){switch(e){case`quick`:return L(t);case`balanced`:return R(t);case`deep`:return B(t)}}async function L(e){return z(e,{lightweight:!0,skipOrder:!0})}async function R(e){return z(e,{includeAnalysis:!0})}async function z(e,t){let{hunks:n,collection:a,conventions:s,existingBranchNames:u,groupMode:d,target:f,model:p,instructions:m,context:h,strategy:g,maxRetries:_,maxTokens:v,onProgress:y,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=i(),T=[],E=await o({hunks:n,commitMessages:a.commitMessages,commitOrder:a.commitOrder,branchName:a.branchName,baseBranch:a.baseBranch,issueUrl:h?.issueUrl,pullRequestUrl:h?.pullRequestUrl,instructions:m,conventions:s,mode:d,strategy:g?.grouping,existingBranchNames:d===`branches`?u:void 0,includeAnalysis:t.includeAnalysis,lightweight:t.lightweight,condensed:t.condensed,redactHunkContent:e.redactHunkContent,model:p,session:e.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`compose-grouping`,...F[`compose-grouping`],event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.[`compose-group`]});E.usage&&r(w,E.usage),T.push({name:`compose-grouping`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=E.session,O,k;if(t.skipOrder)O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:e.commits.map(e=>e.id)})),k={branches:O.map(e=>({branchId:e.branchGroup.id,orderedCommitIds:e.orderedCommitIds})),rationale:``};else{let t=await c({branches:E.result.branches,hunks:n,instructions:m,model:p,session:E.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});t.usage&&r(w,t.usage),T.push({name:`ordering`,durationMs:t.durationMs,attempts:t.attempts,usage:t.usage,callIds:t.callIds}),D=t.session,k=t.result,O=E.result.branches.map(e=>({branchGroup:e,orderedCommitIds:k.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)}))}if(f.type===`auto`){let t=new Set(u);for(let e of E.result.branches)e.name&&t.add(e.name);for(let n of O){let i=await l({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:m,conventions:s,strategy:g?.partition,existingBranchNames:t,model:p,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&r(w,i.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:i.durationMs,attempts:i.attempts,usage:i.usage,callIds:i.callIds}),n.partitioning=i.result,D=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let A=O.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:void 0,grouping:E.result,ordering:k,branchPlans:O,allOrderedCommits:A,session:D,usage:w,phases:T}}async function B(e){let{hunks:t,collection:n,conventions:o,existingBranchNames:u,groupMode:d,target:f,model:p,instructions:m,context:h,strategy:g,maxRetries:_,maxTokens:v,onProgress:y,cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C}=e,w=i(),T=[],E=await a({hunks:t,commitMessages:n.commitMessages,commitOrder:n.commitOrder,branchName:n.branchName,baseBranch:n.baseBranch,issueUrl:h?.issueUrl,pullRequestUrl:h?.pullRequestUrl,instructions:m,redactHunkContent:e.redactHunkContent,model:p,session:e.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`analyzing`,...F.analyzing,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.analyze});E.usage&&r(w,E.usage),T.push({name:`analyzing`,durationMs:E.durationMs,attempts:E.attempts,usage:E.usage,callIds:E.callIds});let D=await s({hunks:t,commitMessages:n.commitMessages,instructions:m,conventions:o,mode:d,strategy:g?.grouping,existingBranchNames:d===`branches`?u:void 0,redactHunkContent:e.redactHunkContent,model:p,session:E.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`grouping`,...F.grouping,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.group});D.usage&&r(w,D.usage),T.push({name:`grouping`,durationMs:D.durationMs,attempts:D.attempts,usage:D.usage,callIds:D.callIds});let O=await c({branches:D.result.branches,hunks:t,instructions:m,model:p,session:D.session,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`ordering`,...F.ordering,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.order});O.usage&&r(w,O.usage),T.push({name:`ordering`,durationMs:O.durationMs,attempts:O.attempts,usage:O.usage,callIds:O.callIds});let k=D.result.branches.map(e=>({branchGroup:e,orderedCommitIds:O.result.branches.find(t=>t.branchId===e.id)?.orderedCommitIds??e.commits.map(e=>e.id)})),A=O.session;if(f.type===`auto`){let t=new Set(u);for(let e of D.result.branches)e.name&&t.add(e.name);for(let n of k){let i=await l({commits:$(n.branchGroup,n.orderedCommitIds),orderedCommitIds:n.orderedCommitIds,sourceBranchName:n.branchGroup.name,instructions:m,conventions:o,strategy:g?.partition,existingBranchNames:t,model:p,maxRetries:_,maxTokens:v,onProgress:e=>y?.({phase:`partitioning`,...F.partitioning,branch:n.branchGroup.name,event:e}),cancellation:b,onBeforeModelCall:x,onAfterModelCall:S,onBeforePrompt:C,promptOverride:e.promptOverrides?.partition});i.usage&&r(w,i.usage),T.push({name:`partitioning:${n.branchGroup.name}`,durationMs:i.durationMs,attempts:i.attempts,usage:i.usage,callIds:i.callIds}),n.partitioning=i.result,A=i.session;for(let e of i.result.partitions)t.add(e.branchName)}}let j=k.flatMap(e=>$(e.branchGroup,e.orderedCommitIds));return{analysis:E.result,grouping:D.result,ordering:O.result,branchPlans:k,allOrderedCommits:j,session:A,usage:w,phases:T}}async function te(e){let t=await I({...e,onBeforeApply:async()=>!1});return{plan:t.plan,snapshot:t.snapshot,source:t.source,session:t.session,usage:t.usage,phases:t.phases,feedbackTargetCallId:t.feedbackTargetCallId,hunksFilteredCount:t.hunksFilteredCount}}async function ne(e){let t=await I({git:e.git,applyPlan:e.applyPlan,source:e.source,target:e.target,onProgress:e.onProgress,signing:e.signing,authorAttribution:e.authorAttribution,applyCommitIds:e.applyCommitIds,stashLabel:e.stashLabel,hunkFilter:e.hunkFilter});return{commitShas:t.commitShas,partitions:t.partitions,partitionGroups:t.partitionGroups,phases:t.phases,undoId:t.undoId,appliedCommitIds:t.appliedCommitIds,unappliedHunkIndices:t.unappliedHunkIndices,stashConflict:t.stashConflict}}async function re(n){let{git:r,applyPlan:a,onProgress:o}=n,{plan:s,snapshot:c,source:l}=a,u=n.source??l,d=n.target??a?.target??await X(r,u);Q(u,d),await K(r,n.applyCommitIds,n.hunkFilter,u,d),await T({git:r,source:u,target:d});let f=await N(w(r,`capturing pre-operation state`)),p=[];o?.({phase:`collecting`,...F.collecting});let m=Date.now(),h=await x(r,u);if(p.push({name:`collecting`,durationMs:Date.now()-m,attempts:1}),h.hunks.length===0)throw new e(`NO_CHANGES`,Z(u));let{hunks:g}=await U(h.hunks,n.hunkFilter);if(g.length===0)throw new e(`NO_CHANGES`,Z(u));let _=g.length<h.hunks.length?await W(r,h.snapshot,g):h.snapshot,v={...h,hunks:g,snapshot:_};if(v.snapshot.diffHash!==c.diffHash)throw new t(`SAFETY_CHECK_FAILED`,`Working directory has changed since the plan was created. Re-run compose to generate a new plan.`);o?.({phase:`verifying`,...F.verifying});let y=Date.now(),b=await S(r,v.snapshot,G(u,v));if(!b.valid)throw new t(`SAFETY_CHECK_FAILED`,`Safety check failed: ${b.error}`);p.push({name:`verifying`,durationMs:Date.now()-y,attempts:1});let E=q(s.allOrderedCommits,n.applyCommitIds),{leftoverHunks:D,leftoverPatch:O}=Y(E,v.hunks),k=D.length>0?await W(r,v.snapshot,J(E,v.hunks)):v.snapshot;o?.({phase:`applying`,...F.applying});let A=Date.now(),j=await C({git:r,source:u,target:d,branchPlans:s.branches,allOrderedCommits:E,hunks:v.hunks,snapshot:k,collection:v,signing:n.signing,authorAttribution:n.authorAttribution,stashLabel:n.stashLabel,leftoverPatch:O});p.push({name:`applying`,durationMs:Date.now()-A,attempts:1});let M=await ae(r,f,j.mutations,d,u);return{plan:s,snapshot:k,partitions:j.partitions,partitionGroups:j.partitionGroups,session:{messages:[],systemPrompt:``,completedSteps:{}},usage:i(),phases:p,source:{type:u.type===`branch`?`branch`:`workdir`,hunks:v.hunks,commitMessages:v.commitMessages,branchName:v.branchName,baseBranch:v.baseBranch},diffStats:V(v.hunks),commitShas:j.commitShas,undoId:M,appliedCommitIds:E.map(e=>e.id),unappliedHunkIndices:D.length>0?D.map(e=>e.index):void 0,stashConflict:j.stashConflict}}function V(e){let t=new Set,n=0,r=0;for(let i of e)t.add(i.fileName),n+=i.additions,r+=i.deletions;return{fileCount:t.size,hunkCount:e.length,addedLines:n,removedLines:r}}function H(e){for(let t=e.length-1;t>=0;t--){let n=e[t].callIds;if(n&&n.length>0)return n[n.length-1]}}async function U(e,t){if(!t)return{hunks:e,filteredCount:void 0};let n=await t(e);return{hunks:n,filteredCount:e.length-n.length}}async function W(e,t,n){let r=t.baseSha??t.headSha,i=j(A(),`compose-filtered-idx-${O()}`),a={GIT_INDEX_FILE:i};try{await g(e,r,{indexEnv:a});let i=y(n);i.length>0&&await u(e,i,{indexEnv:a});let o=await f(e,r,await v(e,{indexEnv:a}));return{...t,diffHash:b(o)}}finally{try{k(i)}catch{}}}function G(e,t){return e.type===`workdir`?{includeUnstaged:!e.stagedOnly,includeUntracked:t.untrackedIncluded}:e.type===`commit-range`&&e.includeWorkdir?{includeStaged:e.includeWorkdir.includeStaged??!0,includeUnstaged:e.includeWorkdir.includeUnstaged??!0,includeUntracked:e.includeWorkdir.includeUntracked??!0}:{includeUnstaged:!1,includeUntracked:!1}}async function K(e,t,r,i,a){if(r==null&&t==null||i.type===`workdir`||!(a.type===`rewrite-range`||(a.type===`branch`||a.type===`auto`)&&a.preserveOriginal!==!0))return;let o=i.type===`branch`?await _(e,i.name):await _(e,i.to),s=await _(e,`HEAD`);if(await d(e)==null)throw new n(`Filtering and partial apply require HEAD to be on a branch (non-detached) when rewriting existing commits. Check out the source branch before composing.`);if(s!==o)throw new n(`Filtering and partial apply require HEAD to be at the source's tip (${o.slice(0,8)}); HEAD is at ${s.slice(0,8)}.`)}function q(e,t){if(t==null)return e;let r=new Set(t),i=e.filter(e=>r.has(e.id)),a=new Set(i.map(e=>e.id)),o=t.filter(e=>!a.has(e));if(o.length>0)throw new n(`applyCommitIds contains commit ids not found in plan.allOrderedCommits: ${o.join(`, `)}`);return i}function J(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices));return t.filter(e=>n.has(e.index))}function Y(e,t){let n=new Set(e.flatMap(e=>e.hunkIndices)),r=t.filter(e=>!n.has(e.index));if(r.length===0)return{leftoverHunks:[],leftoverPatch:void 0};let i=y(r);return{leftoverHunks:r,leftoverPatch:i.length>0?i:void 0}}async function X(e,t){if(t.type===`branch`){let r=await _(e,t.name),i=(await m(e,{format:`%H`,range:`${await h(e,await _(e,t.mergeTarget??`main`),r)}..${r}`,reverse:!0,limit:1})).trim().split(`
2
+ `)[0];if(!i)throw new n(`Cannot recompose branch '${t.name}': no commits between merge-base and branch tip.`);return{type:`rewrite-range`,branch:t.name,rangeFrom:i,rangeTo:r}}return t.type===`commit-range`?{type:`rewrite-range`,branch:t.branch,rangeFrom:t.from,rangeTo:t.to}:{type:`head`}}function Z(e){switch(e.type){case`workdir`:return`No changes found in working directory`;case`branch`:return`No changes found on branch '${e.name}'`;case`commit-range`:return`No changes found in range '${e.from}..${e.to}' on branch '${e.branch}'`}}function Q(e,t){if(t.type===`rewrite-range`&&e.type===`workdir`)throw new n(`target 'rewrite-range' is not valid with 'workdir' source`);if(t.type===`head`&&e.type!==`workdir`)throw new n(`target 'head' is not valid with '${e.type}' source — use 'branch', 'auto', or 'rewrite-range'`)}function $(e,n){return n.map(n=>{let r=e.commits.find(e=>e.id===n);if(!r)throw new t(`INTERNAL`,`Commit ${n} not found in branch '${e.id}'`);return r})}async function ie(e){let t=(await p(e,{format:`%(refname:short)`,prefix:`refs/heads/`})).trim().split(`
3
+ `).filter(Boolean);return new Set(t)}async function ae(e,t,n,r,i){let a=P(),o=n.createdRefs.filter(e=>e.kind===`branch`).length||1,s=`compose: ${o} branch${o===1?``:`es`} from ${i.type}`,c=[...n.movedRefs];if(r.type===`head`&&t.headBranch){let n=await _(e,`HEAD`);n!==t.headSha&&c.push({kind:`branch`,name:t.headBranch,previousSha:t.headSha,newSha:n})}let l={version:1,id:a,createdAt:new Date().toISOString(),description:s,previousHead:{sha:t.headSha,branch:t.headBranch},workdirClean:t.workdirClean,workdirBackupSha:t.workdirBackupSha,createdRefs:n.createdRefs,deletedRefs:n.deletedRefs,movedRefs:c};return await new M(w(e,`undo manifest persistence`)).save(l),a}export{ne as applyComposePlan,I as compose,te as composePlan,F as defaultPhaseMessages};
@@ -2,6 +2,7 @@ import { PhaseDetail, ProgressEvent, TokenUsage } from "../ai/types.js";
2
2
  import { ComposeGitPort } from "../ports/git.js";
3
3
  import { BranchPartition, CommitDetail, ComposeHunk } from "../tasks/types.js";
4
4
  import { OnAfterModelCall, OnBeforeModelCall, OnBeforePrompt } from "../tasks/ai-loop.js";
5
+ import { RedactHunkContent } from "../tasks/utils/redact.js";
5
6
  import { AnalysisResult } from "../tasks/analyze/types.js";
6
7
  import { ComposeSession } from "../tasks/session.js";
7
8
  import { PartitionResult } from "../tasks/partition/types.js";
@@ -15,16 +16,26 @@ interface SplitInput {
15
16
  baseBranch?: string;
16
17
  instructions?: string;
17
18
  maxRetries?: number;
19
+ /** Output-token cap forwarded to every AI task in the split pipeline. Defaults to `runAiLoop`'s 16384. */
20
+ maxTokens?: number;
18
21
  onProgress?: (event: SplitProgressEvent) => void;
19
22
  onBeforeApply?: (plan: SplitPlan) => Promise<boolean>;
20
23
  /**
21
- * Optional cancellation signal. Forwarded to every AI call and every git operation.
22
- * On abort, the workflow throws `WorkflowError('CANCELLED')`.
24
+ * Optional cancellation token. Forwarded only to the AI loop (via the
25
+ * `analyze` and `partition` task calls). Git operations &mdash; collection,
26
+ * pre-state capture, branch creation, undo manifest persistence &mdash; run
27
+ * to completion regardless of whether `cancellation` was aborted.
28
+ *
29
+ * On AI-loop abort, the workflow translates the native `AbortError` into
30
+ * `ComposeWorkflowError('CANCELLED')` so consumers only have to check for
31
+ * the workflow-domain shape.
23
32
  */
24
- signal?: AbortSignal;
33
+ cancellation?: AbortSignal;
25
34
  onBeforeModelCall?: OnBeforeModelCall;
26
35
  onAfterModelCall?: OnAfterModelCall;
27
36
  onBeforePrompt?: OnBeforePrompt;
37
+ /** See {@link RedactHunkContent}. */
38
+ redactHunkContent?: RedactHunkContent;
28
39
  }
29
40
  interface SplitPlan {
30
41
  analysis: AnalysisResult;
@@ -1,3 +1,3 @@
1
- import{throwIfCancelled as e}from"../cancellation.js";import{GitError as t}from"../errors/git.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{forEachRef as o}from"../git/port-dispatch.js";import{createStackedBranches as s}from"../git/apply-branches.js";import{parseUnifiedDiff as c}from"../git/utils/hunk-parser.js";import{collectCommitsForSplit as l}from"../git/diff-collector.js";import{requireExec as u}from"../git/require-exec.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(o){let{git:f,model:m,branchName:h,baseBranch:v=`main`,instructions:y,maxRetries:b,onProgress:x,onBeforeApply:S,signal:C,onBeforeModelCall:w,onAfterModelCall:T,onBeforePrompt:E}=o;e(C);let D=r(),O=[];x?.({phase:`collecting`,...d.collecting});let k=Date.now(),{commits:A,branchSha:j}=await l(f,h,v,C);if(O.push({name:`collecting`,durationMs:Date.now()-k,attempts:1}),A.length===0)throw new t(`NO_CHANGES`,`No commits found between '${v}' and '${h}'`);if(A.length<2)throw new t(`NO_CHANGES`,`Too few commits to split. A branch needs at least 2 commits.`);let M=c(A.map(e=>e.diff).join(`
2
- `),`branch`),N=A.map(e=>({id:e.sha,message:e.message,explanation:``,hunkIndices:[]})),P=await i({hunks:M,commitMessages:A.map(e=>e.message),commitOrder:A.map(e=>e.sha),branchName:h,baseBranch:v,instructions:y,model:m,maxRetries:b,onProgress:e=>x?.({phase:`analyzing`,...d.analyzing,event:e}),signal:C,onBeforeModelCall:w,onAfterModelCall:T,onBeforePrompt:E});P.usage&&n(D,P.usage),O.push({name:`analyzing`,durationMs:P.durationMs,attempts:P.attempts,usage:P.usage,callIds:P.callIds});let F=await a({commits:N,orderedCommitIds:A.map(e=>e.sha),sourceBranchName:h,baseBranch:v,instructions:y,existingBranchNames:await g(f,C),model:m,session:P.session,maxRetries:b,onProgress:e=>x?.({phase:`partitioning`,...d.partitioning,event:e}),signal:C,onBeforeModelCall:w,onAfterModelCall:T,onBeforePrompt:E});F.usage&&n(D,F.usage),O.push({name:`partitioning`,durationMs:F.durationMs,attempts:F.attempts,usage:F.usage,callIds:F.callIds});let I={type:`branch`,hunks:M,commitMessages:A.map(e=>e.message),branchName:h,baseBranch:v},L={analysis:P.result,partitioning:F.result,commits:A};if(F.result.partitions.length<=1||S&&!await S(L))return{plan:L,partitions:F.result.partitions,session:F.session,usage:D,phases:O,source:I};let R=await p(u(f,`capturing pre-operation state`),{signal:C});x?.({phase:`applying`,...d.applying});let z=Date.now();await s({git:f,partitions:F.result.partitions,originalBranchName:h,originalBranchSha:j,signal:C}),O.push({name:`applying`,durationMs:Date.now()-z,attempts:1});let B=await _(f,R,F.result.partitions,h,j,C);return{plan:L,partitions:F.result.partitions,session:F.session,usage:D,phases:O,source:I,undoId:B}}async function g(e,t){let n=(await o(e,{format:`%(refname:short)`,prefix:`refs/heads/`,signal:t})).trim().split(`
3
- `).filter(Boolean);return new Set(n)}async function _(e,t,n,r,i,a){let o=m(),s=n.map(e=>{let t=e.commitIds[e.commitIds.length-1];return{name:e.branchName,sha:t}}),c={version:1,id:o,createdAt:new Date().toISOString(),description:`split: ${n.length} branches from '${r}'`,previousHead:{sha:t.headSha,branch:t.headBranch},workdirClean:t.workdirClean,workdirBackupSha:t.workdirBackupSha,createdBranches:s,deletedBranches:[{name:r,sha:i}],movedBranches:[]};return await new f(u(e,`undo manifest persistence`)).save(c,{signal:a}),o}export{h as split};
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};
@@ -5,10 +5,13 @@
5
5
  * - `workdir`: collect from the working directory. Set `stagedOnly` to ignore unstaged changes,
6
6
  * `noUntracked` to ignore untracked files.
7
7
  * - `branch`: collect the diff between `name` and `mergeTarget` (defaults to the upstream/base branch).
8
- * - `commit-range`: collect from a contiguous range of commits on a branch, with per-hunk author
9
- * info derived from the owning commits. Used by "recompose this commit" / "recompose selected
10
- * commits" flows. `from` is exclusive (parent of first commit), `to` is inclusive. For a
11
- * single-commit recompose: `from = parent(commitSha)`, `to = commitSha`.
8
+ * Internally translates to a commit range from `parent(first-commit-on-branch)` to `name`'s tip,
9
+ * so the same rules apply as for `commit-range`.
10
+ * - `commit-range`: collect from a contiguous range of commits on a branch as a single combined
11
+ * diff. Used by "recompose this commit" / "recompose selected commits" flows. Both `from` and
12
+ * `to` are **inclusive** — they name the first and last commits being rewritten. For a
13
+ * single-commit recompose: `from = commitSha`, `to = commitSha`. The library internally
14
+ * computes `parent(from)` for git operations. `from` must be an ancestor of `to`.
12
15
  */
13
16
  type ComposeSource = {
14
17
  type: 'workdir';
@@ -20,20 +23,46 @@ type ComposeSource = {
20
23
  mergeTarget?: string;
21
24
  } | {
22
25
  type: 'commit-range';
23
- branch: string;
24
- from: string;
26
+ branch: string; /** First commit in the range to recompose (inclusive). Must be an ancestor of `to`. */
27
+ from: string; /** Last commit in the range to recompose (inclusive). */
25
28
  to: string;
29
+ includeWorkdir?: {
30
+ includeStaged?: boolean;
31
+ includeUnstaged?: boolean;
32
+ includeUntracked?: boolean;
33
+ };
26
34
  };
27
35
  /**
28
36
  * Where compose writes the result.
29
37
  *
30
- * - `head`: commit onto the current branch.
31
- * - `branch`: create or replace a single named branch.
32
- * - `branch-replace`: replace the source branch (only valid when source is `branch`).
33
- * - `auto`: create one branch per grouped concern, optionally subdividing into stacks.
34
- * - `rewrite-range`: rewrite a contiguous commit range on `branch`, preserving commits
35
- * before `rangeFrom` (always) and commits after `rangeTo` (by cherry-pick when
36
- * `preserveSuffix`). Used with `source: 'commit-range'` for mid-branch recompose.
38
+ * Legal source / target pairings:
39
+ *
40
+ * | source | head | branch | auto | rewrite-range |
41
+ * | -------------- | :--: | :----: | :--: | :-----------: |
42
+ * | `workdir` | ✓ | ✓ | ✓ | ✗ |
43
+ * | `branch` | ✗ | ✓ | ✓ | ✓ |
44
+ * | `commit-range` | ✗ | ✓ | ✓ | ✓ |
45
+ *
46
+ * - `head`: write the output commits onto the current branch (workdir source only).
47
+ * - `branch`: create a new branch from the base commit and write the chain to it. The base is
48
+ * HEAD for `workdir` source, or `parent(rangeFrom)` (equivalently `mergeBase` for `branch` source)
49
+ * for `branch`/`commit-range` sources. `preserveOriginal` behavior:
50
+ * - `workdir` source: `true` → WIP stays in the worktree; `false` → WIP is cleared from the
51
+ * worktree (except hunks excluded by `hunkFilter` or workdir flags, and any partial-apply
52
+ * leftover — those stay in their original location).
53
+ * - `branch` source: `true` → source branch is preserved; `false` → source branch ref is deleted
54
+ * once the full diff has been applied to the new branch.
55
+ * - `commit-range` source: forced to `true` (the source commits always stay reachable from the
56
+ * source branch). Passing `false` is a no-op.
57
+ * - `auto`: same as `branch` but the AI splits the work across one or more new branches or
58
+ * stacks; the same `preserveOriginal` rules apply per source type.
59
+ * - `rewrite-range`: replace the source's commits in place. The new chain stacks on
60
+ * `parent(rangeFrom)`. Every ref whose tip is a strict descendant of `rangeTo` gets its
61
+ * downstream chain re-parented onto the new tip; refs at `rangeTo` itself move with the
62
+ * source branch. Used with `source: 'commit-range'` for mid-branch recompose, or with
63
+ * `source: 'branch'` to rewrite the entire branch in place. Partial apply (`applyCommitIds`)
64
+ * and `hunkFilter` are supported on this target only when HEAD is on the source branch and
65
+ * pointing at `rangeTo`.
37
66
  */
38
67
  type ComposeTarget = {
39
68
  type: 'head';
@@ -41,18 +70,17 @@ type ComposeTarget = {
41
70
  type: 'branch';
42
71
  name: string;
43
72
  preserveOriginal?: boolean;
44
- } | {
45
- type: 'branch-replace';
46
73
  } | {
47
74
  type: 'auto';
48
75
  preserveOriginal?: boolean;
49
76
  } | {
50
77
  type: 'rewrite-range';
51
78
  branch: string;
52
- rangeFrom: string;
79
+ /** First commit in the rewritten range (inclusive). The new chain stacks on
80
+ * `parent(rangeFrom)`. */
81
+ rangeFrom: string; /** Last commit in the rewritten range (inclusive). */
53
82
  rangeTo: string; /** Commits before `rangeFrom` always stay; this flag is reserved for future semantics. Default true. */
54
- preservePrefix?: boolean; /** When true (default), cherry-pick `rangeTo..branch-tip` onto the new range tip. */
55
- preserveSuffix?: boolean;
83
+ preservePrefix?: boolean;
56
84
  };
57
85
  /** Optional context passed through to AI prompts (issue/PR URLs surface in commit messages). */
58
86
  interface ComposeContext {
@@ -1,5 +1,5 @@
1
1
  import { ComposeGitPort } from "../ports/git.js";
2
- import { UndoId, UndoResult, UndoValidation } from "@gitkraken/shared-tools";
2
+ import { UndoForceOptions, UndoId, UndoResult, UndoValidation } from "@gitkraken/shared-tools";
3
3
 
4
4
  //#region src/workflows/undo.d.ts
5
5
  interface UndoComposeInput {
@@ -7,27 +7,28 @@ interface UndoComposeInput {
7
7
  /** The undo ID returned by `compose()` or `split()`. */
8
8
  undoId: UndoId;
9
9
  /**
10
- * When true, downgrades `branch_has_new_commits` blockers to warnings and
11
- * proceeds. The `dirty_workdir` blocker is never force-able.
10
+ * When `true`, downgrades `branch_has_new_commits` blockers to warnings —
11
+ * shorthand for `{ branchHasNewCommits: true }`. Pass an object form to
12
+ * additionally bypass `dirty_workdir` (e.g., when rolling back from a
13
+ * stash-pop conflict where the dirty state is the operation's own
14
+ * leftover, not user work).
12
15
  */
13
- force?: boolean;
16
+ force?: boolean | UndoForceOptions;
14
17
  /** Called before each undo step with a human-readable description. */
15
18
  onProgress?: (step: string) => void;
16
- /** Optional cancellation signal. Forwarded to every git operation. */
17
- signal?: AbortSignal;
18
19
  }
19
20
  interface ValidateUndoComposeInput {
20
21
  git: ComposeGitPort;
21
22
  /** The undo ID to validate. */
22
23
  undoId: UndoId;
23
- /** Optional cancellation signal. Forwarded to every git operation. */
24
- signal?: AbortSignal;
25
24
  }
26
25
  /**
27
26
  * Validate whether undoing a compose/split operation can safely proceed.
28
27
  *
29
28
  * Returns blockers (hard stops) and warnings (notable conditions). Does not
30
29
  * mutate the repository. Use this for dry-run checks before {@link undoCompose}.
30
+ *
31
+ * Not cancellable — undo work is git-only and runs to completion.
31
32
  */
32
33
  declare function validateUndoCompose(input: ValidateUndoComposeInput): Promise<UndoValidation>;
33
34
  /**
@@ -38,10 +39,14 @@ declare function validateUndoCompose(input: ValidateUndoComposeInput): Promise<U
38
39
  * mutations that were recorded. Returns a structured {@link UndoResult}.
39
40
  *
40
41
  * Safety:
41
- * - Refuses to proceed when the working directory has uncommitted changes.
42
+ * - Refuses to proceed when the working directory has uncommitted changes
43
+ * unless `force.dirtyWorkdir` is set.
42
44
  * - Refuses when a branch created by the operation has been modified since
43
- * (unless `force` is set).
45
+ * unless `force` is set (or `force.branchHasNewCommits`).
44
46
  * - Best-effort on working directory restoration from stash backup.
47
+ *
48
+ * Not cancellable — undo runs to completion to avoid leaving the repo in
49
+ * a half-reversed state.
45
50
  */
46
51
  declare function undoCompose(input: UndoComposeInput): Promise<UndoResult>;
47
52
  /**
@@ -50,7 +55,9 @@ declare function undoCompose(input: UndoComposeInput): Promise<UndoResult>;
50
55
  * Returns IDs for all workflows that used the shared undo infrastructure
51
56
  * (compose, split, and any future packages). Each ID can be passed to
52
57
  * {@link validateUndoCompose} or {@link undoCompose}.
58
+ *
59
+ * Not cancellable — git-only operation that runs to completion.
53
60
  */
54
- declare function listUndoIds(git: ComposeGitPort, signal?: AbortSignal): Promise<UndoId[]>;
61
+ declare function listUndoIds(git: ComposeGitPort): Promise<UndoId[]>;
55
62
  //#endregion
56
63
  export { UndoComposeInput, ValidateUndoComposeInput, listUndoIds, undoCompose, validateUndoCompose };
@@ -1 +1 @@
1
- import{requireExec as e}from"../git/require-exec.js";import{UndoStore as t,undoOperation as n,validateUndo as r}from"@gitkraken/shared-tools";async function i(t){return r({git:e(t.git,`undo validation`),manifestId:t.undoId,signal:t.signal})}async function a(t){return n({git:e(t.git,`undo execution`),manifestId:t.undoId,force:t.force,onProgress:t.onProgress,signal:t.signal})}async function o(n,r){return new t(e(n,`undo listing`)).list({signal:r})}export{o as listUndoIds,a as undoCompose,i as validateUndoCompose};
1
+ import{requireExec as e}from"../git/require-exec.js";import{UndoStore as t,undoOperation as n,validateUndo as r}from"@gitkraken/shared-tools";async function i(t){return r({git:e(t.git,`undo validation`),manifestId:t.undoId})}async function a(t){return n({git:e(t.git,`undo execution`),manifestId:t.undoId,force:t.force,onProgress:t.onProgress})}async function o(n){return new t(e(n,`undo listing`)).list()}export{o as listUndoIds,a as undoCompose,i as validateUndoCompose};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitkraken/compose-tools",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "zod": "^4.3.6",
24
- "@gitkraken/shared-tools": "^0.1.0"
24
+ "@gitkraken/shared-tools": "^0.2.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.0.0",
@@ -1,20 +0,0 @@
1
- //#region src/cancellation.d.ts
2
- /**
3
- * Throws a `WorkflowError('CANCELLED')` if the signal has been aborted.
4
- *
5
- * Call at workflow phase boundaries so cancellation is observable even when
6
- * a consumer's adapter doesn't fully respect the signal on every operation.
7
- * Individual `git.exec` and `model.generate` calls should also receive the
8
- * signal via their options; this helper is the coarse-grained backstop.
9
- */
10
- declare function throwIfCancelled(signal?: AbortSignal): void;
11
- /**
12
- * Wraps a native `AbortError` (thrown by a signal-aware adapter) in a
13
- * `WorkflowError('CANCELLED')`. Rethrows non-cancellation errors unchanged.
14
- *
15
- * Use to normalize exceptions from `git.exec` or `model.generate` so consumers
16
- * only have to check for `WorkflowError` with code `'CANCELLED'`.
17
- */
18
- declare function wrapCancellation<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T>;
19
- //#endregion
20
- export { throwIfCancelled, wrapCancellation };
@@ -1 +0,0 @@
1
- import{WorkflowError as e}from"./errors/workflow.js";function t(t){if(t?.aborted)throw new e(`CANCELLED`,t.reason instanceof Error?t.reason.message:`Operation cancelled`)}function n(t,n){return t.catch(t=>{throw r(t)||n?.aborted?new e(`CANCELLED`,t instanceof Error?t.message:`Operation cancelled`,{cause:t}):t})}function r(e){return e instanceof Error&&(e.name===`AbortError`||e.name===`DOMException`)}export{t as throwIfCancelled,n as wrapCancellation};