Skip to content

feat(staging): interactive hunk staging#8

Merged
poolcamacho merged 3 commits intomasterfrom
feat/interactive-staging-model
Apr 20, 2026
Merged

feat(staging): interactive hunk staging#8
poolcamacho merged 3 commits intomasterfrom
feat/interactive-staging-model

Conversation

@poolcamacho
Copy link
Copy Markdown
Owner

Summary

Interactive staging at hunk granularity. Tick a checkbox on any @@ header to stage (or unstage) only those hunks — no more git add -p in the terminal. Per-line selection ships in a follow-up PR.

What changed

Stage 1 — foundation (c29d5f7)

  • DiffHunk / DiffFile models grouping lines under their @@ header and preserving the file preamble (diff --git, index, ---, +++) needed to reconstruct a patch.
  • DiffFile.patchText(forHunkIndices:) — rebuilds a git apply-compatible patch from any subset of hunks.
  • Services/DiffParser.swift extracted from GitServiceparseFiles (structured) and parseFlat (legacy).
  • GitService.run(...) now takes an optional stdin: String? so we can pipe patches directly.
  • GitService.applyPatch(_:cached:reverse:in:) wraps git apply --whitespace=nowarn - with optional --cached and --reverse.

Stage 2 + 3 — UI + wiring (e2a960a)

  • AppState gains currentDiffFile: DiffFile? and selectedHunks: Set<Int> alongside the existing flat DiffLine stream. Selection resets on file change and repo reload.
  • GitService.diffFile(for:staged:in:) returns the structured parse so the coordinator skips a second subprocess.
  • GitCoordinator.stageSelectedHunks() / unstageSelectedHunks() build the patch via DiffFile.patchText and pipe it to git apply --cached (with --reverse to unstage).
  • DiffView takes optional diffFile + selection binding. When present, renders from hunks with a checkbox on each @@ row. Commit-history diffs keep the flat read-only path.
  • ChangesTabView adds HunkStagingToolbar above the diff — Select all, counter, Stage / Unstage selected (flips based on isStaged). Only rendered for tracked files; untracked and conflicted skip.

Docs (2f3712a)

  • README: new Features bullet, hunk-level staging moved to Done, line-level stays in Next.

Not in this PR

  • Per-line selection inside a hunk — needs to rewrite oldCount / newCount and handle context lines carefully. Separate PR.
  • Keyboard shortcuts for the toolbar.

Test plan

  • Modify a file with two separate regions, open it in Changes tab, tick one @@ header → click Stage selected → only that hunk lands in the index (git diff --cached confirms)
  • Select the staged file, tick a hunk, click Unstage selected → hunk returns to unstaged
  • Stage all hunks → unstaged entry disappears; staged entry has the full file
  • Select All toggles all hunks on / off, counter updates
  • Untracked file → no toolbar, no checkboxes
  • Conflicted file → ConflictView still shown, not the staging toolbar
  • History tab commit diff → no checkboxes, no toolbar
  • swiftlint --strict clean, xcodebuild clean

… builder, stdin support

First of 4 stages for Interactive Staging (#roadmap). No user-visible
change yet; this commit adds the plumbing that Stage 2 (UI) and Stage 3
(coordinator) will build on top of.

Models
- DiffHunk: groups the lines under a single '@@ ... @@' header, keeping
  the raw header text so we can reconstruct patches without reformatting.
- DiffFile: preamble (diff --git / index / --- / +++) + hunks. Previously
  the parser discarded the preamble; it has to stay in the tree so git
  apply accepts the patch.
- DiffFile.patchText(forHunkIndices:) builds a git-apply-compatible patch
  including only the selected hunks. Valid for whole-hunk selection; the
  later line-level stage will rewrite the header counts for partials.

Parser
- New DiffParser enum with parseFiles(_:) and parseFlat(_:).
- Internal State struct handles the per-line transitions cleanly instead
  of the local-var soup the previous parser used.
- Two thin aliases remain on GitService (parseDiffFiles, parseDiff) so
  existing callers keep working unchanged.

GitService
- run(...) accepts an optional stdin: String. When non-nil, a Pipe is
  used for stdin and the string is written + closed after process.run()
  so git sees EOF. Falls back to the existing /dev/null behaviour when
  stdin is nil, so every other caller is unaffected.
- applyPatch(_:cached:reverse:in:) wraps 'git apply --whitespace=nowarn
  -' with the provided patch piped in. cached=true stages, reverse=true
  unstages.

Build clean, swiftlint --strict clean.
Stage 2 + Stage 3 of the interactive-staging plan. Whole-hunk only;
per-line selection follows in a later PR.

- AppState: currentDiffFile + selectedHunks alongside the existing
  flat DiffLine stream. Selection resets on file change / repo reload.
- GitService.diffFile(for:staged:in:) returns the structured DiffFile
  so the coordinator can build partial patches without a second
  subprocess roundtrip.
- GitCoordinator: stageSelectedHunks / unstageSelectedHunks build the
  patch via DiffFile.patchText and pipe it to git apply --cached
  (with --reverse when unstaging).
- DiffView: optional diffFile + selection binding. When both are
  present, renders from hunks with a checkbox on each @@ header row.
  Commit-history diffs keep the flat, read-only path.
- ChangesTabView: HunkStagingToolbar above the diff — Select all,
  counter, and Stage|Unstage selected (flips based on isStaged).
  Only rendered for tracked files (untracked and conflicted skip).
Adds a Features bullet for interactive staging and moves hunk-level
support to Done. Line-level selection stays under Next.
@poolcamacho poolcamacho merged commit 150ce29 into master Apr 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant