@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
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 &mdash; 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 &mdash; `WorkflowError` &rarr; `ComposeWorkflowError`, `WorkflowInputError` &rarr; `ComposeWorkflowInputError`, `WorkflowErrorCode` &rarr; `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` &mdash; 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 &mdash; 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 &mdash; 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` &mdash; 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)` &mdash; 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)` &mdash; a two-phase API for consumers (e.g. GitLens's webview) that surface the plan for user review or editing before applying; `applyComposePlan` reverifies the workdir matches the plan's snapshot before mutating
85
+ - Adds `composePlan(input)` and `applyComposePlan(input)` &mdash; 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)` &mdash; partitions an existing multi-commit branch into a stack of smaller, reviewable branches
16
87
  - Adds `undoCompose(input)` and `validateUndoCompose(input)` &mdash; 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` &mdash; 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, cherry-pick, etc.) from `exec`.
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`. Every op takes an `OpOptions` with `signal?: AbortSignal`.
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 GitLens-style UIs use (plan → webview → user edits → apply).
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 `WorkflowError('SAFETY_CHECK_FAILED')`.
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` 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`. |
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: 'branch-replace' }` | Replace the source branch in-place. Only valid with a `branch` source. |
193
- | `{ type: 'auto', preserveOriginal? }` | Create one branch per concern, optionally subdividing into stacked sub-branches. Default for `branch` source. |
194
- | `{ 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')`. |
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 `WorkflowError('CANCELLED')`. Use for large-prompt confirmations, cost warnings, org policy checks.
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
- ### `signal`
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
- Standard `AbortSignal`. Threaded through every AI call, every git operation, and every hook. On abort, the workflow throws `WorkflowError('CANCELLED')`.
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. The `dirty_workdir` blocker is never force-able.
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 `signal` for the same behaviors as the top-level entry points.
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`, `CHERRY_PICK_CONFLICT` |
472
- | `WorkflowError` | `SAFETY_CHECK_FAILED` (workdir changed between plan and apply), `INTERNAL`, `CANCELLED` |
473
- | `WorkflowInputError` | Invalid workflow input (missing `model`, incompatible source/target combination, etc.) |
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, WorkflowError, AIError } from '@gitkraken/compose-tools';
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 WorkflowError && error.code === 'CANCELLED') {
577
+ } else if (error instanceof ComposeWorkflowError && error.code === 'CANCELLED') {
491
578
  console.log('Operation cancelled');
492
- } else if (error instanceof WorkflowError) {
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 WorkflowErrorCode = 'SAFETY_CHECK_FAILED' | 'INTERNAL' | 'CANCELLED';
3
- declare class WorkflowInputError extends Error {
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
- declare class WorkflowError extends Error {
7
- readonly code: WorkflowErrorCode;
8
- constructor(code: WorkflowErrorCode, message: string, options?: {
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 { WorkflowError, WorkflowErrorCode, WorkflowInputError };
23
+ export { ComposeWorkflowError, ComposeWorkflowErrorCode, ComposeWorkflowInputError };
@@ -1 +1 @@
1
- var e=class extends Error{constructor(e){super(e),this.name=`WorkflowInputError`}},t=class extends Error{code;constructor(e,t,n){super(t,n),this.name=`WorkflowError`,this.code=e}};export{t as WorkflowError,e as WorkflowInputError};
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,signal:l}=i;if(l?.throwIfAborted(),s&&c){let i=await n(a,{signal:l})===s;i&&await e(a,c,{detach:!0,signal:l}),await r(a,s,{force:!0,signal:l});for(let e of o)for(let n of e.partitions){l?.throwIfAborted();let e=n.commitIds[n.commitIds.length-1];await t(a,n.branchName,e,{signal:l})}if(i&&o.length>0){let t=o[o.length-1],n=t.partitions[t.partitions.length-1];await e(a,n.branchName,{signal:l})}}else for(let e of o)for(let n of e.partitions){l?.throwIfAborted();let e=n.commitIds[n.commitIds.length-1];await t(a,n.branchName,e,{signal:l})}}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,signal:e.signal})}export{i as applyBranchStructure,a as createStackedBranches};
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
@@ -1 +1 @@
1
- import{GitError as e}from"../errors/git.js";import{cleanWorkdir as t,discardWorkdirChanges as n,revParse as r}from"./port-dispatch.js";import{applyBranchStructure as i}from"./apply-branches.js";import{applyCommitChain as a,applyRangeRewrite as o,applyRecompose as s,buildCommitChain as c}from"./commit-chain.js";import{verifyMultiBranchDiff as l,verifyResultingDiff as u}from"./safety.js";async function d(t){let{git:n,target:l,branchPlans:d,allOrderedCommits:m,hunks:h,snapshot:v,collection:y,signal:b,signing:x,authorAttribution:S}=t;if(b?.throwIfAborted(),l.type===`rewrite-range`){if(!y.branchName||!y.branchSha)throw new e(`OPERATION_FAILED`,`rewrite-range target requires a source that records branchName and branchSha (branch or commit-range).`);let{headSha:t,shaMap:r}=await c({git:n,baseSha:l.rangeFrom,commits:m,hunks:h,signal:b,signing:x,authorAttribution:S}),i=await o({git:n,branch:l.branch,rangeFrom:l.rangeFrom,rangeTo:l.rangeTo,newHeadSha:t,originalBranchSha:y.branchSha,preserveSuffix:l.preserveSuffix,signal:b});return{commitShas:Object.fromEntries(r),mutations:{createdBranches:[],deletedBranches:[],movedBranches:[{name:l.branch,previousSha:y.branchSha,newSha:i.finalTipSha}]}}}if(y.branchName&&!y.mergeBaseSha)throw new e(`OPERATION_FAILED`,`Branch source is missing mergeBaseSha — cannot determine base commit`);let C=y.mergeBaseSha??await r(n,`HEAD`,{signal:b});if(l.type===`auto`&&d.length>1)return f(n,C,d,h,v,y,l.preserveOriginal,b,x,S);if(l.type===`auto`&&d.length===1&&d[0].partitioning)return p(n,C,d[0],h,v,y,l.preserveOriginal,b,x,S);let{headSha:w,shaMap:T}=await c({git:n,baseSha:C,commits:m,hunks:h,signal:b,signing:x,authorAttribution:S}),E=Object.fromEntries(T),D=await u(n,C,w,v,b);if(!D.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${D.error}`);if(l.type===`branch-replace`){if(!y.branchName||!y.branchSha)throw new e(`OPERATION_FAILED`,`branch-replace target requires a branch source with branchName and branchSha`);return await s({git:n,branchName:y.branchName,newHeadSha:w,originalBranchSha:y.branchSha,signal:b}),{commitShas:E,mutations:{createdBranches:[],deletedBranches:[],movedBranches:[{name:y.branchName,previousSha:y.branchSha,newSha:w}]}}}if(l.type===`branch`)return await a({git:n,newHeadSha:w,branchName:l.name,untrackedIncluded:y.untrackedIncluded,signal:b}),{commitShas:E,mutations:{createdBranches:[{name:l.name,sha:w}],deletedBranches:[],movedBranches:[]}};if(l.type===`auto`){let e=d[0]?.branchGroup.name||y.branchName;if(e){let t=!l.preserveOriginal&&y.branchName&&y.branchSha?{originalBranchName:y.branchName,originalBranchSha:y.branchSha}:{},r={branchName:e,title:d[0]?.branchGroup.title??``,description:d[0]?.branchGroup.description??``,commitIds:m.map(e=>e.id)};await i({git:n,branches:[{partitions:[r],tipSha:w}],...t,signal:b});let a=_([r],E,t.originalBranchName,t.originalBranchSha);return{partitions:[r],commitShas:E,mutations:a}}return await a({git:n,newHeadSha:w,untrackedIncluded:y.untrackedIncluded,signal:b}),{commitShas:E,mutations:g()}}return await a({git:n,newHeadSha:w,untrackedIncluded:y.untrackedIncluded,signal:b}),{commitShas:E,mutations:g()}}async function f(r,a,o,s,u,d,f,p,g,v){let y=[],b=[],x=[],S=[],C={};for(let e of o){p?.throwIfAborted();let t=m(e),{headSha:n,shaMap:i}=await c({git:r,baseSha:a,commits:t,hunks:s,signal:p,signing:g,authorAttribution:v});x.push(n);for(let[e,t]of i)C[e]=t;if(e.partitioning&&e.partitioning.partitions.length>1){let t=h(e.partitioning.partitions,i);y.push(...t),b.push(t),S.push({partitions:t,tipSha:n})}else{let r={branchName:e.branchGroup.name,title:e.branchGroup.title,description:e.branchGroup.description,commitIds:t.map(e=>i.get(e.id)??e.id)};y.push(r),b.push([r]),S.push({partitions:[r],tipSha:n})}}let w=await l(r,a,x,u,p);if(!w.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${w.error}`);let T=!f&&d.branchName&&d.branchSha?{originalBranchName:d.branchName,originalBranchSha:d.branchSha}:{};return await i({git:r,branches:S,...T,signal:p}),!d.branchName&&!f&&(await n(r,void 0,{signal:p}),d.untrackedIncluded&&await t(r,{signal:p})),{partitions:y,partitionGroups:b,commitShas:C,mutations:_(y,C,T.originalBranchName,T.originalBranchSha)}}async function p(r,o,s,l,d,f,p,v,y,b){let{headSha:x,shaMap:S}=await c({git:r,baseSha:o,commits:m(s),hunks:l,signal:v,signing:y,authorAttribution:b}),C=await u(r,o,x,d,v);if(!C.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${C.error}`);let w=h(s.partitioning.partitions,S),T=Object.fromEntries(S);if(w.length>1){let e=!p&&f.branchName&&f.branchSha?{originalBranchName:f.branchName,originalBranchSha:f.branchSha}:{};return await i({git:r,branches:[{partitions:w,tipSha:x}],...e,signal:v}),!f.branchName&&!p&&(await n(r,void 0,{signal:v}),f.untrackedIncluded&&await t(r,{force:!0,directories:!0,signal:v})),{partitions:w,commitShas:T,mutations:_(w,T,e.originalBranchName,e.originalBranchSha)}}return await a({git:r,newHeadSha:x,untrackedIncluded:f.untrackedIncluded,signal:v}),{partitions:w,commitShas:T,mutations:g()}}function m(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 h(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 g(){return{createdBranches:[],deletedBranches:[],movedBranches:[]}}function _(e,t,n,r){let i=e.map(e=>{let n=e.commitIds[e.commitIds.length-1];return{name:e.branchName,sha:t[n]??n}}),a=[];return n&&r&&a.push({name:n,sha:r}),{createdBranches:i,deletedBranches:a,movedBranches:[]}}export{d as applyToTarget};
1
+ import{GitError as e}from"../errors/git.js";import{applyPatchToWorkdir as t,checkout as n,cleanWorkdir as r,currentBranch as i,deleteBranch as a,discardWorkdirChanges as o,reset as s,revParse as c,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
- signal?: AbortSignal;
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
- declare function applyCommitChain(params: ApplyCommitChainParams): Promise<void>;
37
- interface ApplyRecomposeParams {
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 applyRecompose(params: ApplyRecomposeParams): Promise<void>;
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
- /** When true (default), cherry-pick commits in `rangeTo..originalBranchSha` onto `newHeadSha`. */
58
- preserveSuffix?: boolean;
59
- signal?: AbortSignal;
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 range rewrite + (optional) suffix cherry-picks. */
66
+ /** Final source-branch tip SHA after descendant re-parenting and any ref-at-tip moves. */
63
67
  finalTipSha: string;
64
- /** SHAs that were successfully cherry-picked, in order applied. Empty when preserveSuffix=false or no suffix. */
65
- preservedSuffixShas: string[];
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
- * Given that the library has already built a new commit chain on top of
71
- * `rangeFrom` (available at `newHeadSha`), this replays any commits that were
72
- * after `rangeTo` on the original branch onto the new tip via cherry-pick, so
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, ApplyRecomposeParams, AuthorAttributionStrategy, BuildCommitChainParams, BuildCommitChainResult, applyCommitChain, applyRangeRewrite, applyRecompose, buildCommitChain };
101
+ export { ApplyChainResult, ApplyCommitChainParams, ApplyRangeRewriteParams, ApplyRangeRewriteResult, AuthorAttributionStrategy, BuildCommitChainParams, BuildCommitChainResult, applyCommitChain, applyRangeRewrite, buildCommitChain, moveRefsAtSha };