@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.
- package/CHANGELOG.md +72 -1
- package/README.md +109 -22
- package/dist/errors/workflow.d.ts +16 -6
- package/dist/errors/workflow.js +1 -1
- package/dist/git/apply-branches.d.ts +0 -2
- package/dist/git/apply-branches.js +1 -1
- package/dist/git/apply-target.d.ts +10 -3
- package/dist/git/apply-target.js +1 -1
- package/dist/git/commit-chain.d.ts +47 -33
- package/dist/git/commit-chain.js +7 -5
- package/dist/git/diff-collector.d.ts +69 -19
- package/dist/git/diff-collector.js +6 -7
- package/dist/git/index.d.ts +2 -2
- package/dist/git/index.js +1 -1
- package/dist/git/port-dispatch.d.ts +11 -3
- package/dist/git/port-dispatch.js +4 -3
- package/dist/git/reparent.d.ts +39 -0
- package/dist/git/reparent.js +5 -0
- package/dist/git/require-exec.js +1 -1
- package/dist/git/safety-range.d.ts +33 -0
- package/dist/git/safety-range.js +5 -0
- package/dist/git/safety.d.ts +3 -3
- package/dist/git/safety.js +1 -1
- package/dist/git/types.d.ts +16 -15
- package/dist/git/utils/authorship.d.ts +22 -0
- package/dist/git/utils/authorship.js +1 -0
- package/dist/git/utils/conventions.d.ts +0 -1
- package/dist/git/utils/conventions.js +4 -4
- package/dist/git/utils/hunk-parser.d.ts +1 -1
- package/dist/git/utils/hunk-parser.js +1 -1
- package/dist/git/utils/temp-index.d.ts +1 -1
- package/dist/git/utils/temp-index.js +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +1 -1
- package/dist/ports/git.d.ts +48 -7
- package/dist/tasks/ai-loop.d.ts +9 -15
- package/dist/tasks/ai-loop.js +2 -2
- package/dist/tasks/analyze/analyze.js +1 -1
- package/dist/tasks/analyze/prompt.d.ts +2 -0
- package/dist/tasks/analyze/prompt.js +5 -5
- package/dist/tasks/analyze/types.d.ts +6 -1
- package/dist/tasks/analyze/validator.js +1 -1
- package/dist/tasks/compose-group/compose-group.js +1 -1
- package/dist/tasks/compose-group/prompt.d.ts +2 -0
- package/dist/tasks/compose-group/prompt.js +52 -46
- package/dist/tasks/compose-group/types.d.ts +6 -1
- package/dist/tasks/group/group.js +1 -1
- package/dist/tasks/group/prompt.d.ts +2 -0
- package/dist/tasks/group/prompt.js +45 -39
- package/dist/tasks/group/types.d.ts +6 -1
- package/dist/tasks/group/validator.js +9 -4
- package/dist/tasks/order/order.js +1 -1
- package/dist/tasks/order/types.d.ts +3 -1
- package/dist/tasks/order/validator.js +1 -1
- package/dist/tasks/partition/partition.js +1 -1
- package/dist/tasks/partition/types.d.ts +3 -1
- package/dist/tasks/partition/validator.js +1 -1
- package/dist/tasks/quick-compose/prompt.d.ts +2 -0
- package/dist/tasks/quick-compose/prompt.js +24 -15
- package/dist/tasks/quick-compose/quick-compose.js +1 -1
- package/dist/tasks/quick-compose/types.d.ts +6 -1
- package/dist/tasks/quick-compose/validator.js +10 -2
- package/dist/tasks/types.d.ts +10 -9
- package/dist/tasks/utils/redact.d.ts +14 -0
- package/dist/tasks/utils/redact.js +1 -0
- package/dist/utils/cancellation.d.ts +26 -0
- package/dist/utils/cancellation.js +1 -0
- package/dist/workflows/compose.d.ts +89 -10
- package/dist/workflows/compose.js +3 -2
- package/dist/workflows/split.d.ts +14 -3
- package/dist/workflows/split.js +3 -3
- package/dist/workflows/types.d.ts +46 -18
- package/dist/workflows/undo.d.ts +18 -11
- package/dist/workflows/undo.js +1 -1
- package/package.json +2 -2
- package/dist/cancellation.d.ts +0 -20
- package/dist/cancellation.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,12 +6,83 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-05-13
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Adds line-range-overlap authorship heuristic ([getAuthorAndCoAuthorsForCombinedDiffHunk](src/git/utils/authorship.ts)) for `commit-range` and `branch` sources. Walks per-commit hunks across the source range and attributes each combined-diff hunk to the source-commit author with the highest cumulative line overlap, with the remaining contributors surfaced as co-authors. Ported from GitLens.
|
|
14
|
+
- Adds descendant re-parenting on rewrite-target applies. When `rewrite-range` rewrites history (or `target: 'branch'`/`'auto'` with branch source and `!preserveOriginal`), every ref whose tip is a strict descendant of the source range's tip (commits on the source branch beyond the range, plus any other branches forked off the tip) is rebuilt onto the new last commit via `git commit-tree`, preserving each commit's tree and remapping parents through a shared `oldSha -> newSha` map. Shared downstream commits across multiple refs get one new SHA each; merges in the downstream are rebuilt structurally. Affected refs are recorded in the undo manifest's `movedRefs` field and can be restored by `undoCompose`.
|
|
15
|
+
- Adds ref-at-tip movement — refs pointing exactly at the source range's tip (other than the source branch, which is moved by its own path) are moved to the new last commit alongside the source branch. Captured in the undo manifest the same way.
|
|
16
|
+
- Adds `validateRewriteRangeSafety` — runs at `composePlan` time when the target rewrites history (`rewrite-range`, or `branch`/`auto` with branch source and `!preserveOriginal`). Rejects the operation up front when:
|
|
17
|
+
- Any interior commit of `[from, ..., predecessor(to)]` has more than one child, or a child outside the rewritten range — this catches side branches forked off interior commits and internal sub-branches that merge back inside the range.
|
|
18
|
+
- Any ref (branch, tag, or detached HEAD) points at a commit in `[from, ..., predecessor(to)]`. The source branch ref is exempted (it moves to the new tip).
|
|
19
|
+
- Adds new port ops — `mergeBaseIsAncestor` (covers `merge-base --is-ancestor`), `revListChildren` (covers `rev-list --children`), and a `pointsAt` option on `forEachRef`. Used by the rewrite-target safety check and descendant re-parenting. CLI consumers (via `exec`) need no changes; libgit2-backed consumers should implement these for libgit2-only setups.
|
|
20
|
+
- Adds `redactHunkContent` — a `(hunk) => boolean` predicate on `ComposeInput`, `ComposePlanInput`, `SplitInput`, and on each AI-task input (`AnalyzeInput`, `GroupInput`, `ComposeGroupInput`, `QuickComposeInput`). When the predicate returns true for a hunk, the AI prompt embeds a placeholder (`REDACTED_HUNK_CONTENT`) in place of the hunk's patch body. The hunk's `fileName`, `hunkHeader`, `index`, line counts, and rename metadata are still sent so the AI can group the hunk into a commit based on filename alone. The hunk's actual `content` is still used by the apply path — no data loss, just AI-visibility control. Intended for `.aiexclude`/`.aiignore`/`.cursorignore`-style consumer features that previously had to filter excluded files entirely out of compose via `hunkFilter`. Applied at every prompt that embeds hunk content (`analyze`, `group`, `compose-group`, `quick-compose`); the `order` and `partition` prompts already omit content so the predicate is a no-op there. Type exports: `RedactHunkContent`, runtime export `REDACTED_HUNK_CONTENT`.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Breaking**: `commit-range` source's `from` and `to` are both now **inclusive**. `from` refers to the first commit being rewritten, not its parent. Callers passing `from = parent(firstCommit)` today must pass `from = firstCommit` instead. Single-commit recompose: `from = sha, to = sha`. Internally the library computes `parent(from)` for git operations.
|
|
25
|
+
- **Breaking**: `commit-range` and `branch` sources now collect hunks from a single combined diff (`diffTree(parent(from), to)`) instead of per-commit diffs. Hunks no longer carry `sourceCommitSha`. Per-hunk authorship is heuristic (line-range overlap against per-commit history) rather than deterministic per-source-commit. All hunks share one pre-image, so reordering/splitting them across new commits no longer breaks apply — plain `git apply --cached` is correct.
|
|
26
|
+
- **Breaking**: `commit-range` source with `includeWorkdir` now produces a single combined diff `diffTree(parent(from), finalTree)` and parses all hunks from it, where `finalTree` is HEAD's tree with the requested workdir portions applied. Previously the hunks fed to the AI were concatenated from two separately-parsed diffs (`parent(from)..to` for range hunks, `HEAD..finalTree` for workdir hunks), which calibrated them against two different pre-images and could fail apply-time diff verification when the AI mixed them across commits. The hash captured by the safety snapshot now matches the bytes the AI consumed, byte-for-byte.
|
|
27
|
+
- **Breaking**: `commit-range` source with `includeWorkdir` additionally requires HEAD to be non-detached (previously only the SHA equality check was enforced).
|
|
28
|
+
- **Breaking**: All sources using existing commits now validate `from` is an ancestor of `to`. Surfaces as `ComposeWorkflowInputError` at collection time when violated.
|
|
29
|
+
- **Breaking**: Filtering (`hunkFilter`) and partial apply (`applyCommitIds`) are now accepted on more source/target combinations. The new rules (enforced by `validateFilterOrPartialApply`):
|
|
30
|
+
- `workdir` source → any legal target: always allowed.
|
|
31
|
+
- `branch`/`commit-range` source → `branch`/`auto` with `preserveOriginal: true`: always allowed (source is preserved, leftover lives in the original commits).
|
|
32
|
+
- `branch`/`commit-range` source → destructive targets (`rewrite-range`, `branch`/`auto` with `preserveOriginal: false`): allowed only when HEAD is non-detached and points at the source's tip (branch tip or `to`).
|
|
33
|
+
- **Breaking**: Removes the `preserveSuffix` option from the `rewrite-range` target. Under the new descendant re-parenting model, commits on the source branch beyond `rangeTo` are always re-parented onto the new tip — opting out is no longer supported because doing so would silently strand history.
|
|
34
|
+
- The `applyRangeRewrite` result type now includes a `refMovements` field listing every ref that was moved by the apply (source branch, re-parented descendant branches/tags, refs that pointed at the source tip), with full refnames. The previous `preservedSuffixShas` field on `ApplyRangeRewriteResult` is replaced.
|
|
35
|
+
- **Breaking**: Consolidates ref-mutation tracking on a single kind-tagged shape. `ApplyMutations` (compose) now has `createdRefs` / `deletedRefs` / `movedRefs`, each entry tagged `{ kind: 'branch' | 'tag'; name; ... }`. Previously: `createdBranches` / `deletedBranches` / `movedBranches` + optional `movedRefs` with full refnames. The same consolidation applies to `UndoManifest` (shared-tools): `createdRefs` / `deletedRefs` / `movedRefs` replace the per-namespace fields. `UndoResult` similarly: `deletedRefs` / `recreatedRefs` / `restoredRefs` (kind-tagged). The previous `UndoManifest.createdRefs` field for internal/auxiliary refs is renamed to `internalRefs`, and `UndoResult.cleanedRefs` is renamed to `cleanedInternalRefs`, so the names don't collide with the new user-visible ref fields.
|
|
36
|
+
- **Breaking**: Rewrite-target apply and safety paths now restrict their ref filters to `refs/heads/*` and `refs/tags/*`. Any other refs (remote-tracking, stash, notes, replace, internal undo metadata, third-party custom namespaces) are skipped — neither moved by the apply nor flagged by the safety check.
|
|
37
|
+
- **Breaking**: Reworks the apply-time leftover model:
|
|
38
|
+
- `target: 'branch'`/`'auto'` with workdir source no longer uses a stash dance. The new branch ref is created via `update-ref` (no checkout, no HEAD move). When `preserveOriginal: false`, the worktree is reset to the user's original HEAD and any leftover hunks are materialized as unstaged WIP. When `preserveOriginal: true` (default), the worktree is left entirely untouched. The user stays on their original branch in both cases.
|
|
39
|
+
- `target: 'branch'`/`'auto'` with branch source and `preserveOriginal: false` similarly skips the stash dance. If the user was on the source branch, HEAD migrates to the new branch and leftover is applied as unstaged.
|
|
40
|
+
- `target: 'rewrite-range'` only takes a safety stash when the user is on the source branch AND `includeWorkdir` was NOT active for the source. With `includeWorkdir` active, the WIP was already mixed into the source input, so the stash is skipped and leftover is materialized via the leftover patch as unstaged.
|
|
41
|
+
|
|
42
|
+
### Removed
|
|
43
|
+
|
|
44
|
+
- **Breaking**: Removes `sourceCommitSha` from `ComposeHunk`. The field was internal-facing and never reached the AI prompt; replaced by the heuristic authorship pass.
|
|
45
|
+
- **Breaking**: Removes the `source` field from `ComposeHunk` (previously `'staged' | 'unstaged' | 'untracked' | 'branch'`). The only downstream consumer was the leftover-patch's `'branch'`-only filter, which is no longer needed under the unified leftover model above. `parseUnifiedDiff`'s `source` argument is gone — call it with just `(diffText)` or `(diffText, startIndex)`.
|
|
46
|
+
- **Breaking**: Removes the `ApplyUpTo` type. Replaced by the simpler `applyCommitIds?: string[]` field on `ComposeInput` and `ApplyComposePlanInput` — a flat list of plan commit ids to include. Undefined means "all". The old `{ kind: 'count' }` and `{ kind: 'commitId' }` cumulative forms are gone; if you need them, derive a list by slicing `plan.allOrderedCommits` and mapping to ids.
|
|
47
|
+
- **Breaking**: Removes `leftoverPatchBranchOnly` from `ApplyCommitChainParams`, `ApplyRangeRewriteParams`, and `ApplyBranchTargetParams`. The single `leftoverPatch` is now applied conditionally based on whether a stash was taken (skipped when stashing, because the pop's 3-way merge restores pre-apply WIP); the `'branch'`-source filter that previously partitioned this is gone.
|
|
48
|
+
|
|
49
|
+
## [0.2.0] - 2026-05-11
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- Adds an optional `maxTokens` field to every task input (`analyze`, `group`, `compose-group`, `order`, `partition`, `quick-compose`) and to the top-level `ComposeInput`, `ComposePlanInput`, and `SplitInput`. Forwarded to `runAiLoop` → `model.generate`. Defaults stay at 16384; bump on very large diffs whose structured output overflows the cap.
|
|
54
|
+
- Adds optional `includeWorkdir` to the `{ type: 'commit-range' }` source, with three independent flags — `includeStaged`, `includeUnstaged`, `includeUntracked`. Combines a contiguous commit range with the user's working changes in a single composed input. Constraint: `to` must equal the current branch tip (including-workdir on a mid-range slice has no defined WIP basis); the library validates this and throws `ComposeWorkflowInputError` otherwise.
|
|
55
|
+
- Adds `applyUpTo` partial-apply on `ComposeInput` and `ApplyComposePlanInput`. `{ kind: 'all' }` (default) applies the entire plan; `{ kind: 'count', count: N }` applies the first `N` commits in plan order; `{ kind: 'commitId', id }` applies up to and including the named commit. Hunks not covered by the applied prefix become uncommitted leftover in the worktree. Incompatible with `target: 'auto'`; throws `ComposeWorkflowInputError` on combination.
|
|
56
|
+
- Adds `stashLabel` on `ComposeInput` and `ApplyComposePlanInput`. Custom message for the safety stash the apply step creates when the worktree is dirty; defaults to `compose backup`. Consumers wanting their own abandoned-stash recovery UX use a stable prefix here and scan `git stash list` for it after process kills.
|
|
57
|
+
- Adds new fields on apply results — `appliedCommitIds` (the prefix of `plan.allOrderedCommits.map(c => c.id)` actually written; same as the full plan when `applyUpTo` is unset or `'all'`), `unappliedHunkIndices` (hunk indices from the plan not covered by the applied commits), and `stashConflict` (set when the post-apply stash pop hit conflicts — apply itself succeeded and the named stash is preserved for manual recovery). Type exports: `ApplyUpTo`, `StashConflict`.
|
|
58
|
+
- Adds optional `applyPatchToWorkdir` op on `ComposeGitOps` — worktree-only `git apply` (no `--cached`). The library uses it to write leftover hunks during partial apply. CLI / hybrid consumers fall through to `exec(['apply', ...])` automatically; libgit2-style consumers should provide a libgit2-backed implementation.
|
|
59
|
+
- Adds `withIndex?: boolean` on the `stashPop` op via a new `StashPopOptions` argument (`stashPop?: (opts?: StashPopOptions) => Promise<void>`). Maps to `git stash pop --index` so originally-staged content is restored as staged after the apply. The previous signature was `(opts?: OpOptions)`; `StashPopOptions` extends `OpOptions`, so existing implementations remain type-compatible. Ops-only consumers should honor `withIndex` to preserve the user's pre-stash staging.
|
|
60
|
+
- Adds optional `hunkFilter` on `ApplyComposePlanInput`. Required to match the filter passed to `composePlan` when the plan was originally created — the library re-applies the filter to the freshly collected hunks before its drift check. Plans created with a filter that are applied without the equivalent filter will fail the drift check (or, in degenerate cases, apply with the filter silently ignored, reintroducing excluded content as leftover). Consumers that serialize plans across processes should record the filter spec (e.g. excluded file names) alongside the plan and reconstruct the filter on apply.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- **Breaking:** Renames the workflow error classes to compose-scoped names — `WorkflowError` → `ComposeWorkflowError`, `WorkflowInputError` → `ComposeWorkflowInputError`, `WorkflowErrorCode` → `ComposeWorkflowErrorCode`. The shape and codes are unchanged; consumers only need to update import / `instanceof` sites.
|
|
65
|
+
- **Breaking:** Renames the cancellation field on workflow and task inputs from `signal` to `cancellation` to make the contract clear: this token only flows into the AI loop, not into git operations. Mid-flight cancellation of a git op risks a half-mutated repo, so by design git operations always run to completion.
|
|
66
|
+
- **Breaking:** Removes the `signal` field from `OpOptions` and from `ApplyComposePlanInput`, `UndoComposeInput`, `ValidateUndoComposeInput`, and `listUndoIds` — those are git-only paths and run to completion.
|
|
67
|
+
- The AI loop now throws a native `AbortError` on cancellation; workflows translate it into `ComposeWorkflowError('CANCELLED')` for consumers via the new `rethrowAsWorkflowError` helper.
|
|
68
|
+
- The apply step now backs up pending workdir state via `git stash push --include-untracked` before mutating, and pops it back afterward. Conflicts during the post-apply pop surface as `result.stashConflict` rather than throwing — apply itself succeeded, the named stash is preserved, and consumers handle restoration through their own UX. Set `stashLabel` for a stable prefix consumers can recognize in `git stash list` for abandoned-stash recovery flows. Apply-time observers will see new stash entries created and (on success) immediately popped; if you've been scanning the reflog or the stash list around an apply, expect this entry pair.
|
|
69
|
+
- Tightens upfront guidance against duplicate hunks in the `group`, `compose-group`, and `quick-compose` system prompts — an explicit "Critical constraints" section spells out the four hard rules and asks the model to self-check the hunk count before producing the output tag.
|
|
70
|
+
- Sharpens the default commit-message guidance to require both a summary line and a description body separated by a blank line.
|
|
71
|
+
- Validation error feedback now shows where each duplicated hunk landed (`hunk N → branch-1/commit-2, branch-2/commit-3`) and reports the total reference count vs the expected count, so duplicate / drift / truncation patterns are obvious at a glance.
|
|
72
|
+
- JSON-parse failures in validators now include the underlying error message and a 300-character tail of the model's output, making it obvious when a response was truncated by `maxTokens` rather than malformed.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- `applyComposePlan` now correctly handles plans created with a `hunkFilter` that dropped hunks. The drift check applies the same filter to the fresh collection before comparing diff hashes, and falls back to source-state hashes (filter-agnostic) when diff hashes mismatch on any verify path. Previously this surfaced as a spurious `SAFETY_CHECK_FAILED` even when the user hadn't drifted; now requires the consumer to pass the same `hunkFilter` to `applyComposePlan` — see the "Added" entry for `hunkFilter` on `ApplyComposePlanInput`.
|
|
77
|
+
- The `rewrite-range` and `branch-replace` apply paths now capture the user's pre-apply state with a single top-level stash that runs before any branch ref movement. Previously the stash could be taken after the ref move had already shifted HEAD's effective SHA, producing a stash whose recorded parent disagreed with its content; on pop this could surface as reverse-staged changes ("staged: file added/removed" the user never staged) or `would be overwritten by merge` / `untracked working tree files would be overwritten` failures.
|
|
78
|
+
- The apply step's leftover-patch now covers only `branch`-source hunks when a stash backup was taken; WIP-source restoration (modified files via 3-way merge, originally-staged content via index restore, untracked files via `u`-commit checkout) is now left entirely to the post-apply pop. Avoids the dual-write that produced `untracked already exists` and `would be overwritten` failures in earlier versions.
|
|
79
|
+
|
|
9
80
|
## [0.1.0] - 2026-04-23
|
|
10
81
|
|
|
11
82
|
### Added
|
|
12
83
|
|
|
13
84
|
- Adds `compose(input)` — one-call sugar that runs planning and apply together; ideal when the consumer does not need to surface the plan for review
|
|
14
|
-
- Adds `composePlan(input)` and `applyComposePlan(input)` — a two-phase API for consumers
|
|
85
|
+
- Adds `composePlan(input)` and `applyComposePlan(input)` — a two-phase API for consumers that surface the plan for user review or editing before applying; `applyComposePlan` reverifies the workdir matches the plan's snapshot before mutating
|
|
15
86
|
- Adds `split(input)` — partitions an existing multi-commit branch into a stack of smaller, reviewable branches
|
|
16
87
|
- Adds `undoCompose(input)` and `validateUndoCompose(input)` — surgical reversal of only the git mutations a compose operation recorded, plus a dry-run validator that reports blockers and warnings without mutating the repo
|
|
17
88
|
- Adds `ComposeGitPort` — the git surface used by every workflow; accepts raw `exec`, a hybrid of `exec` plus targeted high-level op overrides (e.g. a signing-aware `commitTree`), or an ops-only implementation for libgit2-backed consumers; missing ops throw `ComposeGitPortMissingOpError` naming the missing op
|
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ const git: ComposeGitPort = {
|
|
|
49
49
|
};
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
The library builds every git operation it needs (diff, write-tree, commit-tree, update-ref, stash,
|
|
52
|
+
The library builds every git operation it needs (diff, write-tree, commit-tree, update-ref, stash, for-each-ref, etc.) from `exec`.
|
|
53
53
|
|
|
54
54
|
**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`.
|
|
55
55
|
|
|
@@ -66,7 +66,9 @@ const git: ComposeGitPort = {
|
|
|
66
66
|
|
|
67
67
|
**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.
|
|
68
68
|
|
|
69
|
-
The full list of high-level ops is in the `ComposeGitOps` type; the most commonly overridden are `commitTree`, `readTree`, `writeTree`, `applyPatchToIndex`, `diffTree`, `stageAll`.
|
|
69
|
+
The full list of high-level ops is in the `ComposeGitOps` type; the most commonly overridden are `commitTree`, `readTree`, `writeTree`, `applyPatchToIndex`, `applyPatchToWorkdir`, `diffTree`, `stageAll`. `OpOptions` is currently empty — git operations are not cancellable in this library; they always run to completion to avoid leaving the repo half-mutated. Cancellation flows only into the AI loop (see [`cancellation`](#cancellation) below).
|
|
70
|
+
|
|
71
|
+
`stashPop` accepts a `StashPopOptions` argument with `withIndex?: boolean` (maps to `git stash pop --index`); ops-only consumers should honor `withIndex` to preserve the user's pre-stash staging after the apply step's safety stash is popped.
|
|
70
72
|
|
|
71
73
|
### `AiModelPort`
|
|
72
74
|
|
|
@@ -122,7 +124,7 @@ const result = await compose({
|
|
|
122
124
|
|
|
123
125
|
### `composePlan(input)` + `applyComposePlan(input)` — two-phase
|
|
124
126
|
|
|
125
|
-
Returns the plan without mutating the repo, lets the user review/edit it, then applies. This is the shape
|
|
127
|
+
Returns the plan without mutating the repo, lets the user review/edit it, then applies. This is the shape interactive review UIs use (plan → review surface → user edits → apply).
|
|
126
128
|
|
|
127
129
|
```typescript
|
|
128
130
|
import { composePlan, applyComposePlan } from '@gitkraken/compose-tools';
|
|
@@ -148,7 +150,7 @@ const applied = await applyComposePlan({
|
|
|
148
150
|
});
|
|
149
151
|
```
|
|
150
152
|
|
|
151
|
-
`applyComposePlan` reverifies the workdir matches `snapshot` before mutating. If it drifted between plan and apply, it throws `
|
|
153
|
+
`applyComposePlan` reverifies the workdir matches `snapshot` before mutating. If it drifted between plan and apply, it throws `ComposeWorkflowError('SAFETY_CHECK_FAILED')`.
|
|
152
154
|
|
|
153
155
|
### `split(input)`
|
|
154
156
|
|
|
@@ -181,17 +183,26 @@ See [Undo](#undo) below.
|
|
|
181
183
|
|---|---|
|
|
182
184
|
| `{ type: 'workdir', stagedOnly?, noUntracked? }` | Collects staged, unstaged, and untracked changes. `stagedOnly: true` limits to the index; `noUntracked: true` skips untracked files. |
|
|
183
185
|
| `{ type: 'branch', name, mergeTarget? }` | Collects `mergeBase(mergeTarget, name) → name` as one combined diff. `mergeTarget` defaults to `'main'`. |
|
|
184
|
-
| `{ type: 'commit-range', branch, from, to }` | Collects a contiguous range on `branch`
|
|
186
|
+
| `{ type: 'commit-range', branch, from, to, includeWorkdir? }` | Collects a contiguous range on `branch` as a single combined diff. Both `from` and `to` are **inclusive** — they name the first and last commits being rewritten. For a single-commit recompose: `from = sha, to = sha`. Per-hunk authorship is attributed via a line-range-overlap heuristic against per-commit history. Pass `includeWorkdir: { includeStaged?, includeUnstaged?, includeUntracked? }` to combine the range with the user's working changes in one composed input. When set, `to` must equal the current branch tip AND HEAD must be non-detached. |
|
|
185
187
|
|
|
186
188
|
## Targets
|
|
187
189
|
|
|
188
190
|
| Target | Description |
|
|
189
191
|
|---|---|
|
|
190
192
|
| `{ type: 'head' }` | Commit onto the current branch. Default for `workdir` source. |
|
|
191
|
-
| `{ type: 'branch', name, preserveOriginal? }` | Create a single named branch. `preserveOriginal: true` keeps the source branch. |
|
|
192
|
-
| `{ type: '
|
|
193
|
-
| `{ type: '
|
|
194
|
-
|
|
193
|
+
| `{ type: 'branch', name, preserveOriginal? }` | Create a single named branch. `preserveOriginal: true` keeps the source branch / WIP; `false` cleans it up (deletes the source branch ref, or clears the committed WIP from the worktree). |
|
|
194
|
+
| `{ type: 'auto', preserveOriginal? }` | Create one branch per concern, optionally subdividing into stacked sub-branches. Same `preserveOriginal` semantics as `branch`. Default for `branch`/`commit-range` sources. |
|
|
195
|
+
| `{ type: 'rewrite-range', branch, rangeFrom, rangeTo }` | Replace a contiguous range on `branch` in-place. The new chain stacks on `parent(rangeFrom)`. Refs whose tip is a strict descendant of `rangeTo` get their downstream chains re-parented onto the new tip via `commit-tree`; refs at `rangeTo` itself move with the source branch. |
|
|
196
|
+
|
|
197
|
+
### Source/target pairing matrix
|
|
198
|
+
|
|
199
|
+
| source | head | branch | auto | rewrite-range |
|
|
200
|
+
| -------------- | :--: | :----: | :--: | :-----------: |
|
|
201
|
+
| `workdir` | ✓ | ✓ | ✓ | ✗ |
|
|
202
|
+
| `branch` | ✗ | ✓ | ✓ | ✓ |
|
|
203
|
+
| `commit-range` | ✗ | ✓ | ✓ | ✓ |
|
|
204
|
+
|
|
205
|
+
Invalid pairings throw `ComposeWorkflowInputError` at `composePlan` time.
|
|
195
206
|
|
|
196
207
|
## Options
|
|
197
208
|
|
|
@@ -257,7 +268,7 @@ Consumers build their own per-call telemetry from these. No built-in telemetry i
|
|
|
257
268
|
|
|
258
269
|
### `onBeforePrompt`
|
|
259
270
|
|
|
260
|
-
Gating hook — run immediately before each `model.generate`. Returning `false` aborts the workflow with `
|
|
271
|
+
Gating hook — run immediately before each `model.generate`. Returning `false` aborts the workflow with `ComposeWorkflowError('CANCELLED')`. Use for large-prompt confirmations, cost warnings, org policy checks.
|
|
261
272
|
|
|
262
273
|
```typescript
|
|
263
274
|
onBeforePrompt: async ({ phase, attempt, tokenEstimate, charCount, messageCount }) => {
|
|
@@ -278,6 +289,21 @@ hunkFilter: async (hunks) => hunks.filter(h => !isAiIgnored(h.fileName)),
|
|
|
278
289
|
|
|
279
290
|
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.
|
|
280
291
|
|
|
292
|
+
**`hunkFilter` and the two-phase `composePlan` / `applyComposePlan` flow:** `applyComposePlan` accepts the same `hunkFilter` field. **You must pass an equivalent filter at apply time** — the library re-applies it to the freshly collected hunks before the drift check, otherwise the cached (filtered) snapshot's diff hash won't match the (unfiltered) fresh diff. For consumers that serialize the plan across processes (e.g. CLI save-to-JSON workflows), record the filter spec — the list of excluded file names, the AIIgnore glob set, etc. — alongside the plan and reconstruct the function on apply. The filter function itself isn't serializable; its inputs typically are.
|
|
293
|
+
|
|
294
|
+
### `redactHunkContent`
|
|
295
|
+
|
|
296
|
+
Predicate that returns `true` for hunks whose patch body should be masked from AI prompts. Matching hunks keep their `fileName`, `hunkHeader`, `index`, line counts, and rename metadata — only the body is replaced with a placeholder. The apply path still uses the real content from the collection.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
const aiExcluded = new Set(['secrets.env', 'config/keys.json']);
|
|
300
|
+
redactHunkContent: (h) => aiExcluded.has(h.fileName) || aiExcluded.has(h.originalFileName ?? h.fileName),
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Intended for `.aiexclude` / `.aiignore` / `.cursorignore`-style features where the AI should group a hunk into a commit by filename alone without ever seeing its content. Distinct from `hunkFilter`: filtered hunks are dropped from the plan entirely (and never end up in any commit), while redacted hunks stay in the plan and get applied normally — only the AI prompts hide their content.
|
|
304
|
+
|
|
305
|
+
Apply paths don't observe `redactHunkContent` (no AI runs at apply time), so it isn't required on `applyComposePlan` inputs. Per-task inputs (`analyze`, `group`, `compose-group`, `quick-compose`) accept it directly when running tasks à la carte. The `order` and `partition` tasks never embed hunk content, so the predicate is a no-op there.
|
|
306
|
+
|
|
281
307
|
### `signing`
|
|
282
308
|
|
|
283
309
|
GPG signing for produced commits. Typically derived from `git config commit.gpgsign` + `user.signingkey`.
|
|
@@ -300,9 +326,57 @@ How the primary author is picked when a commit's hunks come from multiple origin
|
|
|
300
326
|
| `'first-hunk'` | Use the first hunk's author; other distinct authors become `Co-authored-by:` trailers |
|
|
301
327
|
| 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 |
|
|
302
328
|
|
|
303
|
-
### `
|
|
329
|
+
### `cancellation`
|
|
330
|
+
|
|
331
|
+
Standard `AbortSignal`, but with a deliberately narrow scope: it is forwarded **only into the AI loop** — every `model.generate` call and the prompt-gate hook. Git operations (collection, snapshot verification, applying, undo manifest persistence) run to completion regardless of whether `cancellation` was aborted; mid-flight cancellation of a git op risks a half-mutated repo, so by design only the AI loop is cancellable.
|
|
332
|
+
|
|
333
|
+
On AI-loop abort, the workflow translates the native `AbortError` into `ComposeWorkflowError('CANCELLED')` so consumers only have to check for the workflow-domain shape.
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
const controller = new AbortController();
|
|
337
|
+
const result = await compose({ git, model, cancellation: controller.signal });
|
|
338
|
+
// later: controller.abort() — interrupts the next AI call; any git op already in
|
|
339
|
+
// flight finishes normally, then the workflow throws ComposeWorkflowError('CANCELLED').
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`applyComposePlan`, `undoCompose`, `validateUndoCompose`, and `listUndoIds` do **not** accept `cancellation` — they are git-only and run to completion.
|
|
343
|
+
|
|
344
|
+
### `applyCommitIds`
|
|
345
|
+
|
|
346
|
+
Apply only the listed plan commit ids. Hunks belonging to commits not in the list — plus any hunks dropped by `hunkFilter` — become uncommitted leftover in the worktree (modified for tracked files, untracked for new files; index state is not preserved when the source included WIP).
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
applyCommitIds: undefined // default — apply the entire plan
|
|
350
|
+
applyCommitIds: ['commit-1', 'commit-3'] // apply only these two commits, in plan order
|
|
351
|
+
```
|
|
304
352
|
|
|
305
|
-
|
|
353
|
+
The list is interpreted as a set: order doesn't matter (the plan's own order is preserved), but every id must appear in `plan.allOrderedCommits` — unknown ids throw `ComposeWorkflowInputError`.
|
|
354
|
+
|
|
355
|
+
When `applyCommitIds` (or `hunkFilter`) is set and the target rewrites existing commits (`rewrite-range`, or `branch`/`auto` with `preserveOriginal: false`) on a `branch`/`commit-range` source, the library additionally requires HEAD to be on a branch (non-detached) and pointing at the source's tip (branch tip or range `to`). The leftover patch is materialized on top of the rewritten branch as unstaged WIP. Other source/target combinations impose no HEAD constraint.
|
|
356
|
+
|
|
357
|
+
The result includes `appliedCommitIds` (the SHAs actually written) and `unappliedHunkIndices` (the hunk indices the partial apply left behind).
|
|
358
|
+
|
|
359
|
+
### `stashLabel`
|
|
360
|
+
|
|
361
|
+
Custom message for the safety stash the apply step creates when the worktree is dirty. Defaults to `compose backup`.
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
stashLabel: `myapp-compose-${new Date().toISOString()}`
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
The apply step always stashes pending workdir state via `git stash push --include-untracked` before mutating, and pops it back after. If the post-apply pop hits conflicts, the result includes `stashConflict: { stashLabel, conflictedFiles }` — apply itself succeeded, the named stash is preserved, and the consumer surfaces recovery UX (typical pattern: scan `git stash list` for a stable prefix on next mode-entry to detect abandoned stashes after process kills).
|
|
368
|
+
|
|
369
|
+
### `maxTokens`
|
|
370
|
+
|
|
371
|
+
Output-token cap forwarded to every `model.generate` call in the pipeline. Defaults to `runAiLoop`'s 16384.
|
|
372
|
+
|
|
373
|
+
Bump it on very large diffs whose structured output (e.g. analyze's per-hunk annotations) overflows the cap mid-JSON. When that happens, the validator surfaces a JSON-parse failure with a `Response tail: …` preview of the last 300 characters of the model's output, so it's obvious the response was truncated rather than malformed.
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
const result = await compose({ git, model, maxTokens: 32_000 });
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`maxTokens` is also accepted on every individual task (`analyze`, `group`, `compose-group`, `order`, `partition`, `quick-compose`) for the same reason.
|
|
306
380
|
|
|
307
381
|
### `session` (resumable workflows)
|
|
308
382
|
|
|
@@ -385,6 +459,9 @@ Every top-level entry point returns an object with these common fields:
|
|
|
385
459
|
| `commitShas` | apply paths | `{ commitId: sha }` mapping for every commit written |
|
|
386
460
|
| `undoId` | apply paths | Pass to `undoCompose()` to reverse |
|
|
387
461
|
| `partitions` / `partitionGroups` | apply paths with partitioning | Applied partition info |
|
|
462
|
+
| `appliedCommitIds` | apply paths | Plan commit IDs actually written. Same as `plan.allOrderedCommits.map(c => c.id)` for full apply; a subset when `applyCommitIds` is set. |
|
|
463
|
+
| `unappliedHunkIndices` | apply paths | Hunk indices from the plan not covered by `appliedCommitIds`. Empty for full apply. |
|
|
464
|
+
| `stashConflict` | apply paths | `{ stashLabel, conflictedFiles }` set when the post-apply stash pop hit conflicts. Apply itself succeeded; the named stash is preserved for the consumer's recovery UX. |
|
|
388
465
|
|
|
389
466
|
## Undo
|
|
390
467
|
|
|
@@ -437,7 +514,19 @@ for (const warning of validation.warnings) console.warn(`Warning: ${warning.type
|
|
|
437
514
|
await undoCompose({ git, undoId, force: true });
|
|
438
515
|
```
|
|
439
516
|
|
|
440
|
-
Downgrades the "branch has new commits" blocker to a warning.
|
|
517
|
+
Downgrades the "branch has new commits" blocker to a warning. Equivalent to `force: { branchHasNewCommits: true }`.
|
|
518
|
+
|
|
519
|
+
For finer control, pass an object form:
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
await undoCompose({
|
|
523
|
+
git,
|
|
524
|
+
undoId,
|
|
525
|
+
force: { branchHasNewCommits: true, dirtyWorkdir: true },
|
|
526
|
+
});
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
`dirtyWorkdir: true` allows undo to proceed despite uncommitted changes — the HEAD restore step does a `reset --hard`, so anything in the worktree is clobbered. Use only when the dirty state is a known artifact of the operation being undone (e.g. a failed stash pop after a successful apply where the consumer chose to roll back) and the caller has confirmed work loss with the user. `head_sha_unreachable` and `manifest_not_found` are never force-able regardless of flags.
|
|
441
530
|
|
|
442
531
|
### List all undo IDs
|
|
443
532
|
|
|
@@ -462,34 +551,32 @@ The AI pipeline is built from composable task functions. Call them individually
|
|
|
462
551
|
| `composeGroup(input)` | Combined analyze + group in a single AI call (used by `balanced` / `quick` depths) |
|
|
463
552
|
| `quickCompose(input)` | Full pipeline (analyze + group + order + optional partition) in a single AI call |
|
|
464
553
|
|
|
465
|
-
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 `
|
|
554
|
+
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`, `maxTokens`, `onBeforeModelCall`, `onAfterModelCall`, `onBeforePrompt`, and `cancellation` for the same behaviors as the top-level entry points.
|
|
466
555
|
|
|
467
556
|
## Error handling
|
|
468
557
|
|
|
469
558
|
| Error | Codes |
|
|
470
559
|
|---|---|
|
|
471
|
-
| `GitError` | `NO_CHANGES`, `BRANCH_NOT_FOUND`, `OPERATION_FAILED`, `SAFETY_CHECK_FAILED
|
|
472
|
-
| `
|
|
473
|
-
| `
|
|
560
|
+
| `GitError` | `NO_CHANGES`, `BRANCH_NOT_FOUND`, `OPERATION_FAILED`, `SAFETY_CHECK_FAILED` |
|
|
561
|
+
| `ComposeWorkflowError` | `SAFETY_CHECK_FAILED` (workdir changed between plan and apply), `INTERNAL`, `CANCELLED` |
|
|
562
|
+
| `ComposeWorkflowInputError` | Invalid workflow input (missing `model`, incompatible source/target combination, etc.) |
|
|
474
563
|
| `TaskInputError` | Invalid task input |
|
|
475
564
|
| `AIError` | AI output failed schema validation after all retries. Code: `VALIDATION_EXHAUSTED` |
|
|
476
565
|
| `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`. |
|
|
477
566
|
|
|
478
567
|
```typescript
|
|
479
|
-
import { compose, GitError,
|
|
568
|
+
import { compose, GitError, ComposeWorkflowError, AIError } from '@gitkraken/compose-tools';
|
|
480
569
|
|
|
481
570
|
try {
|
|
482
571
|
await compose({ git, model });
|
|
483
572
|
} catch (error) {
|
|
484
573
|
if (error instanceof GitError && error.code === 'NO_CHANGES') {
|
|
485
574
|
console.log('Nothing to compose');
|
|
486
|
-
} else if (error instanceof GitError && error.code === 'CHERRY_PICK_CONFLICT') {
|
|
487
|
-
console.error(`Conflict cherry-picking ${error.detail?.sha}`);
|
|
488
575
|
} else if (error instanceof AIError) {
|
|
489
576
|
console.error('AI could not produce valid output after retries');
|
|
490
|
-
} else if (error instanceof
|
|
577
|
+
} else if (error instanceof ComposeWorkflowError && error.code === 'CANCELLED') {
|
|
491
578
|
console.log('Operation cancelled');
|
|
492
|
-
} else if (error instanceof
|
|
579
|
+
} else if (error instanceof ComposeWorkflowError) {
|
|
493
580
|
console.error(`Workflow failed: ${error.code} — ${error.message}`);
|
|
494
581
|
}
|
|
495
582
|
}
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
//#region src/errors/workflow.d.ts
|
|
2
|
-
type
|
|
3
|
-
|
|
2
|
+
type ComposeWorkflowErrorCode = 'SAFETY_CHECK_FAILED' | 'INTERNAL' | 'CANCELLED';
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when a workflow input is malformed or self-inconsistent (e.g. a
|
|
5
|
+
* source/target combination that cannot apply, or a session with mismatched
|
|
6
|
+
* commit-message format).
|
|
7
|
+
*/
|
|
8
|
+
declare class ComposeWorkflowInputError extends Error {
|
|
4
9
|
constructor(message: string);
|
|
5
10
|
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Thrown when a workflow fails at a domain checkpoint — safety verification
|
|
13
|
+
* drift, internal invariant violation, or cancellation. Carries a `code` so
|
|
14
|
+
* consumers can branch on the failure mode.
|
|
15
|
+
*/
|
|
16
|
+
declare class ComposeWorkflowError extends Error {
|
|
17
|
+
readonly code: ComposeWorkflowErrorCode;
|
|
18
|
+
constructor(code: ComposeWorkflowErrorCode, message: string, options?: {
|
|
9
19
|
cause?: unknown;
|
|
10
20
|
});
|
|
11
21
|
}
|
|
12
22
|
//#endregion
|
|
13
|
-
export {
|
|
23
|
+
export { ComposeWorkflowError, ComposeWorkflowErrorCode, ComposeWorkflowInputError };
|
package/dist/errors/workflow.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var e=class extends Error{constructor(e){super(e),this.name=`
|
|
1
|
+
var e=class extends Error{constructor(e){super(e),this.name=`ComposeWorkflowInputError`}},t=class extends Error{code;constructor(e,t,n){super(t,n),this.name=`ComposeWorkflowError`,this.code=e}};export{t as ComposeWorkflowError,e as ComposeWorkflowInputError};
|
|
@@ -8,7 +8,6 @@ interface ApplyBranchStructureParams {
|
|
|
8
8
|
branches: ApplyBranchEntry[];
|
|
9
9
|
originalBranchName?: string;
|
|
10
10
|
originalBranchSha?: string;
|
|
11
|
-
signal?: AbortSignal;
|
|
12
11
|
}
|
|
13
12
|
interface ApplyBranchEntry {
|
|
14
13
|
/** The partition(s) for this branch group. Each partition becomes a git branch. */
|
|
@@ -22,7 +21,6 @@ declare function createStackedBranches(params: {
|
|
|
22
21
|
partitions: BranchPartition[];
|
|
23
22
|
originalBranchName?: string;
|
|
24
23
|
originalBranchSha?: string;
|
|
25
|
-
signal?: AbortSignal;
|
|
26
24
|
}): Promise<void>;
|
|
27
25
|
//#endregion
|
|
28
26
|
export { ApplyBranchEntry, ApplyBranchStructureParams, applyBranchStructure, createStackedBranches };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{checkout as e,createBranch as t,currentBranch as n,deleteBranch as r}from"./port-dispatch.js";async function i(i){let{git:a,branches:o,originalBranchName:s,originalBranchSha:c
|
|
1
|
+
import{checkout as e,createBranch as t,currentBranch as n,deleteBranch as r}from"./port-dispatch.js";async function i(i){let{git:a,branches:o,originalBranchName:s,originalBranchSha:c}=i;if(s&&c){let i=await n(a)===s;i&&await e(a,c,{detach:!0}),await r(a,s,{force:!0});for(let e of o)for(let n of e.partitions){let e=n.commitIds[n.commitIds.length-1];await t(a,n.branchName,e)}if(i&&o.length>0){let t=o[o.length-1],n=t.partitions[t.partitions.length-1];await e(a,n.branchName)}}else for(let e of o)for(let n of e.partitions){let e=n.commitIds[n.commitIds.length-1];await t(a,n.branchName,e)}}async function a(e){return i({git:e.git,branches:[{partitions:e.partitions,tipSha:e.partitions[e.partitions.length-1].commitIds[e.partitions[e.partitions.length-1].commitIds.length-1]}],originalBranchName:e.originalBranchName,originalBranchSha:e.originalBranchSha})}export{i as applyBranchStructure,a as createStackedBranches};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ComposeGitPort, SigningConfig } from "../ports/git.js";
|
|
2
2
|
import { BranchGroup, BranchPartition, CommitSuggestion, ComposeHunk } from "../tasks/types.js";
|
|
3
3
|
import { PartitionResult } from "../tasks/partition/types.js";
|
|
4
|
-
import { ComposeTarget } from "../workflows/types.js";
|
|
5
|
-
import { ApplyMutations, SafetySnapshot } from "./types.js";
|
|
4
|
+
import { ComposeSource, ComposeTarget } from "../workflows/types.js";
|
|
5
|
+
import { ApplyMutations, SafetySnapshot, StashConflict } from "./types.js";
|
|
6
6
|
import { CollectionResult } from "./diff-collector.js";
|
|
7
7
|
|
|
8
8
|
//#region src/git/apply-target.d.ts
|
|
@@ -13,18 +13,24 @@ interface ComposeBranchPlan {
|
|
|
13
13
|
}
|
|
14
14
|
interface ApplyToTargetParams {
|
|
15
15
|
git: ComposeGitPort;
|
|
16
|
+
source: ComposeSource;
|
|
16
17
|
target: ComposeTarget;
|
|
17
18
|
branchPlans: ComposeBranchPlan[];
|
|
18
19
|
allOrderedCommits: CommitSuggestion[];
|
|
19
20
|
hunks: ComposeHunk[];
|
|
20
21
|
snapshot: SafetySnapshot;
|
|
21
22
|
collection: CollectionResult;
|
|
22
|
-
signal?: AbortSignal;
|
|
23
23
|
signing?: SigningConfig;
|
|
24
24
|
authorAttribution?: 'plurality' | 'first-hunk' | ((hunks: ComposeHunk[]) => {
|
|
25
25
|
name: string;
|
|
26
26
|
email: string;
|
|
27
27
|
} | undefined);
|
|
28
|
+
/** Stash message used by the apply helpers when stashing pending workdir state. */
|
|
29
|
+
stashLabel?: string;
|
|
30
|
+
/** Patch applied to the worktree (unstaged) to materialize hunks not included in the chain
|
|
31
|
+
* (filter-dropped or partial-apply omitted). Lets the user keep their unselected content
|
|
32
|
+
* as WIP after a destructive apply rewrites the source. */
|
|
33
|
+
leftoverPatch?: string;
|
|
28
34
|
}
|
|
29
35
|
interface ApplyToTargetResult {
|
|
30
36
|
partitions?: BranchPartition[];
|
|
@@ -33,6 +39,7 @@ interface ApplyToTargetResult {
|
|
|
33
39
|
commitShas: Record<string, string>;
|
|
34
40
|
/** Git mutations performed during apply, for undo manifest construction. */
|
|
35
41
|
mutations: ApplyMutations;
|
|
42
|
+
stashConflict?: StashConflict;
|
|
36
43
|
}
|
|
37
44
|
declare function applyToTarget(params: ApplyToTargetParams): Promise<ApplyToTargetResult>;
|
|
38
45
|
//#endregion
|
package/dist/git/apply-target.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{GitError as e}from"../errors/git.js";import{
|
|
1
|
+
import{GitError as e}from"../errors/git.js";import{applyPatchToWorkdir as t,checkout as n,cleanWorkdir as r,currentBranch as i,deleteBranch as a,discardWorkdirChanges as o,reset as s,revParse as c,updateRef as l}from"./port-dispatch.js";import{applyBranchStructure as u}from"./apply-branches.js";import{reparentDescendants as d}from"./reparent.js";import{applyCommitChain as f,applyRangeRewrite as p,buildCommitChain as m,moveRefsAtSha as h}from"./commit-chain.js";import{verifyMultiBranchDiff as g,verifyResultingDiff as _}from"./safety.js";async function v(t){let{git:n,source:r,target:i,branchPlans:a,allOrderedCommits:o,hunks:s,snapshot:l,collection:d,signing:h,authorAttribution:g,stashLabel:v,leftoverPatch:x}=t;if(i.type===`rewrite-range`){if(!d.branchName||!d.branchSha)throw new e(`OPERATION_FAILED`,`rewrite-range target requires a source that records branchName and branchSha (branch or commit-range).`);let t;try{t=await c(n,`${i.rangeFrom}^`)}catch{throw new e(`OPERATION_FAILED`,`rewrite-range target's rangeFrom (${i.rangeFrom.slice(0,8)}) has no parent (root commit) — rewriting the root commit is not currently supported.`)}let{headSha:a,shaMap:u}=await m({git:n,baseSha:t,commits:o,hunks:s,signing:h,authorAttribution:g}),f=await _(n,t,a,l);if(!f.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${f.error}`);let y=r.type===`commit-range`&&r.includeWorkdir!=null,b=await p({git:n,branch:i.branch,rangeFrom:i.rangeFrom,rangeTo:i.rangeTo,newHeadSha:a,originalBranchSha:d.branchSha,stashLabel:v,leftoverPatch:x,includeWorkdirActive:y});return{commitShas:Object.fromEntries(u),mutations:T(b.refMovements),stashConflict:b.stashConflict}}if(d.branchName&&!d.mergeBaseSha)throw new e(`OPERATION_FAILED`,`Branch source is missing mergeBaseSha — cannot determine base commit`);let S=d.mergeBaseSha??await c(n,`HEAD`),w=D(r,i);if(i.type===`auto`&&a.length>1)return y(n,S,a,s,l,d,w,h,g);if(i.type===`auto`&&a.length===1&&a[0].partitioning)return b(n,S,a[0],s,l,d,w,h,g);let{headSha:k,shaMap:A}=await m({git:n,baseSha:S,commits:o,hunks:s,signing:h,authorAttribution:g}),j=Object.fromEntries(A),M=await _(n,S,k,l);if(!M.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${M.error}`);if(i.type===`branch`)return O({git:n,source:r,target:i,newHeadSha:k,collection:d,stashLabel:v,leftoverPatch:x,commitShas:j});if(i.type===`auto`){let e=a[0]?.branchGroup.name||d.branchName;if(e){let t=!w&&d.branchName&&d.branchSha?{originalBranchName:d.branchName,originalBranchSha:d.branchSha}:{},r={branchName:e,title:a[0]?.branchGroup.title??``,description:a[0]?.branchGroup.description??``,commitIds:o.map(e=>e.id)};await u({git:n,branches:[{partitions:[r],tipSha:k}],...t});let i=E([r],j,t.originalBranchName,t.originalBranchSha);return{partitions:[r],commitShas:j,mutations:i}}let t=await f({git:n,newHeadSha:k,untrackedIncluded:d.untrackedIncluded,stashLabel:v});return{commitShas:j,mutations:C(),stashConflict:t.stashConflict}}let N=await f({git:n,newHeadSha:k,untrackedIncluded:d.untrackedIncluded,stashLabel:v,leftoverPatch:x});return{commitShas:j,mutations:C(),stashConflict:N.stashConflict}}async function y(t,n,i,a,s,c,l,d,f){let p=[],h=[],_=[],v=[],y={};for(let e of i){let r=x(e),{headSha:i,shaMap:o}=await m({git:t,baseSha:n,commits:r,hunks:a,signing:d,authorAttribution:f});_.push(i);for(let[e,t]of o)y[e]=t;if(e.partitioning&&e.partitioning.partitions.length>1){let t=S(e.partitioning.partitions,o);p.push(...t),h.push(t),v.push({partitions:t,tipSha:i})}else{let t={branchName:e.branchGroup.name,title:e.branchGroup.title,description:e.branchGroup.description,commitIds:r.map(e=>o.get(e.id)??e.id)};p.push(t),h.push([t]),v.push({partitions:[t],tipSha:i})}}let b=await g(t,n,_,s);if(!b.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${b.error}`);let C=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await u({git:t,branches:v,...C}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t)),{partitions:p,partitionGroups:h,commitShas:y,mutations:E(p,y,C.originalBranchName,C.originalBranchSha)}}async function b(t,n,i,a,s,c,l,d,p){let{headSha:h,shaMap:g}=await m({git:t,baseSha:n,commits:x(i),hunks:a,signing:d,authorAttribution:p}),v=await _(t,n,h,s);if(!v.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${v.error}`);let y=S(i.partitioning.partitions,g),b=Object.fromEntries(g);if(y.length>1){let e=!l&&c.branchName&&c.branchSha?{originalBranchName:c.branchName,originalBranchSha:c.branchSha}:{};return await u({git:t,branches:[{partitions:y,tipSha:h}],...e}),!c.branchName&&!l&&(await o(t),c.untrackedIncluded&&await r(t,{force:!0,directories:!0})),{partitions:y,commitShas:b,mutations:E(y,b,e.originalBranchName,e.originalBranchSha)}}return await f({git:t,newHeadSha:h,untrackedIncluded:c.untrackedIncluded}),{partitions:y,commitShas:b,mutations:C()}}function x(t){return t.orderedCommitIds.map(n=>{let r=t.branchGroup.commits.find(e=>e.id===n);if(!r)throw new e(`OPERATION_FAILED`,`Commit ${n} not found in branch '${t.branchGroup.id}'`);return r})}function S(t,n){return t.map(t=>({...t,commitIds:t.commitIds.map(t=>{let r=n.get(t);if(!r)throw new e(`OPERATION_FAILED`,`No SHA found for commit ID '${t}'`);return r})}))}function C(){return{createdRefs:[],deletedRefs:[],movedRefs:[]}}function w(t){if(t.startsWith(`refs/heads/`))return{kind:`branch`,name:t.slice(11)};if(t.startsWith(`refs/tags/`))return{kind:`tag`,name:t.slice(10)};throw new e(`OPERATION_FAILED`,`Unexpected refname '${t}' in apply mutations — only refs/heads/* and refs/tags/* are tracked.`)}function T(e){let t=[];for(let n of e){let{kind:e,name:r}=w(n.refname);t.push({kind:e,name:r,previousSha:n.previousSha,newSha:n.newSha})}return{createdRefs:[],deletedRefs:[],movedRefs:t}}function E(e,t,n,r){let i=e.map(e=>{let n=e.commitIds[e.commitIds.length-1];return{kind:`branch`,name:e.branchName,sha:t[n]??n}}),a=[];return n&&r&&a.push({kind:`branch`,name:n,sha:r}),{createdRefs:i,deletedRefs:a,movedRefs:[]}}function D(e,t){return t.type!==`branch`&&t.type!==`auto`||e.type===`commit-range`?!0:t.preserveOriginal===!0}async function O(r){let{git:o,source:u,target:f,newHeadSha:p,collection:m,leftoverPatch:g,commitShas:_}=r,v=D(u,f),y=u.type===`branch`&&!v&&m.branchName!=null&&m.branchSha!=null;if(y&&f.name===m.branchName)throw new e(`OPERATION_FAILED`,`target 'branch' with name '${f.name}' conflicts with the source branch (preserveOriginal=false). Use target 'rewrite-range' to rewrite the branch in place under its existing name.`);let b={createdRefs:[{kind:`branch`,name:f.name,sha:p}],deletedRefs:[],movedRefs:[]},x=await i(o),S=await c(o,`HEAD`);if(await l(o,`refs/heads/${f.name}`,p),y){let e=m.branchSha,r=m.branchName,i=await d({git:o,oldToSha:e,newTopSha:p,sourceBranchName:void 0});for(let e of i.reparentedRefs){let{kind:t,name:n}=w(e.refname);b.movedRefs.push({kind:t,name:n,previousSha:e.oldSha,newSha:e.newSha})}let c=await h(o,e,p,`refs/heads/${r}`);for(let e of c){let{kind:t,name:n}=w(e.refname);b.movedRefs.push({kind:t,name:n,previousSha:e.previousSha,newSha:e.newSha})}x===r&&(await n(o,f.name),await s(o,p,`hard`),g&&await t(o,g)),await a(o,r,{force:!0}),b.deletedRefs.push({kind:`branch`,name:r,sha:e})}else u.type===`workdir`&&!v&&(await s(o,S,`hard`),g&&await t(o,g));return{commitShas:_,mutations:b}}export{v as applyToTarget};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ComposeGitPort, SigningConfig } from "../ports/git.js";
|
|
2
2
|
import { CommitSuggestion, ComposeHunk } from "../tasks/types.js";
|
|
3
|
+
import { StashConflict } from "./types.js";
|
|
3
4
|
|
|
4
5
|
//#region src/git/commit-chain.d.ts
|
|
5
6
|
type AuthorAttributionStrategy = 'plurality' | 'first-hunk' | ((hunks: ComposeHunk[]) => {
|
|
@@ -11,7 +12,6 @@ interface BuildCommitChainParams {
|
|
|
11
12
|
baseSha: string;
|
|
12
13
|
commits: CommitSuggestion[];
|
|
13
14
|
hunks: ComposeHunk[];
|
|
14
|
-
signal?: AbortSignal;
|
|
15
15
|
signing?: SigningConfig;
|
|
16
16
|
authorAttribution?: AuthorAttributionStrategy;
|
|
17
17
|
}
|
|
@@ -31,17 +31,18 @@ interface ApplyCommitChainParams {
|
|
|
31
31
|
* conflicts between stash pop and the newly committed tree.
|
|
32
32
|
*/
|
|
33
33
|
untrackedIncluded?: boolean;
|
|
34
|
-
|
|
34
|
+
/** Stash message used when a backup stash is needed. */
|
|
35
|
+
stashLabel?: string;
|
|
36
|
+
/** Patch applied to the worktree (no staging) after the chain is reset and before the stash is popped.
|
|
37
|
+
* Materializes the hunks not included in the chain (filter-dropped / partial-apply omitted) as
|
|
38
|
+
* unstaged WIP, so the user's unselected content isn't lost when a destructive apply rewrites
|
|
39
|
+
* the source. */
|
|
40
|
+
leftoverPatch?: string;
|
|
35
41
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
git: ComposeGitPort;
|
|
39
|
-
branchName: string;
|
|
40
|
-
newHeadSha: string;
|
|
41
|
-
originalBranchSha: string;
|
|
42
|
-
signal?: AbortSignal;
|
|
42
|
+
interface ApplyChainResult {
|
|
43
|
+
stashConflict?: StashConflict;
|
|
43
44
|
}
|
|
44
|
-
declare function
|
|
45
|
+
declare function applyCommitChain(params: ApplyCommitChainParams): Promise<ApplyChainResult>;
|
|
45
46
|
interface ApplyRangeRewriteParams {
|
|
46
47
|
git: ComposeGitPort;
|
|
47
48
|
/** Branch whose tip will be updated to the result. */
|
|
@@ -54,34 +55,47 @@ interface ApplyRangeRewriteParams {
|
|
|
54
55
|
newHeadSha: string;
|
|
55
56
|
/** Resolved SHA: branch tip before the operation started. */
|
|
56
57
|
originalBranchSha: string;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
stashLabel?: string;
|
|
59
|
+
leftoverPatch?: string;
|
|
60
|
+
/** When `true`, the source's `includeWorkdir` was active, so WIP was already mixed into the
|
|
61
|
+
* input and no safety stash is taken. Leftover hunks are materialized via {@link leftoverPatch}
|
|
62
|
+
* instead. When `false` (or undefined), a safety stash protects unrelated dirty WIP. */
|
|
63
|
+
includeWorkdirActive?: boolean;
|
|
60
64
|
}
|
|
61
65
|
interface ApplyRangeRewriteResult {
|
|
62
|
-
/** Final branch tip SHA after
|
|
66
|
+
/** Final source-branch tip SHA after descendant re-parenting and any ref-at-tip moves. */
|
|
63
67
|
finalTipSha: string;
|
|
64
|
-
/**
|
|
65
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Every ref movement performed by this apply: the source branch move, every
|
|
70
|
+
* descendant ref re-parented onto the new tip, and every ref that pointed at
|
|
71
|
+
* the old range tip and was moved to the new tip. Names are full refnames
|
|
72
|
+
* (e.g. `refs/heads/main`, `refs/tags/v1.0.0`). The caller categorizes these
|
|
73
|
+
* into branch vs tag for the undo manifest.
|
|
74
|
+
*/
|
|
75
|
+
refMovements: {
|
|
76
|
+
refname: string;
|
|
77
|
+
previousSha: string;
|
|
78
|
+
newSha: string;
|
|
79
|
+
}[];
|
|
80
|
+
stashConflict?: StashConflict;
|
|
66
81
|
}
|
|
67
82
|
/**
|
|
68
|
-
* Rewrite a contiguous range of commits on a branch in-place.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* work done later on the branch is preserved.
|
|
74
|
-
*
|
|
75
|
-
* Behavior:
|
|
76
|
-
* 1. Stash the user's workdir if dirty (restored in the caller via finally).
|
|
77
|
-
* 2. If `preserveSuffix` (default true) and the range didn't reach the branch
|
|
78
|
-
* tip, cherry-pick every commit in `rangeTo..originalBranchSha` onto
|
|
79
|
-
* `newHeadSha`. On conflict, abort the cherry-pick and throw
|
|
80
|
-
* `GitError('CHERRY_PICK_CONFLICT', …)` — repo state is left safe.
|
|
81
|
-
* 3. `update-ref` the branch to the final tip.
|
|
82
|
-
* 4. If the user was on the branch, `reset --hard` to the final tip so their
|
|
83
|
-
* checked-out state follows the rewrite.
|
|
83
|
+
* Rewrite a contiguous range of commits on a branch in-place. Given a new commit chain
|
|
84
|
+
* already built on top of `parent(rangeFrom)` (at `newHeadSha`), re-parents every ref whose
|
|
85
|
+
* tip is a descendant of `rangeTo` onto the new tip (via `reparentDescendants`), moves the
|
|
86
|
+
* source branch, and moves any other refs that pointed at `rangeTo`. If the user was on the
|
|
87
|
+
* branch, their HEAD and worktree are reset to the final tip.
|
|
84
88
|
*/
|
|
85
89
|
declare function applyRangeRewrite(params: ApplyRangeRewriteParams): Promise<ApplyRangeRewriteResult>;
|
|
90
|
+
/**
|
|
91
|
+
* Move every `refs/heads/*` and `refs/tags/*` ref pointing at `oldSha` to `newSha`, except
|
|
92
|
+
* for `excludeRefname` (typically the source branch, handled by the caller). Returns the
|
|
93
|
+
* moves for the undo manifest.
|
|
94
|
+
*/
|
|
95
|
+
declare function moveRefsAtSha(git: ComposeGitPort, oldSha: string, newSha: string, excludeRefname?: string): Promise<{
|
|
96
|
+
refname: string;
|
|
97
|
+
previousSha: string;
|
|
98
|
+
newSha: string;
|
|
99
|
+
}[]>;
|
|
86
100
|
//#endregion
|
|
87
|
-
export { ApplyCommitChainParams, ApplyRangeRewriteParams, ApplyRangeRewriteResult,
|
|
101
|
+
export { ApplyChainResult, ApplyCommitChainParams, ApplyRangeRewriteParams, ApplyRangeRewriteResult, AuthorAttributionStrategy, BuildCommitChainParams, BuildCommitChainResult, applyCommitChain, applyRangeRewrite, buildCommitChain, moveRefsAtSha };
|