@gitkraken/compose-tools 0.2.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 (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +38 -17
  3. package/dist/git/apply-target.d.ts +5 -9
  4. package/dist/git/apply-target.js +1 -1
  5. package/dist/git/commit-chain.d.ts +36 -42
  6. package/dist/git/commit-chain.js +7 -7
  7. package/dist/git/diff-collector.d.ts +40 -17
  8. package/dist/git/diff-collector.js +6 -7
  9. package/dist/git/index.d.ts +2 -2
  10. package/dist/git/index.js +1 -1
  11. package/dist/git/port-dispatch.d.ts +4 -2
  12. package/dist/git/port-dispatch.js +4 -3
  13. package/dist/git/reparent.d.ts +39 -0
  14. package/dist/git/reparent.js +5 -0
  15. package/dist/git/safety-range.d.ts +33 -0
  16. package/dist/git/safety-range.js +5 -0
  17. package/dist/git/types.d.ts +10 -14
  18. package/dist/git/utils/authorship.d.ts +22 -0
  19. package/dist/git/utils/authorship.js +1 -0
  20. package/dist/git/utils/hunk-parser.d.ts +1 -1
  21. package/dist/git/utils/hunk-parser.js +1 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +1 -1
  24. package/dist/ports/git.d.ts +22 -1
  25. package/dist/tasks/analyze/analyze.js +1 -1
  26. package/dist/tasks/analyze/prompt.d.ts +2 -0
  27. package/dist/tasks/analyze/prompt.js +5 -5
  28. package/dist/tasks/analyze/types.d.ts +3 -0
  29. package/dist/tasks/compose-group/compose-group.js +1 -1
  30. package/dist/tasks/compose-group/prompt.d.ts +2 -0
  31. package/dist/tasks/compose-group/prompt.js +29 -29
  32. package/dist/tasks/compose-group/types.d.ts +3 -0
  33. package/dist/tasks/group/group.js +1 -1
  34. package/dist/tasks/group/prompt.d.ts +2 -0
  35. package/dist/tasks/group/prompt.js +22 -22
  36. package/dist/tasks/group/types.d.ts +3 -0
  37. package/dist/tasks/quick-compose/prompt.d.ts +2 -0
  38. package/dist/tasks/quick-compose/prompt.js +11 -11
  39. package/dist/tasks/quick-compose/quick-compose.js +1 -1
  40. package/dist/tasks/quick-compose/types.d.ts +3 -0
  41. package/dist/tasks/types.d.ts +10 -9
  42. package/dist/tasks/utils/redact.d.ts +14 -0
  43. package/dist/tasks/utils/redact.js +1 -0
  44. package/dist/workflows/compose.d.ts +25 -14
  45. package/dist/workflows/compose.js +3 -2
  46. package/dist/workflows/split.d.ts +3 -0
  47. package/dist/workflows/split.js +2 -2
  48. package/dist/workflows/types.d.ts +41 -18
  49. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ 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
+
9
49
  ## [0.2.0] - 2026-05-11
10
50
 
11
51
  ### Added
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
 
@@ -183,17 +183,26 @@ See [Undo](#undo) below.
183
183
  |---|---|
184
184
  | `{ type: 'workdir', stagedOnly?, noUntracked? }` | Collects staged, unstaged, and untracked changes. `stagedOnly: true` limits to the index; `noUntracked: true` skips untracked files. |
185
185
  | `{ type: 'branch', name, mergeTarget? }` | Collects `mergeBase(mergeTarget, name) → name` as one combined diff. `mergeTarget` defaults to `'main'`. |
186
- | `{ type: 'commit-range', branch, from, to, includeWorkdir? }` | 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`. Pass `includeWorkdir: { includeStaged?, includeUnstaged?, includeUntracked? }` to combine the range with the user's working changes in a single composed input — useful for "rewrite recent commits and the WIP on top of them" flows. When set, `to` must equal the current branch tip; the library throws `ComposeWorkflowInputError` otherwise. |
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. |
187
187
 
188
188
  ## Targets
189
189
 
190
190
  | Target | Description |
191
191
  |---|---|
192
192
  | `{ type: 'head' }` | Commit onto the current branch. Default for `workdir` source. |
193
- | `{ type: 'branch', name, preserveOriginal? }` | Create a single named branch. `preserveOriginal: true` keeps the source branch. |
194
- | `{ type: 'branch-replace' }` | Replace the source branch in-place. Only valid with a `branch` source. |
195
- | `{ type: 'auto', preserveOriginal? }` | Create one branch per concern, optionally subdividing into stacked sub-branches. Default for `branch` source. |
196
- | `{ 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.
197
206
 
198
207
  ## Options
199
208
 
@@ -282,6 +291,19 @@ The filter must preserve the original `index` values on remaining hunks (only dr
282
291
 
283
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.
284
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
+
285
307
  ### `signing`
286
308
 
287
309
  GPG signing for produced commits. Typically derived from `git config commit.gpgsign` + `user.signingkey`.
@@ -319,19 +341,20 @@ const result = await compose({ git, model, cancellation: controller.signal });
319
341
 
320
342
  `applyComposePlan`, `undoCompose`, `validateUndoCompose`, and `listUndoIds` do **not** accept `cancellation` — they are git-only and run to completion.
321
343
 
322
- ### `applyUpTo`
344
+ ### `applyCommitIds`
323
345
 
324
- Apply only a prefix of the planned commits. Hunks not covered by the applied prefix become uncommitted leftover in the worktree (modified for tracked files, untracked for new files; the user's pre-apply staging on those hunks is preserved by the post-apply stash restore).
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).
325
347
 
326
348
  ```typescript
327
- applyUpTo: { kind: 'all' } // default — apply the entire plan
328
- applyUpTo: { kind: 'count', count: 2 } // first 2 in plan order
329
- applyUpTo: { kind: 'commitId', id: 'commit-3' } // up to and including commit-3
349
+ applyCommitIds: undefined // default — apply the entire plan
350
+ applyCommitIds: ['commit-1', 'commit-3'] // apply only these two commits, in plan order
330
351
  ```
331
352
 
332
- Incompatible with `target: 'auto'` — the library throws `ComposeWorkflowInputError` on that combination. Compatible with `head`, `branch`, `branch-replace`, and `rewrite-range`.
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.
333
356
 
334
- The result includes `appliedCommitIds` (the SHAs that were actually written, a prefix of `plan.allOrderedCommits.map(c => c.id)`) and `unappliedHunkIndices` (the hunk indices the partial apply left behind).
357
+ The result includes `appliedCommitIds` (the SHAs actually written) and `unappliedHunkIndices` (the hunk indices the partial apply left behind).
335
358
 
336
359
  ### `stashLabel`
337
360
 
@@ -436,7 +459,7 @@ Every top-level entry point returns an object with these common fields:
436
459
  | `commitShas` | apply paths | `{ commitId: sha }` mapping for every commit written |
437
460
  | `undoId` | apply paths | Pass to `undoCompose()` to reverse |
438
461
  | `partitions` / `partitionGroups` | apply paths with partitioning | Applied partition info |
439
- | `appliedCommitIds` | apply paths | Plan commit IDs actually written. Same as `plan.allOrderedCommits.map(c => c.id)` for full apply; a prefix when `applyUpTo` is set. |
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. |
440
463
  | `unappliedHunkIndices` | apply paths | Hunk indices from the plan not covered by `appliedCommitIds`. Empty for full apply. |
441
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. |
442
465
 
@@ -534,7 +557,7 @@ Each task accepts an optional `session` to maintain AI conversation context (or
534
557
 
535
558
  | Error | Codes |
536
559
  |---|---|
537
- | `GitError` | `NO_CHANGES`, `BRANCH_NOT_FOUND`, `OPERATION_FAILED`, `SAFETY_CHECK_FAILED`, `CHERRY_PICK_CONFLICT` |
560
+ | `GitError` | `NO_CHANGES`, `BRANCH_NOT_FOUND`, `OPERATION_FAILED`, `SAFETY_CHECK_FAILED` |
538
561
  | `ComposeWorkflowError` | `SAFETY_CHECK_FAILED` (workdir changed between plan and apply), `INTERNAL`, `CANCELLED` |
539
562
  | `ComposeWorkflowInputError` | Invalid workflow input (missing `model`, incompatible source/target combination, etc.) |
540
563
  | `TaskInputError` | Invalid task input |
@@ -549,8 +572,6 @@ try {
549
572
  } catch (error) {
550
573
  if (error instanceof GitError && error.code === 'NO_CHANGES') {
551
574
  console.log('Nothing to compose');
552
- } else if (error instanceof GitError && error.code === 'CHERRY_PICK_CONFLICT') {
553
- console.error(`Conflict cherry-picking ${error.detail?.sha}`);
554
575
  } else if (error instanceof AIError) {
555
576
  console.error('AI could not produce valid output after retries');
556
577
  } else if (error instanceof ComposeWorkflowError && error.code === 'CANCELLED') {
@@ -1,7 +1,7 @@
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";
4
+ import { ComposeSource, ComposeTarget } from "../workflows/types.js";
5
5
  import { ApplyMutations, SafetySnapshot, StashConflict } from "./types.js";
6
6
  import { CollectionResult } from "./diff-collector.js";
7
7
 
@@ -13,6 +13,7 @@ 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[];
@@ -26,15 +27,10 @@ interface ApplyToTargetParams {
26
27
  } | undefined);
27
28
  /** Stash message used by the apply helpers when stashing pending workdir state. */
28
29
  stashLabel?: string;
29
- /** Patch applied to the worktree after the chain is reset. Used to restore the unapplied
30
- * hunks of a partial apply so the user's content isn't lost when their leftover hunks lived in
31
- * commits being rewritten (commits-only / commit-range+workdir partial apply). */
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. */
32
33
  leftoverPatch?: string;
33
- /** Variant of {@link leftoverPatch} containing only branch-source hunks. Applied instead of
34
- * the full leftover patch when stashing happens — WIP-source leftover hunks are restored
35
- * by the stash pop's 3-way merge / index restore / untracked checkout, so writing them
36
- * to the worktree pre-pop causes "would be overwritten" or "already exists" errors. */
37
- leftoverPatchBranchOnly?: string;
38
34
  }
39
35
  interface ApplyToTargetResult {
40
36
  partitions?: BranchPartition[];
@@ -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,signing:b,authorAttribution:x,stashLabel:S,leftoverPatch:C,leftoverPatchBranchOnly:w}=t;if(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,signing:b,authorAttribution:x}),i=await o({git:n,branch:l.branch,rangeFrom:l.rangeFrom,rangeTo:l.rangeTo,newHeadSha:t,originalBranchSha:y.branchSha,preserveSuffix:l.preserveSuffix,stashLabel:S,leftoverPatch:C,leftoverPatchBranchOnly:w});return{commitShas:Object.fromEntries(r),mutations:{createdBranches:[],deletedBranches:[],movedBranches:[{name:l.branch,previousSha:y.branchSha,newSha:i.finalTipSha}]},stashConflict:i.stashConflict}}if(y.branchName&&!y.mergeBaseSha)throw new e(`OPERATION_FAILED`,`Branch source is missing mergeBaseSha — cannot determine base commit`);let T=y.mergeBaseSha??await r(n,`HEAD`);if(l.type===`auto`&&d.length>1)return f(n,T,d,h,v,y,l.preserveOriginal,b,x);if(l.type===`auto`&&d.length===1&&d[0].partitioning)return p(n,T,d[0],h,v,y,l.preserveOriginal,b,x);let{headSha:E,shaMap:D}=await c({git:n,baseSha:T,commits:m,hunks:h,signing:b,authorAttribution:x}),O=Object.fromEntries(D),k=await u(n,T,E,v);if(!k.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${k.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`);let t=await s({git:n,branchName:y.branchName,newHeadSha:E,originalBranchSha:y.branchSha,stashLabel:S,leftoverPatch:C,leftoverPatchBranchOnly:w});return{commitShas:O,mutations:{createdBranches:[],deletedBranches:[],movedBranches:[{name:y.branchName,previousSha:y.branchSha,newSha:E}]},stashConflict:t.stashConflict}}if(l.type===`branch`){let e=await a({git:n,newHeadSha:E,branchName:l.name,untrackedIncluded:y.untrackedIncluded,stashLabel:S,leftoverPatch:C,leftoverPatchBranchOnly:w});return{commitShas:O,mutations:{createdBranches:[{name:l.name,sha:E}],deletedBranches:[],movedBranches:[]},stashConflict:e.stashConflict}}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:E}],...t});let a=_([r],O,t.originalBranchName,t.originalBranchSha);return{partitions:[r],commitShas:O,mutations:a}}let t=await a({git:n,newHeadSha:E,untrackedIncluded:y.untrackedIncluded,stashLabel:S});return{commitShas:O,mutations:g(),stashConflict:t.stashConflict}}let A=await a({git:n,newHeadSha:E,untrackedIncluded:y.untrackedIncluded,stashLabel:S,leftoverPatch:C,leftoverPatchBranchOnly:w});return{commitShas:O,mutations:g(),stashConflict:A.stashConflict}}async function f(r,a,o,s,u,d,f,p,g){let v=[],y=[],b=[],x=[],S={};for(let e of o){let t=m(e),{headSha:n,shaMap:i}=await c({git:r,baseSha:a,commits:t,hunks:s,signing:p,authorAttribution:g});b.push(n);for(let[e,t]of i)S[e]=t;if(e.partitioning&&e.partitioning.partitions.length>1){let t=h(e.partitioning.partitions,i);v.push(...t),y.push(t),x.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)};v.push(r),y.push([r]),x.push({partitions:[r],tipSha:n})}}let C=await l(r,a,b,u);if(!C.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${C.error}`);let w=!f&&d.branchName&&d.branchSha?{originalBranchName:d.branchName,originalBranchSha:d.branchSha}:{};return await i({git:r,branches:x,...w}),!d.branchName&&!f&&(await n(r),d.untrackedIncluded&&await t(r)),{partitions:v,partitionGroups:y,commitShas:S,mutations:_(v,S,w.originalBranchName,w.originalBranchSha)}}async function p(r,o,s,l,d,f,p,v,y){let{headSha:b,shaMap:x}=await c({git:r,baseSha:o,commits:m(s),hunks:l,signing:v,authorAttribution:y}),S=await u(r,o,b,d);if(!S.valid)throw new e(`SAFETY_CHECK_FAILED`,`Diff verification failed: ${S.error}`);let C=h(s.partitioning.partitions,x),w=Object.fromEntries(x);if(C.length>1){let e=!p&&f.branchName&&f.branchSha?{originalBranchName:f.branchName,originalBranchSha:f.branchSha}:{};return await i({git:r,branches:[{partitions:C,tipSha:b}],...e}),!f.branchName&&!p&&(await n(r),f.untrackedIncluded&&await t(r,{force:!0,directories:!0})),{partitions:C,commitShas:w,mutations:_(C,w,e.originalBranchName,e.originalBranchSha)}}return await a({git:r,newHeadSha:b,untrackedIncluded:f.untrackedIncluded}),{partitions:C,commitShas:w,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};
@@ -34,30 +34,15 @@ interface ApplyCommitChainParams {
34
34
  /** Stash message used when a backup stash is needed. */
35
35
  stashLabel?: string;
36
36
  /** Patch applied to the worktree (no staging) after the chain is reset and before the stash is popped.
37
- * Used when no stash backup is taken (workdir was clean pre-apply). */
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. */
38
40
  leftoverPatch?: string;
39
- /** Branch-source-only variant of {@link leftoverPatch}. Used when a stash IS taken: WIP-source
40
- * leftover (modified/untracked files the user had pre-apply) is restored by the pop's 3-way
41
- * merge, index restore, and untracked checkout. Writing those hunks pre-pop causes git to
42
- * reject the pop ("would be overwritten" / "already exists"). Branch-source leftover is the
43
- * only category the stash has no record of, so it must still go through the patch path. */
44
- leftoverPatchBranchOnly?: string;
45
41
  }
46
42
  interface ApplyChainResult {
47
43
  stashConflict?: StashConflict;
48
44
  }
49
45
  declare function applyCommitChain(params: ApplyCommitChainParams): Promise<ApplyChainResult>;
50
- interface ApplyRecomposeParams {
51
- git: ComposeGitPort;
52
- branchName: string;
53
- newHeadSha: string;
54
- originalBranchSha: string;
55
- stashLabel?: string;
56
- leftoverPatch?: string;
57
- /** See {@link ApplyCommitChainParams.leftoverPatchBranchOnly}. */
58
- leftoverPatchBranchOnly?: string;
59
- }
60
- declare function applyRecompose(params: ApplyRecomposeParams): Promise<ApplyChainResult>;
61
46
  interface ApplyRangeRewriteParams {
62
47
  git: ComposeGitPort;
63
48
  /** Branch whose tip will be updated to the result. */
@@ -70,38 +55,47 @@ interface ApplyRangeRewriteParams {
70
55
  newHeadSha: string;
71
56
  /** Resolved SHA: branch tip before the operation started. */
72
57
  originalBranchSha: string;
73
- /** When true (default), cherry-pick commits in `rangeTo..originalBranchSha` onto `newHeadSha`. */
74
- preserveSuffix?: boolean;
75
58
  stashLabel?: string;
76
59
  leftoverPatch?: string;
77
- /** See {@link ApplyCommitChainParams.leftoverPatchBranchOnly}. */
78
- leftoverPatchBranchOnly?: 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;
79
64
  }
80
65
  interface ApplyRangeRewriteResult {
81
- /** 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. */
82
67
  finalTipSha: string;
83
- /** SHAs that were successfully cherry-picked, in order applied. Empty when preserveSuffix=false or no suffix. */
84
- 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
+ }[];
85
80
  stashConflict?: StashConflict;
86
81
  }
87
82
  /**
88
- * Rewrite a contiguous range of commits on a branch in-place.
89
- *
90
- * Given that the library has already built a new commit chain on top of
91
- * `rangeFrom` (available at `newHeadSha`), this replays any commits that were
92
- * after `rangeTo` on the original branch onto the new tip via cherry-pick, so
93
- * work done later on the branch is preserved.
94
- *
95
- * Behavior:
96
- * 1. Stash the user's workdir if dirty (restored in the caller via finally).
97
- * 2. If `preserveSuffix` (default true) and the range didn't reach the branch
98
- * tip, cherry-pick every commit in `rangeTo..originalBranchSha` onto
99
- * `newHeadSha`. On conflict, abort the cherry-pick and throw
100
- * `GitError('CHERRY_PICK_CONFLICT', …)` — repo state is left safe.
101
- * 3. `update-ref` the branch to the final tip.
102
- * 4. If the user was on the branch, `reset --hard` to the final tip so their
103
- * 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.
104
88
  */
105
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
+ }[]>;
106
100
  //#endregion
107
- export { ApplyChainResult, ApplyCommitChainParams, ApplyRangeRewriteParams, ApplyRangeRewriteResult, ApplyRecomposeParams, AuthorAttributionStrategy, BuildCommitChainParams, BuildCommitChainResult, applyCommitChain, applyRangeRewrite, applyRecompose, buildCommitChain };
101
+ export { ApplyChainResult, ApplyCommitChainParams, ApplyRangeRewriteParams, ApplyRangeRewriteResult, AuthorAttributionStrategy, BuildCommitChainParams, BuildCommitChainResult, applyCommitChain, applyRangeRewrite, buildCommitChain, moveRefsAtSha };
@@ -1,8 +1,8 @@
1
- import{GitError as e}from"../errors/git.js";import{applyPatchToIndex as t,applyPatchToWorkdir as n,checkout as r,cherryPick as i,cherryPickAbort as a,commitTree as o,currentBranch as s,listUntracked as c,localeNeutralEnv as l,log as u,readTree as d,reset as f,revParse as p,stageAll as m,stashDrop as h,stashPop as g,stashPush as _,statusPorcelain as v,updateRef as y,writeTree as b}from"./port-dispatch.js";import{buildPatchForCommit as x}from"./utils/patch-builder.js";import{randomUUID as S}from"node:crypto";import*as C from"node:fs";import*as w from"node:os";import*as T from"node:path";async function E(n){let{git:r,baseSha:i,commits:a,hunks:s,signing:c,authorAttribution:l=`plurality`}=n,u=T.join(w.tmpdir(),`compose-idx-${S()}`),f=i,p=new Map,m=new Set;try{for(let n of a){let i=n.hunkIndices.map(t=>{let n=s.find(e=>e.index===t);if(!n)throw new e(`OPERATION_FAILED`,`Hunk index ${t} not found`);return n}),a=x(i.map(e=>m.has(e.diffHeader)?{...e,diffHeader:H(e.diffHeader,e.fileName)}:e));if(!a.trim())throw new e(`OPERATION_FAILED`,`Empty patch for commit: ${n.message}`);let h={GIT_INDEX_FILE:u};await d(r,f,{indexEnv:h});try{await t(r,a,{indexEnv:h})}catch(t){let r=t instanceof Error?t.message:String(t);throw new e(`OPERATION_FAILED`,`git apply failed for commit '${n.message.split(`
2
- `)[0]}': ${r}`,{cause:t})}for(let e of i)e.originalFileName&&e.diffHeader.includes(`rename from`)&&m.add(e.diffHeader);let g=await b(r,{indexEnv:h}),_=R(i,l),v=B(n.message,_.coAuthors),y=await o(r,g,[f],v,{signing:c,author:_.author});p.set(n.id,y),f=y}return{headSha:f,shaMap:p}}finally{V(u)}}async function D(e){let{git:t,newHeadSha:i,branchName:a,untrackedIncluded:o,stashLabel:s,leftoverPatch:c,leftoverPatchBranchOnly:l}=e;o&&await O(t);let u=s??j,d=await M(t,u),p=d==null?c:l;try{a?await r(t,a,{createBranch:!0,startPoint:i}):await f(t,i,`hard`),p&&await n(t,p)}catch(e){throw d!=null&&await P(t,d,u),e}if(d!=null){let e=await P(t,d,u);if(e.conflicted)return{stashConflict:{stashLabel:u,conflictedFiles:e.conflictedFiles}}}return{}}async function O(e){let t=await c(e);t.length>0&&await m(e,{paths:t})}async function k(e){let{git:t,branchName:r,newHeadSha:i,stashLabel:a,leftoverPatch:o,leftoverPatchBranchOnly:c}=e,l=await s(t)===r,u=a??j,d=l?await M(t,u):null,p;try{if(await y(t,`refs/heads/${r}`,i),l){let e=d==null?o:c;await f(t,i,`hard`),e&&await n(t,e)}}catch(e){throw d!=null&&await P(t,d,u),e}if(d!=null){let e=await P(t,d,u);e.conflicted&&(p={stashLabel:u,conflictedFiles:e.conflictedFiles})}return{stashConflict:p}}async function A(t){let{git:o,branch:c,rangeTo:l,newHeadSha:d,originalBranchSha:m,preserveSuffix:h=!0,stashLabel:g,leftoverPatch:_,leftoverPatchBranchOnly:v}=t,b=await s(o)===c,x=[],S=d,C=g??j,w=[];h&&l!==m&&(w=(await u(o,{format:`%H`,range:`${l}..${m}`,reverse:!0})).trim().split(`
3
- `).filter(Boolean));let T=b||w.length>0?await M(o,C):null,E;try{if(w.length>0){await r(o,d,{detach:!0});for(let t of w)try{await i(o,t),x.push(t)}catch(n){throw await a(o).catch(()=>{}),new e(`CHERRY_PICK_CONFLICT`,`Could not preserve commit ${t.slice(0,8)} after rewriting the range. Cherry-pick aborted; no changes were made to the branch.`,{cause:n,detail:{conflictingCommit:t,successfullyCherryPicked:[...x]}})}S=await p(o,`HEAD`)}if(await y(o,`refs/heads/${c}`,S),b){let e=T==null?_:v;await r(o,c),await f(o,S,`hard`),e&&await n(o,e)}}catch(e){throw T!=null&&await P(o,T,C),e}if(T!=null){let e=await P(o,T,C);e.conflicted&&(E={stashLabel:C,conflictedFiles:e.conflictedFiles})}return{finalTipSha:S,preservedSuffixShas:x,stashConflict:E}}const j=`compose backup`;async function M(e,t){if(!(await v(e)).trim())return null;await _(e,{message:t,includeUntracked:!0});try{let t=(await p(e,`stash@{0}`)).trim();return t.length>0?t:null}catch{return null}}async function N(e,t){if(!e.exec)return null;let n;try{n=await e.exec([`stash`,`list`,`--format=%H %gd`],{env:l})}catch{return null}for(let e of n.split(`
4
- `)){let n=e.indexOf(` `);if(!(n<0)&&e.slice(0,n)===t){let t=e.slice(n+1).trim();return t.length>0?t:null}}return null}async function P(e,t,n){let r=await N(e,t);if(r==null)return{conflicted:!0,conflictedFiles:[]};let i;try{return await g(e,{withIndex:!0,ref:r}),{conflicted:!1,conflictedFiles:[]}}catch(e){i=e}let a=await L(e);if(a.length>0)return{conflicted:!0,conflictedFiles:a};if(F(i)){let n=await N(e,t);if(n!=null)try{await h(e,n)}catch{}return{conflicted:!1,conflictedFiles:[]}}return{conflicted:!0,conflictedFiles:[]}}function F(e){let t=I(e);return t.includes(`already exists, no checkout`)||t.includes(`could not restore untracked files from stash`)}function I(e){if(e==null)return``;if(e instanceof Error){let t=e.stderr;return t!=null&&t.length>0?`${e.message}\n${t}`:e.message}return String(e)}async function L(e){try{let t=await v(e),n=[];for(let e of t.split(`
5
- `))/^(UU|AA|DD|AU|UA|UD|DU) /.test(e)&&n.push(e.slice(3).trim());return n}catch{return[]}}function R(e,t){let n=e.filter(e=>e.author!=null);if(n.length===0)return{author:void 0,coAuthors:[]};let r;if(typeof t==`function`)r=t(e);else if(t===`first-hunk`)r=n[0].author;else{let e=new Map;for(let t of n){let n=z(t.author),r=e.get(n);r?r.count++:e.set(n,{identity:t.author,count:1})}let t=0;for(let{identity:n,count:i}of e.values())i>t&&(r=n,t=i)}let i=new Map;for(let t of e){t.author&&i.set(z(t.author),t.author);for(let e of t.coAuthors??[])i.set(z(e),e)}let a=r?z(r):null,o=[];for(let[e,t]of i)e!==a&&o.push(t);return{author:r,coAuthors:o}}function z(e){return`${e.name.toLowerCase()}\0${e.email.toLowerCase()}`}function B(e,t){if(t.length===0)return e;let n=t.map(e=>`Co-authored-by: ${e.name} <${e.email}>`).join(`
6
- `);return`${e.trimEnd()}\n\n${n}\n`}function V(e){try{C.unlinkSync(e)}catch{}}function H(e,t){let n=e.split(`
1
+ import{GitError as e}from"../errors/git.js";import{applyPatchToIndex as t,applyPatchToWorkdir as n,checkout as r,commitTree as i,currentBranch as a,forEachRef as o,listUntracked as s,localeNeutralEnv as c,readTree as l,reset as u,revParse as d,stageAll as f,stashDrop as p,stashPop as m,stashPush as h,statusPorcelain as g,updateRef as _,writeTree as v}from"./port-dispatch.js";import{reparentDescendants as y}from"./reparent.js";import{buildPatchForCommit as b}from"./utils/patch-builder.js";import{randomUUID as x}from"node:crypto";import*as S from"node:fs";import*as C from"node:os";import*as w from"node:path";async function T(n){let{git:r,baseSha:a,commits:o,hunks:s,signing:c,authorAttribution:u=`plurality`}=n,d=w.join(C.tmpdir(),`compose-idx-${x()}`),f=a,p=new Map,m=new Set;try{for(let n of o){let a=n.hunkIndices.map(t=>{let n=s.find(e=>e.index===t);if(!n)throw new e(`OPERATION_FAILED`,`Hunk index ${t} not found`);return n}),o=b(a.map(e=>m.has(e.diffHeader)?{...e,diffHeader:V(e.diffHeader,e.fileName)}:e));if(!o.trim())throw new e(`OPERATION_FAILED`,`Empty patch for commit: ${n.message}`);let h={GIT_INDEX_FILE:d};await l(r,f,{indexEnv:h});try{await t(r,o,{indexEnv:h})}catch(t){let r=t instanceof Error?t.message:String(t);throw new e(`OPERATION_FAILED`,`git apply failed for commit '${n.message.split(`
2
+ `)[0]}': ${r}`,{cause:t})}for(let e of a)e.originalFileName&&e.diffHeader.includes(`rename from`)&&m.add(e.diffHeader);let g=await v(r,{indexEnv:h}),_=L(a,u),y=z(n.message,_.coAuthors),x=await i(r,g,[f],y,{signing:c,author:_.author});p.set(n.id,x),f=x}return{headSha:f,shaMap:p}}finally{B(d)}}async function E(e){let{git:t,newHeadSha:i,branchName:a,untrackedIncluded:o,stashLabel:s,leftoverPatch:c}=e;o&&await D(t);let l=s??A,d=await j(t,l);try{a?await r(t,a,{createBranch:!0,startPoint:i}):await u(t,i,`hard`),c&&await n(t,c)}catch(e){throw d!=null&&await N(t,d,l),e}if(d!=null){let e=await N(t,d,l);if(e.conflicted)return{stashConflict:{stashLabel:l,conflictedFiles:e.conflictedFiles}}}return{}}async function D(e){let t=await s(e);t.length>0&&await f(e,{paths:t})}async function O(e){let{git:t,branch:i,rangeTo:o,newHeadSha:s,originalBranchSha:c,stashLabel:l,leftoverPatch:d,includeWorkdirActive:f}=e,p=await a(t)===i,m=l??A,h=p&&!f?await j(t,m):null,g,v=s,b=[];try{let e=await y({git:t,oldToSha:o,newTopSha:s,sourceBranchName:i});for(let t of e.reparentedRefs)b.push({refname:t.refname,previousSha:t.oldSha,newSha:t.newSha});e.sourceBranchNewSha==null?(await _(t,`refs/heads/${i}`,s),b.push({refname:`refs/heads/${i}`,previousSha:c,newSha:s}),v=s):v=e.sourceBranchNewSha;let a=await k(t,o,s,`refs/heads/${i}`);b.push(...a),p&&(await r(t,i),await u(t,v,`hard`),h==null&&d&&await n(t,d))}catch(e){throw h!=null&&await N(t,h,m),e}if(h!=null){let e=await N(t,h,m);e.conflicted&&(g={stashLabel:m,conflictedFiles:e.conflictedFiles})}return{finalTipSha:v,refMovements:b,stashConflict:g}}async function k(e,t,n,r){let i=await o(e,{format:`%(refname) %(objectname)`,pointsAt:t}),a=[];for(let o of i.split(`
3
+ `)){let i=o.trim();if(!i)continue;let s=i.indexOf(` `);if(s<0)continue;let c=i.slice(0,s),l=i.slice(s+1).trim();c!==r&&(!c.startsWith(`refs/heads/`)&&!c.startsWith(`refs/tags/`)||l===t&&(await _(e,c,n),a.push({refname:c,previousSha:t,newSha:n})))}return a}const A=`compose backup`;async function j(e,t){if(!(await g(e)).trim())return null;await h(e,{message:t,includeUntracked:!0});try{let t=(await d(e,`stash@{0}`)).trim();return t.length>0?t:null}catch{return null}}async function M(e,t){if(!e.exec)return null;let n;try{n=await e.exec([`stash`,`list`,`--format=%H %gd`],{env:c})}catch{return null}for(let e of n.split(`
4
+ `)){let n=e.indexOf(` `);if(!(n<0)&&e.slice(0,n)===t){let t=e.slice(n+1).trim();return t.length>0?t:null}}return null}async function N(e,t,n){let r=await M(e,t);if(r==null)return{conflicted:!0,conflictedFiles:[]};let i;try{return await m(e,{withIndex:!0,ref:r}),{conflicted:!1,conflictedFiles:[]}}catch(e){i=e}let a=await I(e);if(a.length>0)return{conflicted:!0,conflictedFiles:a};if(P(i)){let n=await M(e,t);if(n!=null)try{await p(e,n)}catch{}return{conflicted:!1,conflictedFiles:[]}}return{conflicted:!0,conflictedFiles:[]}}function P(e){let t=F(e);return t.includes(`already exists, no checkout`)||t.includes(`could not restore untracked files from stash`)}function F(e){if(e==null)return``;if(e instanceof Error){let t=e.stderr;return t!=null&&t.length>0?`${e.message}\n${t}`:e.message}return String(e)}async function I(e){try{let t=await g(e),n=[];for(let e of t.split(`
5
+ `))/^(UU|AA|DD|AU|UA|UD|DU) /.test(e)&&n.push(e.slice(3).trim());return n}catch{return[]}}function L(e,t){let n=e.filter(e=>e.author!=null);if(n.length===0)return{author:void 0,coAuthors:[]};let r;if(typeof t==`function`)r=t(e);else if(t===`first-hunk`)r=n[0].author;else{let e=new Map;for(let t of n){let n=R(t.author),r=e.get(n);r?r.count++:e.set(n,{identity:t.author,count:1})}let t=0;for(let{identity:n,count:i}of e.values())i>t&&(r=n,t=i)}let i=new Map;for(let t of e){t.author&&i.set(R(t.author),t.author);for(let e of t.coAuthors??[])i.set(R(e),e)}let a=r?R(r):null,o=[];for(let[e,t]of i)e!==a&&o.push(t);return{author:r,coAuthors:o}}function R(e){return`${e.name.toLowerCase()}\0${e.email.toLowerCase()}`}function z(e,t){if(t.length===0)return e;let n=t.map(e=>`Co-authored-by: ${e.name} <${e.email}>`).join(`
6
+ `);return`${e.trimEnd()}\n\n${n}\n`}function B(e){try{S.unlinkSync(e)}catch{}}function V(e,t){let n=e.split(`
7
7
  `),r=[];for(let e of n)e.startsWith(`diff --git `)?r.push(`diff --git a/${t} b/${t}`):e.startsWith(`similarity index`)||e.startsWith(`rename from`)||e.startsWith(`rename to`)||(e.startsWith(`--- a/`)?r.push(`--- a/${t}`):r.push(e));return r.join(`
8
- `)}export{D as applyCommitChain,A as applyRangeRewrite,k as applyRecompose,E as buildCommitChain};
8
+ `)}export{E as applyCommitChain,O as applyRangeRewrite,T as buildCommitChain,k as moveRefsAtSha};
@@ -33,6 +33,11 @@ interface CollectionResult {
33
33
  branchName?: string;
34
34
  baseBranch?: string;
35
35
  branchSha?: string;
36
+ /**
37
+ * Base SHA for the recomposed chain — `mergeBase(mergeTarget, branch)` for `branch`
38
+ * sources, `parent(from)` for `commit-range` sources. Named `mergeBaseSha` for
39
+ * historical reasons but really means "parent of the first rewritten commit".
40
+ */
36
41
  mergeBaseSha?: string;
37
42
  untrackedIncluded: boolean;
38
43
  }
@@ -42,29 +47,49 @@ interface WorkingDiffOptions {
42
47
  includeUntracked: boolean;
43
48
  }
44
49
  declare function collectWorkingDiffs(git: ComposeGitPort, options: WorkingDiffOptions): Promise<WorkingDiffResult>;
50
+ /**
51
+ * Branch source — thin adapter over {@link collectCombinedDiffRange}. The merge-base with
52
+ * `baseBranch` plays the role of `parent(from)`.
53
+ */
45
54
  declare function collectBranchDiffs(git: ComposeGitPort, branchName: string, baseBranch: string): Promise<BranchDiffResult>;
46
55
  /**
47
- * Collect hunks from a contiguous range of commits on a branch, tagging each
48
- * hunk with the author of the commit that introduced it.
56
+ * Collect hunks from a contiguous range of commits on a branch via a single
57
+ * combined diff. Both `from` and `to` are **inclusive** — they name the first
58
+ * and last commits being rewritten. For a single-commit recompose:
59
+ * `from = sha, to = sha`. The library computes `parent(from)` internally and
60
+ * delegates to {@link collectCombinedDiffRange}.
49
61
  *
50
- * `from` is exclusive, `to` is inclusive — so single-commit recompose is
51
- * `from = parent(sha), to = sha`. The returned `combinedDiff` is the
52
- * tree-to-tree diff `from..to`; per-commit patches are re-parsed to get author
53
- * attribution, then the hunks from those per-commit parses are what's returned
54
- * (ordered by commit, then by file/hunk within each commit).
62
+ * The returned hunks come from `diffTree(parent(from), to)`, so every hunk
63
+ * shares one pre-image (the tree at `parent(from)`) and the AI can freely
64
+ * reorder/split them across new commits. Per-hunk authorship is attributed
65
+ * via a line-range-overlap heuristic against per-commit hunks in the range.
55
66
  *
56
- * The snapshot records `branchSha = to` and `baseSha = from` so the apply
57
- * phase can re-verify the range state hasn't drifted.
67
+ * The snapshot records `branchSha = to` and `baseSha = parent(from)` so the
68
+ * apply phase can re-verify the range hasn't drifted.
58
69
  */
59
70
  declare function collectCommitRangeDiffs(git: ComposeGitPort, branchName: string, from: string, to: string): Promise<BranchDiffResult>;
71
+ /**
72
+ * Shared collection path for `commit-range` and `branch` sources. Returns the combined diff
73
+ * `diffTree(parentOfFromSha, toSha)` parsed into hunks, with per-hunk authorship attributed
74
+ * via a line-range-overlap heuristic against a per-commit side-table.
75
+ *
76
+ * Merge commits walk their first-parent diff. The incoming content from the other parent is
77
+ * already in `parentOfFromSha`'s tree (for `branch` sources) and won't match the heuristic;
78
+ * conflict-resolution edits at the merge DO appear in the combined diff and correctly
79
+ * attribute to the merge committer.
80
+ */
81
+ declare function collectCombinedDiffRange(git: ComposeGitPort, parentOfFromSha: string, toSha: string): Promise<BranchDiffResult>;
60
82
  /**
61
83
  * Collect a commit-range plus selected portions of the working directory as one combined input.
62
84
  *
63
- * Range hunks come first (with per-commit author info preserved), then workdir hunks in stable
64
- * order. The safety snapshot covers only the requested portions, so unstaged drift cannot block
65
- * an apply that didn't include unstaged.
85
+ * All hunks come from a single combined diff `diffTree(parent(from), finalTree)`, where
86
+ * `finalTree` is HEAD's tree with the requested workdir portions applied. The safety
87
+ * snapshot hashes that same combined diff — the bytes the AI sees match the bytes the
88
+ * apply phase verifies against.
66
89
  *
67
- * Requires `to` to equal both the branch tip and HEAD — the workdir basis is `to`.
90
+ * Requires `to` to equal both the branch tip and HEAD, and HEAD to be non-detached.
91
+ * Mixing WIP with a range only makes sense when the user is actively on the branch tip
92
+ * being recomposed.
68
93
  */
69
94
  declare function collectCommitRangeWithWorkdir(git: ComposeGitPort, branchName: string, from: string, to: string, options: {
70
95
  includeStaged: boolean;
@@ -72,8 +97,7 @@ declare function collectCommitRangeWithWorkdir(git: ComposeGitPort, branchName:
72
97
  includeUntracked: boolean;
73
98
  }): Promise<BranchDiffResult>;
74
99
  /**
75
- * Build a tree representing HEAD plus the requested workdir portions, and return the
76
- * workdir-only hunks (relative to HEAD) parsed from the same tree-to-tree diff.
100
+ * Build a tree representing HEAD plus the requested workdir portions, and return its SHA.
77
101
  *
78
102
  * Each portion is staged into a temp index in dependency order: staged first (HEAD→index),
79
103
  * then unstaged (index→worktree-tracked), then untracked (intent-to-add + content).
@@ -83,7 +107,6 @@ declare function collectWorkdirPortionForRange(git: ComposeGitPort, headSha: str
83
107
  includeUnstaged: boolean;
84
108
  includeUntracked: boolean;
85
109
  }): Promise<{
86
- workdirHunks: ComposeHunk[];
87
110
  finalTreeSha: string;
88
111
  }>;
89
112
  declare function collectCommitsForSplit(git: ComposeGitPort, branchName: string, baseBranch: string): Promise<SplitCollectionResult>;
@@ -104,4 +127,4 @@ declare function collectSourceHashes(git: ComposeGitPort, options: {
104
127
  includeUntracked: boolean;
105
128
  }): Promise<SafetySnapshot['sourceHashes']>;
106
129
  //#endregion
107
- export { BranchCommitInfo, BranchDiffResult, CollectionResult, SplitCollectionResult, WorkingDiffOptions, WorkingDiffResult, collectBranchDiffs, collectCommitRangeDiffs, collectCommitRangeWithWorkdir, collectCommitsForSplit, collectFromSource, collectSourceHashes, collectWorkdirPortionForRange, collectWorkingDiffs };
130
+ export { BranchCommitInfo, BranchDiffResult, CollectionResult, SplitCollectionResult, WorkingDiffOptions, WorkingDiffResult, collectBranchDiffs, collectCombinedDiffRange, collectCommitRangeDiffs, collectCommitRangeWithWorkdir, collectCommitsForSplit, collectFromSource, collectSourceHashes, collectWorkdirPortionForRange, collectWorkingDiffs };
@@ -1,8 +1,7 @@
1
- import{GitError as e}from"../errors/git.js";import{ComposeWorkflowInputError as t}from"../errors/workflow.js";import{applyPatchToIndex as n,createBranch as r,diffTree as i,gitDir as a,listUntracked as o,log as s,mergeBase as c,readTree as l,revParse as u,revParseVerify as d,stageAll as f,writeTree as p}from"./port-dispatch.js";import{sha256 as m}from"./utils/crypto.js";import{parseUnifiedDiff as h}from"./utils/hunk-parser.js";import{withUntrackedInTempIndex as g}from"./utils/temp-index.js";import{randomUUID as _}from"node:crypto";import{copyFileSync as v,unlinkSync as y}from"node:fs";import{tmpdir as b}from"node:os";import{join as x}from"node:path";async function S(e,t){if(t.type===`workdir`){let n=!t.stagedOnly&&!t.noUntracked;return{...await C(e,{includeUnstaged:!t.stagedOnly,includeUntracked:n}),untrackedIncluded:n}}if(t.type===`commit-range`){if(t.includeWorkdir){let n=t.includeWorkdir.includeStaged??!0,r=t.includeWorkdir.includeUnstaged??!0,i=t.includeWorkdir.includeUntracked??!0,a=await j(e,t.branch,t.from,t.to,{includeStaged:n,includeUnstaged:r,includeUntracked:i});return{hunks:a.hunks,snapshot:a.snapshot,combinedDiff:a.combinedDiff,commitMessages:a.commits.map(e=>e.message),commitOrder:a.commits.map(e=>e.sha),branchName:t.branch,baseBranch:void 0,branchSha:a.snapshot.branchSha,mergeBaseSha:a.snapshot.baseSha,untrackedIncluded:i}}let n=await k(e,t.branch,t.from,t.to);return{hunks:n.hunks,snapshot:n.snapshot,combinedDiff:n.combinedDiff,commitMessages:n.commits.map(e=>e.message),commitOrder:n.commits.map(e=>e.sha),branchName:t.branch,baseBranch:void 0,branchSha:n.snapshot.branchSha,mergeBaseSha:n.snapshot.baseSha,untrackedIncluded:!1}}let n=t.mergeTarget??`main`,r=await O(e,t.name,n);return{hunks:r.hunks,snapshot:r.snapshot,combinedDiff:r.combinedDiff,commitMessages:r.commits.map(e=>e.message),commitOrder:r.commits.map(e=>e.sha),branchName:t.name,baseBranch:n,branchSha:r.snapshot.branchSha,mergeBaseSha:r.snapshot.baseSha,untrackedIncluded:!1}}async function C(e,t){let n=t.includeUnstaged||t.includeUntracked;return t.includeUntracked?E(e,n):w(e,n)}async function w(e,t){let n=await u(e,`HEAD`),r=await T(e,n,t);return{hunks:h(r,`staged`),snapshot:F(r,n,void 0,void 0,await I(e,{includeUnstaged:t,includeUntracked:!1})),combinedDiff:r}}async function T(e,t,n){if(!n)return i(e,t,await p(e));let r=x(await a(e),`index`),o=x(b(),`compose-snapshot-idx-${_()}`);try{v(r,o);let n={GIT_INDEX_FILE:o};return await f(e,{trackedOnly:!0,indexEnv:n}),i(e,t,await p(e,{indexEnv:n}))}finally{try{y(o)}catch{}}}async function E(e,t){return g(e,async n=>{await D(e,n,t);let r=await p(e,{indexEnv:n}),a=await u(e,`HEAD`),o=await i(e,a,r);return{hunks:h(o,`staged`),snapshot:F(o,a,void 0,void 0,await I(e,{includeUnstaged:t,includeUntracked:!0})),combinedDiff:o}})}async function D(e,t,n){n&&await f(e,{trackedOnly:!0,indexEnv:t}),await f(e,{indexEnv:t})}async function O(e,t,n){let r=await P(e,t),a=await c(e,await P(e,n),r),o=await u(e,r),l=await i(e,a,r);return{hunks:h(l,`branch`),commits:L(await s(e,{format:`%H%n%s%n---END---`,range:`${a}..${r}`})),snapshot:F(l,await u(e,`HEAD`),o,a),combinedDiff:l}}async function k(t,n,r,a){await P(t,n);let o=await u(t,r),c=await u(t,a),l=A(await s(t,{format:`%H%n%an%n%ae%n%aI%n%B%n---COMMIT-END---`,range:`${o}..${c}`,reverse:!0}));if(l.length===0)throw new e(`NO_CHANGES`,`No commits in range '${r}..${a}' on branch '${n}'.`);let d=[],f=0;for(let e of l){let n=h(await i(t,`${e.sha}^`,e.sha),`branch`,f);for(let t of n)t.sourceCommitSha=e.sha,t.author={name:e.authorName,email:e.authorEmail,date:e.authorDate};d.push(...n),f+=n.length}let p=await i(t,o,c),m=F(p,await u(t,`HEAD`),c,o);return{hunks:d,commits:l.map(e=>({sha:e.sha,message:e.message})),snapshot:m,combinedDiff:p}}function A(e){let t=[],n=e.split(`---COMMIT-END---`).filter(e=>e.trim());for(let e of n){let n=e.replace(/^\n+/,``).split(`
1
+ import{GitError as e}from"../errors/git.js";import{ComposeWorkflowInputError as t}from"../errors/workflow.js";import{applyPatchToIndex as n,createBranch as r,currentBranch as i,diffTree as a,gitDir as o,listUntracked as s,log as c,mergeBase as l,mergeBaseIsAncestor as u,readTree as d,revParse as f,revParseVerify as p,stageAll as m,writeTree as h}from"./port-dispatch.js";import{getAuthorAndCoAuthorsForCombinedDiffHunk as g}from"./utils/authorship.js";import{sha256 as _}from"./utils/crypto.js";import{parseUnifiedDiff as v}from"./utils/hunk-parser.js";import{withUntrackedInTempIndex as y}from"./utils/temp-index.js";import{randomUUID as b}from"node:crypto";import{copyFileSync as x,unlinkSync as S}from"node:fs";import{tmpdir as C}from"node:os";import{join as w}from"node:path";async function T(e,t){if(t.type===`workdir`){let n=!t.stagedOnly&&!t.noUntracked;return{...await E(e,{includeUnstaged:!t.stagedOnly,includeUntracked:n}),untrackedIncluded:n}}if(t.type===`commit-range`){if(t.includeWorkdir){let n=t.includeWorkdir.includeStaged??!0,r=t.includeWorkdir.includeUnstaged??!0,i=t.includeWorkdir.includeUntracked??!0,a=await L(e,t.branch,t.from,t.to,{includeStaged:n,includeUnstaged:r,includeUntracked:i});return{hunks:a.hunks,snapshot:a.snapshot,combinedDiff:a.combinedDiff,commitMessages:a.commits.map(e=>e.message),commitOrder:a.commits.map(e=>e.sha),branchName:t.branch,baseBranch:void 0,branchSha:a.snapshot.branchSha,mergeBaseSha:a.snapshot.baseSha,untrackedIncluded:i}}let n=await M(e,t.branch,t.from,t.to);return{hunks:n.hunks,snapshot:n.snapshot,combinedDiff:n.combinedDiff,commitMessages:n.commits.map(e=>e.message),commitOrder:n.commits.map(e=>e.sha),branchName:t.branch,baseBranch:void 0,branchSha:n.snapshot.branchSha,mergeBaseSha:n.snapshot.baseSha,untrackedIncluded:!1}}let n=t.mergeTarget??`main`,r=await j(e,t.name,n);return{hunks:r.hunks,snapshot:r.snapshot,combinedDiff:r.combinedDiff,commitMessages:r.commits.map(e=>e.message),commitOrder:r.commits.map(e=>e.sha),branchName:t.name,baseBranch:n,branchSha:r.snapshot.branchSha,mergeBaseSha:r.snapshot.baseSha,untrackedIncluded:!1}}async function E(e,t){let n=t.includeUnstaged||t.includeUntracked;return t.includeUntracked?k(e,n):D(e,n)}async function D(e,t){let n=await f(e,`HEAD`),r=await O(e,n,t);return{hunks:v(r),snapshot:V(r,n,void 0,void 0,await H(e,{includeUnstaged:t,includeUntracked:!1})),combinedDiff:r}}async function O(e,t,n){if(!n)return a(e,t,await h(e));let r=w(await o(e),`index`),i=w(C(),`compose-snapshot-idx-${b()}`);try{x(r,i);let n={GIT_INDEX_FILE:i};return await m(e,{trackedOnly:!0,indexEnv:n}),a(e,t,await h(e,{indexEnv:n}))}finally{try{S(i)}catch{}}}async function k(e,t){return y(e,async n=>{await A(e,n,t);let r=await h(e,{indexEnv:n}),i=await f(e,`HEAD`),o=await a(e,i,r);return{hunks:v(o),snapshot:V(o,i,void 0,void 0,await H(e,{includeUnstaged:t,includeUntracked:!0})),combinedDiff:o}})}async function A(e,t,n){n&&await m(e,{trackedOnly:!0,indexEnv:t}),await m(e,{indexEnv:t})}async function j(e,t,n){let r=await B(e,t);return P(e,await l(e,await B(e,n),r),await f(e,r))}async function M(e,t,n,r){await B(e,t);let i=await f(e,n),a=await f(e,r);return P(e,await N(e,i),a)}async function N(e,n){try{return await f(e,`${n}^`)}catch{throw new t(`Cannot recompose commit ${n.slice(0,8)} — it has no parent (root commit). Recomposing the root commit is not currently supported.`)}}async function P(n,r,i){if(!await u(n,r,i))throw new t(`Cannot collect range ${r.slice(0,8)}..${i.slice(0,8)}: '${r.slice(0,8)}' is not an ancestor of '${i.slice(0,8)}'.`);let o=await a(n,r,i),s=v(o),l=I(await c(n,{format:`%H%n%an%n%ae%n%aI%n%B%n---COMMIT-END---`,range:`${r}..${i}`,reverse:!0}));if(l.length===0)throw new e(`NO_CHANGES`,`No commits in range '${r.slice(0,8)}..${i.slice(0,8)}'.`);let d=await F(n,l);for(let e of s){let{author:t,coAuthors:n}=g(d,e);t!=null&&(e.author=t),n.length>0&&(e.coAuthors=n)}let p=V(o,await f(n,`HEAD`),i,r);return{hunks:s,commits:l.map(e=>({sha:e.sha,message:e.message})),snapshot:p,combinedDiff:o}}async function F(e,t){let n=[];for(let r of t){let t;try{t=await a(e,`${r.sha}^`,r.sha)}catch{continue}let i=v(t,0);for(let e of i)e.author={name:r.authorName,email:r.authorEmail,date:r.authorDate};n.push(...i)}return n}function I(e){let t=[],n=e.split(`---COMMIT-END---`).filter(e=>e.trim());for(let e of n){let n=e.replace(/^\n+/,``).split(`
2
2
  `);if(n.length<5)continue;let r=n[0],i=n[1],a=n[2],o=n[3],s=n.slice(4).join(`
3
- `).trimEnd(),c=o?new Date(o):void 0,l=c&&!Number.isNaN(c.getTime())?c:void 0;t.push({sha:r,authorName:i,authorEmail:a,authorDate:l,message:s})}return t}async function j(e,n,r,a,o){let s=await u(e,await P(e,n)),c=await u(e,r),l=await u(e,a),d=await u(e,`HEAD`);if(l!==s)throw new t(`commit-range with includeWorkdir requires 'to' to equal branch tip. Branch '${n}' tip is ${s}; got 'to'=${l}.`);if(d!==l)throw new t(`commit-range with includeWorkdir requires HEAD to equal 'to'. HEAD=${d}, 'to'=${l}.`);let f=await k(e,n,r,a);if(!(o.includeStaged||o.includeUnstaged||o.includeUntracked))return f;let{workdirHunks:p,finalTreeSha:m}=await M(e,d,o),h=p.map((e,t)=>({...e,index:f.hunks.length+t})),g=await i(e,c,m),_=F(g,d,l,c,await I(e,o));return{hunks:[...f.hunks,...h],commits:f.commits,snapshot:_,combinedDiff:g}}async function M(e,t,r){let s=x(b(),`compose-workdir-${_()}`),c={GIT_INDEX_FILE:s};try{if(await l(e,t,{indexEnv:c}),r.includeStaged){let r=await i(e,t,await p(e));r.trim()&&await n(e,r,{indexEnv:c})}if(r.includeUnstaged){let t=await p(e),r=x(b(),`compose-workdir-u-${_()}`),o={GIT_INDEX_FILE:r};try{v(x(await a(e),`index`),r),await f(e,{trackedOnly:!0,indexEnv:o});let s=await i(e,t,await p(e,{indexEnv:o}));s.trim()&&await n(e,s,{indexEnv:c})}finally{try{y(r)}catch{}}}if(r.includeUntracked){let t=await o(e);t.length>0&&(await f(e,{intentToAdd:!0,paths:t,indexEnv:c}),await f(e,{paths:t,indexEnv:c}))}let s=await p(e,{indexEnv:c});return{workdirHunks:h(await i(e,t,s),`staged`,0),finalTreeSha:s}}finally{try{y(s)}catch{}}}async function N(e,t,n){let r=await P(e,t),a=await c(e,await P(e,n),r),o=await u(e,r),l=(await s(e,{format:`%H`,range:`${a}..${r}`,reverse:!0})).trim().split(`
4
- `).filter(Boolean),d=[];for(let t of l){let[n,r,a]=await Promise.all([s(e,{format:`%B`,sha:t}).then(e=>e.trim()),i(e,`${t}^`,t),i(e,`${t}^`,t,{binary:!1,nameOnly:!0})]),o=a.trim().split(`
5
- `).filter(Boolean);d.push({sha:t,message:n,diff:r,filesChanged:o})}return{commits:d,mergeBaseSha:a,branchSha:o}}async function P(t,n){if(await d(t,`refs/heads/${n}`)||await d(t,n))return n;if(await d(t,`origin/${n}`))return await r(t,n,`origin/${n}`),n;throw new e(`BRANCH_NOT_FOUND`,`Branch '${n}' not found locally or on origin. Run 'git fetch' if the branch exists on the remote.`)}function F(e,t,n,r,i){return{diffHash:m(e),headSha:t,branchSha:n,baseSha:r,sourceHashes:i}}async function I(e,t){if(!e.exec)return;let n=t.includeStaged??!0,r={};if(n)try{r.staged=m(await e.exec([`diff`,`--cached`,`--binary`,`HEAD`]))}catch{}if(t.includeUnstaged)try{r.unstaged=m(await e.exec([`diff`,`--binary`]))}catch{}if(t.includeUntracked)try{let t=(await e.exec([`ls-files`,`--others`,`--exclude-standard`])).trim().split(`
6
- `).filter(Boolean);if(t.length>0){let n=[];for(let r of t)try{let t=await e.exec([`hash-object`,`--`,r]);n.push(`${r}:${t.trim()}`)}catch{}r.untracked=m(n.join(`
7
- `))}else r.untracked=m(``)}catch{}return Object.keys(r).length>0?r:void 0}function L(e){let t=[],n=e.split(`---END---`).filter(e=>e.trim());for(let e of n){let n=e.trim().split(`
8
- `);n.length>=2&&t.push({sha:n[0],message:n[1]})}return t}export{O as collectBranchDiffs,k as collectCommitRangeDiffs,j as collectCommitRangeWithWorkdir,N as collectCommitsForSplit,S as collectFromSource,I as collectSourceHashes,M as collectWorkdirPortionForRange,C as collectWorkingDiffs};
3
+ `).trimEnd(),c=o?new Date(o):void 0,l=c&&!Number.isNaN(c.getTime())?c:void 0;t.push({sha:r,authorName:i,authorEmail:a,authorDate:l,message:s})}return t}async function L(e,n,r,o,s){let l=await f(e,await B(e,n)),d=await f(e,r),p=await f(e,o),m=await f(e,`HEAD`);if(p!==l)throw new t(`commit-range with includeWorkdir requires 'to' to equal branch tip. Branch '${n}' tip is ${l}; got 'to'=${p}.`);if(m!==p)throw new t(`commit-range with includeWorkdir requires HEAD to equal 'to'. HEAD=${m}, 'to'=${p}.`);if(await i(e)==null)throw new t(`commit-range with includeWorkdir requires HEAD to be on a branch (non-detached). Check out the branch holding the range before composing.`);let h=await N(e,d),{finalTreeSha:_}=await R(e,m,s),y=await a(e,h,_);if(!await u(e,h,p))throw new t(`Cannot collect range ${h.slice(0,8)}..${p.slice(0,8)}: '${h.slice(0,8)}' is not an ancestor of '${p.slice(0,8)}'.`);let b=v(y),x=I(await c(e,{format:`%H%n%an%n%ae%n%aI%n%B%n---COMMIT-END---`,range:`${h}..${p}`,reverse:!0})),S=await F(e,x);for(let e of b){let{author:t,coAuthors:n}=g(S,e);t!=null&&(e.author=t),n.length>0&&(e.coAuthors=n)}let C=V(y,m,p,h,await H(e,s));return{hunks:b,commits:x.map(e=>({sha:e.sha,message:e.message})),snapshot:C,combinedDiff:y}}async function R(e,t,r){let i=w(C(),`compose-workdir-${b()}`),c={GIT_INDEX_FILE:i};try{if(await d(e,t,{indexEnv:c}),r.includeStaged){let r=await a(e,t,await h(e));r.trim()&&await n(e,r,{indexEnv:c})}if(r.includeUnstaged){let t=await h(e),r=w(C(),`compose-workdir-u-${b()}`),i={GIT_INDEX_FILE:r};try{x(w(await o(e),`index`),r),await m(e,{trackedOnly:!0,indexEnv:i});let s=await a(e,t,await h(e,{indexEnv:i}));s.trim()&&await n(e,s,{indexEnv:c})}finally{try{S(r)}catch{}}}if(r.includeUntracked){let t=await s(e);t.length>0&&(await m(e,{intentToAdd:!0,paths:t,indexEnv:c}),await m(e,{paths:t,indexEnv:c}))}return{finalTreeSha:await h(e,{indexEnv:c})}}finally{try{S(i)}catch{}}}async function z(e,t,n){let r=await B(e,t),i=await l(e,await B(e,n),r),o=await f(e,r),s=(await c(e,{format:`%H`,range:`${i}..${r}`,reverse:!0})).trim().split(`
4
+ `).filter(Boolean),u=[];for(let t of s){let[n,r,i]=await Promise.all([c(e,{format:`%B`,sha:t}).then(e=>e.trim()),a(e,`${t}^`,t),a(e,`${t}^`,t,{binary:!1,nameOnly:!0})]),o=i.trim().split(`
5
+ `).filter(Boolean);u.push({sha:t,message:n,diff:r,filesChanged:o})}return{commits:u,mergeBaseSha:i,branchSha:o}}async function B(t,n){if(await p(t,`refs/heads/${n}`)||await p(t,n))return n;if(await p(t,`origin/${n}`))return await r(t,n,`origin/${n}`),n;throw new e(`BRANCH_NOT_FOUND`,`Branch '${n}' not found locally or on origin. Run 'git fetch' if the branch exists on the remote.`)}function V(e,t,n,r,i){return{diffHash:_(e),headSha:t,branchSha:n,baseSha:r,sourceHashes:i}}async function H(e,t){if(!e.exec)return;let n=t.includeStaged??!0,r={};if(n)try{r.staged=_(await e.exec([`diff`,`--cached`,`--binary`,`HEAD`]))}catch{}if(t.includeUnstaged)try{r.unstaged=_(await e.exec([`diff`,`--binary`]))}catch{}if(t.includeUntracked)try{let t=(await e.exec([`ls-files`,`--others`,`--exclude-standard`])).trim().split(`
6
+ `).filter(Boolean);if(t.length>0){let n=[];for(let r of t)try{let t=await e.exec([`hash-object`,`--`,r]);n.push(`${r}:${t.trim()}`)}catch{}r.untracked=_(n.join(`
7
+ `))}else r.untracked=_(``)}catch{}return Object.keys(r).length>0?r:void 0}export{j as collectBranchDiffs,P as collectCombinedDiffRange,M as collectCommitRangeDiffs,L as collectCommitRangeWithWorkdir,z as collectCommitsForSplit,T as collectFromSource,H as collectSourceHashes,R as collectWorkdirPortionForRange,E as collectWorkingDiffs};
@@ -2,6 +2,6 @@ import { ApplyBranchEntry, ApplyBranchStructureParams, applyBranchStructure, cre
2
2
  import { SafetySnapshot } from "./types.js";
3
3
  import { BranchCommitInfo, BranchDiffResult, CollectionResult, SplitCollectionResult, WorkingDiffOptions, WorkingDiffResult, collectBranchDiffs, collectCommitsForSplit, collectFromSource, collectWorkingDiffs } from "./diff-collector.js";
4
4
  import { ApplyToTargetParams, ApplyToTargetResult, ComposeBranchPlan, applyToTarget } from "./apply-target.js";
5
- import { ApplyCommitChainParams, ApplyRecomposeParams, BuildCommitChainParams, BuildCommitChainResult, applyCommitChain, applyRecompose, buildCommitChain } from "./commit-chain.js";
5
+ import { ApplyCommitChainParams, BuildCommitChainParams, BuildCommitChainResult, applyCommitChain, buildCommitChain } from "./commit-chain.js";
6
6
  import { verifyMultiBranchDiff, verifyResultingDiff, verifySnapshot } from "./safety.js";
7
- export { type ApplyBranchEntry, type ApplyBranchStructureParams, type ApplyCommitChainParams, type ApplyRecomposeParams, type ApplyToTargetParams, type ApplyToTargetResult, type BranchCommitInfo, type BranchDiffResult, type BuildCommitChainParams, type BuildCommitChainResult, type CollectionResult, type ComposeBranchPlan, type SafetySnapshot, type SplitCollectionResult, type WorkingDiffOptions, type WorkingDiffResult, applyBranchStructure, applyCommitChain, applyRecompose, applyToTarget, buildCommitChain, collectBranchDiffs, collectCommitsForSplit, collectFromSource, collectWorkingDiffs, createStackedBranches, verifyMultiBranchDiff, verifyResultingDiff, verifySnapshot };
7
+ export { type ApplyBranchEntry, type ApplyBranchStructureParams, type ApplyCommitChainParams, type ApplyToTargetParams, type ApplyToTargetResult, type BranchCommitInfo, type BranchDiffResult, type BuildCommitChainParams, type BuildCommitChainResult, type CollectionResult, type ComposeBranchPlan, type SafetySnapshot, type SplitCollectionResult, type WorkingDiffOptions, type WorkingDiffResult, applyBranchStructure, applyCommitChain, applyToTarget, buildCommitChain, collectBranchDiffs, collectCommitsForSplit, collectFromSource, collectWorkingDiffs, createStackedBranches, verifyMultiBranchDiff, verifyResultingDiff, verifySnapshot };