@gitkraken/compose-tools
v0.1.0
Published
AI-powered organization of git changes into clean, atomic commits and branches.
Readme
@gitkraken/compose-tools
AI-powered git change organization. Takes a messy working directory or a branch full of interleaved commits and restructures them into clean, atomic commits on well-named branches — ready for code review.
What it does
compose() collects your changes, sends the diffs through an AI pipeline that understands the logical structure of code changes, and produces clean commits grouped by concern. Depending on configuration, it can:
- Rewrite a working directory's staged / unstaged / untracked changes into atomic commits on the current branch
- Reorganize a branch's commit history into logically grouped commits
- Rewrite a contiguous range of commits in the middle of a branch, preserving the commits before and after the range
- Split changes across multiple new branches (one per concern)
- Subdivide branches into stacked sub-branches for incremental review
split() is a focused variant — it takes an existing multi-commit branch and partitions it into a stack of smaller branches.
Every operation that mutates the repository returns an undoId that can be passed to undoCompose() to surgically reverse only the git mutations that were recorded.
Install
pnpm add @gitkraken/compose-tools @gitkraken/shared-toolsThe library is runtime-agnostic — it never executes git or AI calls directly. Consumers provide adapters for their own git and AI backends.
Implement the ports
ComposeGitPort
The git surface the library needs. Two ways to provide it:
Minimal — raw exec only. Fine for a CLI-git consumer.
import type { ComposeGitPort } from '@gitkraken/compose-tools';
const git: ComposeGitPort = {
exec: async (args, options) => {
const result = await yourGitBackend.run(args, {
cwd: '/path/to/repo',
env: options?.env,
stdin: options?.stdin,
signal: options?.signal,
});
if (result.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`);
return result.stdout;
},
};The library builds every git operation it needs (diff, write-tree, commit-tree, update-ref, stash, cherry-pick, etc.) from exec.
Hybrid — exec plus targeted high-level ops. Provide a high-level op when your backend has a better implementation. The library prefers the high-level op over routing through exec.
const git: ComposeGitPort = {
exec: async (args, options) => { /* ... */ },
// Consumer has a signing-aware commit creator; library should prefer it.
commitTree: async (tree, parents, message, opts) => {
return yourSigningCommitCreator({ tree, parents, message, signing: opts?.signing });
},
};Libgit2-style — high-level ops only, no exec. A libgit2-backed consumer implements the specific ops used by its flow. If a flow needs an op the consumer didn't provide and exec isn't available either, the library throws ComposeGitPortMissingOpError naming the missing op.
The full list of high-level ops is in the ComposeGitOps type; the most commonly overridden are commitTree, readTree, writeTree, applyPatchToIndex, diffTree, stageAll. Every op takes an OpOptions with signal?: AbortSignal.
AiModelPort
Stateless text generation — the caller passes the full message history each call.
import type { AiModelPort } from '@gitkraken/shared-tools';
const model: AiModelPort = {
generate: async (params) => {
const response = await yourLLM.chat({
system: params.system,
messages: params.messages,
maxTokens: params.maxTokens,
temperature: params.temperature,
signal: params.signal,
});
return {
text: response.text,
usage: { inputTokens: response.inputTokens, outputTokens: response.outputTokens },
};
},
};The adapter doesn't track sessions — session state is managed inside the library.
Entry points
The library exposes three flavors of the main workflow plus split and undo.
compose(input) — one-call sugar
Runs planning and apply together. Ideal when the consumer doesn't need to surface the plan for review.
import { compose } from '@gitkraken/compose-tools';
const result = await compose({
git,
model,
source: { type: 'workdir' },
onProgress: (event) => console.log(event.phase, event.message),
});
// result.plan — AI's analysis, grouping, ordering
// result.commitShas — { 'commit-1': 'abc123…', … }
// result.undoId — pass to undoCompose() to reverse
// result.phases — timing/usage/callIds per phase
// result.usage — aggregated token counts
// result.feedbackTargetCallId — stable ID of the "decision" model call (for thumbs up/down events)composePlan(input) + applyComposePlan(input) — two-phase
Returns the plan without mutating the repo, lets the user review/edit it, then applies. This is the shape GitLens-style UIs use (plan → webview → user edits → apply).
import { composePlan, applyComposePlan } from '@gitkraken/compose-tools';
const planResult = await composePlan({
git,
model,
source: { type: 'workdir' },
});
// Show planResult.plan + planResult.source.hunks to the user. They may
// reassign hunks between commits, reorder, edit messages, etc.
const userEditedPlan = /* user-edited version of planResult.plan */;
const applied = await applyComposePlan({
git,
applyPlan: {
plan: userEditedPlan,
source: planResult.source, // the library's collected hunks
snapshot: planResult.snapshot, // safety hash — verified at apply time
},
});applyComposePlan reverifies the workdir matches snapshot before mutating. If it drifted between plan and apply, it throws WorkflowError('SAFETY_CHECK_FAILED').
split(input)
Partitions an existing branch into a stack of smaller branches.
import { split } from '@gitkraken/compose-tools';
const result = await split({
git,
model,
branchName: 'feature/large-pr',
baseBranch: 'main',
onBeforeApply: async (plan) => {
for (const p of plan.partitioning.partitions) console.log(`${p.branchName}: ${p.title}`);
return true;
},
});Requires at least 2 commits on the branch. The original branch is replaced by the partition branches.
undoCompose(input)
See Undo below.
Sources
| Source | Description |
|---|---|
| { type: 'workdir', stagedOnly?, noUntracked? } | Collects staged, unstaged, and untracked changes. stagedOnly: true limits to the index; noUntracked: true skips untracked files. |
| { type: 'branch', name, mergeTarget? } | Collects mergeBase(mergeTarget, name) → name as one combined diff. mergeTarget defaults to 'main'. |
| { type: 'commit-range', branch, from, to } | Collects a contiguous range on branch with per-hunk author info. from is exclusive (parent of the first commit to include); to is inclusive. For a single-commit recompose: from = parent(sha), to = sha. |
Targets
| Target | Description |
|---|---|
| { type: 'head' } | Commit onto the current branch. Default for workdir source. |
| { type: 'branch', name, preserveOriginal? } | Create a single named branch. preserveOriginal: true keeps the source branch. |
| { type: 'branch-replace' } | Replace the source branch in-place. Only valid with a branch source. |
| { type: 'auto', preserveOriginal? } | Create one branch per concern, optionally subdividing into stacked sub-branches. Default for branch source. |
| { type: 'rewrite-range', branch, rangeFrom, rangeTo, preserveSuffix? } | Rewrite a contiguous range on branch. Commits before rangeFrom always stay. When preserveSuffix: true (default), commits after rangeTo are cherry-picked onto the new range tip. Intended for use with source: 'commit-range'. Cherry-pick conflicts surface as GitError('CHERRY_PICK_CONFLICT'). |
Options
depth
Controls how many AI calls run and how deeply each reasons.
| Depth | Phases | Best for |
|---|---|---|
| 'quick' | Single compose-group call (lightweight prompt) | Small changes, fast feedback |
| 'balanced' (default) | compose-group + optional order | Most use cases |
| 'deep' | analyze + group + order + optional partition per branch | Large, complex changesets |
strategy
Controls AI aggressiveness at grouping and partitioning time. All settings default to 'balanced'.
strategy: {
grouping: {
branchSplitting: 'conservative' | 'balanced' | 'aggressive',
commitGranularity: 'consolidated' | 'balanced' | 'granular',
},
partition: {
splitting: 'conservative' | 'balanced' | 'aggressive',
},
}branchSplitting— how aggressively to separate changes into distinct branchescommitGranularity— how fine-grained individual commits should bepartition.splitting— how aggressively to subdivide branches into stacked sub-branches
onBeforeApply
Inspect the plan before any git mutations. Return false to abort — the workflow returns the plan without applying it.
onBeforeApply: async (plan) => {
console.log(`${plan.branches.length} branches, ${plan.allOrderedCommits.length} commits`);
return true;
}Equivalent to calling composePlan — useful when you want the sugar compose path but need a chance to intervene.
onBeforeModelCall / onAfterModelCall
Per-call observability hooks. Informational only — the library doesn't interpret the return.
onBeforeModelCall: ({ phase, attempt, maxAttempts, callId }) => {
// phase: 'analyze' | 'group' | 'order' | 'partition' | 'compose-group' | 'quick-compose'
// callId: stable UUID — correlates with onAfterModelCall and with result.feedbackTargetCallId
},
onAfterModelCall: ({ phase, attempt, callId, success, durationMs, usage, error, promptVersion }) => {
// Fires on both success and error
},Consumers build their own per-call telemetry from these. No built-in telemetry is emitted by the library.
onBeforePrompt
Gating hook — run immediately before each model.generate. Returning false aborts the workflow with WorkflowError('CANCELLED'). Use for large-prompt confirmations, cost warnings, org policy checks.
onBeforePrompt: async ({ phase, attempt, tokenEstimate, charCount, messageCount }) => {
if (tokenEstimate > 20_000) {
return await showUserConfirmation(`Large prompt (${tokenEstimate} tokens). Continue?`);
}
return true;
}hunkFilter
Runs after source collection and before the AI pipeline. Drops hunks the consumer doesn't want the AI to see. Used for AIIgnore globs, org policy filters, etc.
hunkFilter: async (hunks) => hunks.filter(h => !isAiIgnored(h.fileName)),The filter must preserve the original index values on remaining hunks (only drop entries, never renumber). The library rebuilds the safety snapshot to reflect just the filtered subset, so verifyResultingDiff still has a correct expectation. result.hunksFilteredCount tells the consumer how many were dropped.
signing
GPG signing for produced commits. Typically derived from git config commit.gpgsign + user.signingkey.
signing: {
enabled: true,
signingKey: 'ABCD1234', // optional — passed to commit-tree as -S<key>
gpgProgram: '/usr/bin/gpg', // optional — sets GIT_GPG_PROGRAM env
}authorAttribution
How the primary author is picked when a commit's hunks come from multiple original authors (recompose / commit-range sources). Ignored when no hunks carry author info (e.g. workdir source).
| Value | Behavior |
|---|---|
| 'plurality' (default) | The author who owns the most hunks wins; all other distinct authors become Co-authored-by: trailers |
| 'first-hunk' | Use the first hunk's author; other distinct authors become Co-authored-by: trailers |
| function | Fully custom — inspect the hunks and return the primary identity (or undefined to use the commit creator's identity). Co-authors are still derived from the union of remaining authors |
signal
Standard AbortSignal. Threaded through every AI call, every git operation, and every hook. On abort, the workflow throws WorkflowError('CANCELLED').
session (resumable workflows)
Pass a ComposeSession from a prior run to skip already-completed AI phases. Each task checks its own entry in completedSteps; if present, the task returns the cached result instead of invoking the model.
Useful when a user ToS-rejected between phases, when a CLI wants to build plans incrementally, or when recovering from a transient failure without redoing the whole pipeline.
// First attempt — aborted between analyze and group
const first = await composePlan({ git, model, depth: 'deep', ... });
// Retry with the session — analyze is skipped, group/order/partition run fresh
const second = await composePlan({ git, model, depth: 'deep', session: first.session, ... });A resumed task contributes no usage and no callIds — telemetry for that phase reflects the original run.
promptOverrides
Per-task prompt overrides for consumers with their own prompt template services (server-driven templates, provider-specific templates, org-tuned wording).
import type { ComposeTaskName, PromptOverride } from '@gitkraken/compose-tools';
promptOverrides: {
group: {
system: myCustomGroupSystemPrompt, // replaces the built-in system prompt entirely
userPrefix: 'Additional context:\n...', // prepended to the user prompt
userSuffix: '\nReminder: prefer short subjects.', // appended
},
order: {
userSuffix: myOrderingHints,
},
}Task names: 'analyze' | 'group' | 'compose-group' | 'order' | 'partition' | 'quick-compose'.
The library still runs the same validators against the model response, so a replacement system prompt must preserve the output contract the task expects (JSON shape inside <output> tags, hunk index constraints, etc.).
context
Optional issue / PR URLs surfaced in commit messages.
context: { issueUrl: 'https://github.com/org/repo/issues/42', pullRequestUrl: '...' }instructions
Free-form natural-language guidance passed to the AI.
instructions: 'Separate the refactoring from the feature work. Put all test changes in their own commit.'maxRetries
Per-task retry count when the AI output fails validation. Default 3.
conventions / conventionAuthor
Override auto-detected commit message conventions. By default the library samples recent commits from the repo. Set conventionAuthor to sample commits from a specific author only (handy when the current user's style should drive it).
Result metadata
Every top-level entry point returns an object with these common fields:
| Field | Set on | Meaning |
|---|---|---|
| plan | all paths | The AI's full analysis / grouping / ordering / branches |
| snapshot | all paths | Safety hash fingerprint of the collection — carry it to apply time |
| source | all paths | { type, hunks, commitMessages?, branchName?, baseBranch? } — the hunks the AI operated on |
| session | all paths | Pass to a subsequent call to resume |
| phases | all paths | PhaseDetail[] with { name, durationMs, attempts, usage?, modelId?, promptVersion?, callIds? } per phase |
| usage | all paths | Aggregated { inputTokens, outputTokens, ... } across all AI calls |
| diffStats | sugar / plan | { fileCount, hunkCount, addedLines, removedLines } |
| feedbackTargetCallId | when AI ran | callId of the last validated model response — stamp this on thumbs up/down feedback events |
| hunksFilteredCount | when hunkFilter ran | Number of hunks the filter dropped |
| commitShas | apply paths | { commitId: sha } mapping for every commit written |
| undoId | apply paths | Pass to undoCompose() to reverse |
| partitions / partitionGroups | apply paths with partitioning | Applied partition info |
Undo
Every operation that mutates the repository returns an undoId. The undo system is surgical — it reverses only the git mutations that were recorded, rather than restoring a full repository snapshot.
Undo an operation
import { undoCompose } from '@gitkraken/compose-tools';
const undoResult = await undoCompose({
git,
undoId: result.undoId,
onProgress: (step) => console.log(step),
});
// undoResult.deletedBranches — branches removed (reversed a creation)
// undoResult.recreatedBranches — branches restored (reversed a deletion)
// undoResult.restoredBranches — branches moved back to their pre-operation SHA
// undoResult.restoredHead — whether HEAD was restored
// undoResult.restoredWorkdir — whether the working directory was restored
// undoResult.warnings — non-fatal issues encounteredSafety checks
The undo system validates safety before executing. It refuses to proceed when:
- Dirty working directory — uncommitted changes would be at risk. Never force-able.
- Branch has new commits — a branch created by the operation has been modified since. Force-able with
force: true. - HEAD SHA unreachable — the pre-operation HEAD commit is no longer reachable (e.g. after garbage collection).
- Manifest not found — the undo ID doesn't correspond to a stored manifest.
Dry-run validation
import { validateUndoCompose } from '@gitkraken/compose-tools';
const validation = await validateUndoCompose({ git, undoId: result.undoId });
if (!validation.safe) {
for (const blocker of validation.blockers) console.error(`Blocked: ${blocker.type} — ${blocker.message}`);
}
for (const warning of validation.warnings) console.warn(`Warning: ${warning.type} — ${warning.message}`);Force undo
await undoCompose({ git, undoId, force: true });Downgrades the "branch has new commits" blocker to a warning. The dirty_workdir blocker is never force-able.
List all undo IDs
import { listUndoIds } from '@gitkraken/compose-tools';
const ids = await listUndoIds(git);Manifests are persisted as git objects under refs/undo/<id> — they survive branch operations and are cleaned up automatically when an undo is executed.
Individual task functions
The AI pipeline is built from composable task functions. Call them individually for custom workflows.
| Function | Purpose |
|---|---|
| analyze(input) | Identify logical concerns, dependencies, and ordering constraints in a set of hunks |
| group(input) | Group hunks into branches and commits |
| order(input) | Determine optimal commit ordering within each branch |
| partition(input) | Subdivide a branch into stacked sub-branches |
| composeGroup(input) | Combined analyze + group in a single AI call (used by balanced / quick depths) |
| quickCompose(input) | Full pipeline (analyze + group + order + optional partition) in a single AI call |
Each task accepts an optional session to maintain AI conversation context (or to resume from cached completedSteps), and returns an updated session in its output. Each task also accepts promptOverride, onBeforeModelCall, onAfterModelCall, onBeforePrompt, and signal for the same behaviors as the top-level entry points.
Error handling
| Error | Codes |
|---|---|
| GitError | NO_CHANGES, BRANCH_NOT_FOUND, OPERATION_FAILED, SAFETY_CHECK_FAILED, CHERRY_PICK_CONFLICT |
| WorkflowError | SAFETY_CHECK_FAILED (workdir changed between plan and apply), INTERNAL, CANCELLED |
| WorkflowInputError | Invalid workflow input (missing model, incompatible source/target combination, etc.) |
| TaskInputError | Invalid task input |
| AIError | AI output failed schema validation after all retries. Code: VALIDATION_EXHAUSTED |
| ComposeGitPortMissingOpError | A flow needs a ComposeGitOps method the consumer didn't provide and exec isn't available either. Has .op (missing op name) and .hint. |
import { compose, GitError, WorkflowError, AIError } from '@gitkraken/compose-tools';
try {
await compose({ git, model });
} catch (error) {
if (error instanceof GitError && error.code === 'NO_CHANGES') {
console.log('Nothing to compose');
} else if (error instanceof GitError && error.code === 'CHERRY_PICK_CONFLICT') {
console.error(`Conflict cherry-picking ${error.detail?.sha}`);
} else if (error instanceof AIError) {
console.error('AI could not produce valid output after retries');
} else if (error instanceof WorkflowError && error.code === 'CANCELLED') {
console.log('Operation cancelled');
} else if (error instanceof WorkflowError) {
console.error(`Workflow failed: ${error.code} — ${error.message}`);
}
}Progress tracking
Every top-level entry point accepts an onProgress callback that receives a discriminated ComposeProgressEvent. Each event carries a default message, a relative weight (for progress bar math), and a phase-specific payload.
import { compose, defaultPhaseMessages } from '@gitkraken/compose-tools';
const result = await compose({
git,
model,
onProgress: (event) => {
// event.phase: 'collecting' | 'compose-grouping' | 'analyzing' | 'grouping' |
// 'ordering' | 'partitioning' | 'verifying' | 'applying'
// event.message: default English label — use directly or map to localized copy
// event.weight: approximate relative duration for progress math
// event.event: ProgressEvent with { type, attempt, maxAttempts, errors? } (AI phases)
// event.detail: phase-specific string (non-AI phases)
// event.branch: only for 'partitioning', names the branch being subdivided
},
});
// defaultPhaseMessages is exported for consumers who want to remap phase labels
// for localization without losing the weights.After completion, result.phases has timing and usage per phase:
for (const phase of result.phases) {
console.log(`${phase.name}: ${phase.durationMs}ms, ${phase.attempts} attempts`);
if (phase.usage) console.log(` ${phase.usage.inputTokens} in, ${phase.usage.outputTokens} out`);
if (phase.callIds) console.log(` call IDs: ${phase.callIds.join(', ')}`);
}
console.log(`Total tokens: ${result.usage.inputTokens} in, ${result.usage.outputTokens} out`);Contributing
This package follows the monorepo's standard tooling — see the root CONTRIBUTING.md. Local commands:
pnpm --filter @gitkraken/compose-tools build
pnpm --filter @gitkraken/compose-tools typecheck
pnpm --filter @gitkraken/compose-tools test