Files
lazygit/pkg/commands/git_commands/patch.go
T
Stefan HallerandClaude Opus 5 b6ce478d46 Materialize the custom patch so the diff renderer can show it
The pane beside a commit's diff showed the patch being built from it by
assembling the text itself. That text could not be handed to a diff
renderer the way a diff can: a stdin filter might have coped, but a tool
that diffs two files could not. Its idea of how much context to show
around a hunk was also its own rather than git's.

Materialize the patch instead: write each of its files as it is before the
patch into one tree and as it is after into another, and let git diff the
two trees. The patch becomes a diff of real files, rendered by whatever
renders the rest of them, with git's own context around it. Its lines can
then be pointed at; taking them back out of the patch will need that.

The trees are named a and b, so that with git's own prefixes suppressed
the paths read like an ordinary diff's over the repo's own paths. They are
written when the patch changes rather than when it is shown, the patch
builder counting its own versions for that, and they go away with the
patch.

A renamed file is materialized under the name the patch expects to find it
under. Where the patch carries the rename, that is the name the file had
before, so the rename comes out as a rename. git names the trees
themselves in the two rename lines, having only the two paths to go by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 11:33:11 +02:00

444 lines
13 KiB
Go

package git_commands
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
)
type PatchCommands struct {
*GitCommon
rebase *RebaseCommands
commit *CommitCommands
status *StatusCommands
stash *StashCommands
PatchBuilder *patch.PatchBuilder
// The version of the patch the diff trees were last written for, so that they are
// written again when, and only when, the patch has changed since.
treesWrittenForGeneration int
}
func NewPatchCommands(
gitCommon *GitCommon,
rebase *RebaseCommands,
commit *CommitCommands,
status *StatusCommands,
stash *StashCommands,
patchBuilder *patch.PatchBuilder,
) *PatchCommands {
return &PatchCommands{
GitCommon: gitCommon,
rebase: rebase,
commit: commit,
status: status,
stash: stash,
PatchBuilder: patchBuilder,
}
}
// EnsureCustomPatchDiffTrees writes the custom patch's diff trees if what is there no
// longer describes the patch. Call it before rendering the patch, which is often — every
// time the panel showing it re-renders — while the patch itself changes rarely.
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
if self.PatchBuilder.Generation() == self.treesWrittenForGeneration {
return nil
}
if err := self.WriteCustomPatchDiffTrees(); err != nil {
return err
}
self.treesWrittenForGeneration = self.PatchBuilder.Generation()
return nil
}
// WriteCustomPatchDiffTrees materializes the custom patch as two file trees under the
// directory the patch builder keeps for it: `a` holds each of the patch's files as it is
// before the patch, `b` as it is after. Diffing those two trees against each other
// (DiffCommands.CustomPatchDiffCmdObj) turns the patch into a diff of real files, which
// can then be rendered exactly as any other diff is — through a diff renderer of any
// kind, and with git's own idea of how much context to show.
//
// The trees are named a and b so that the diff's paths, with git's own prefixes
// suppressed, come out reading like the a/ and b/ of an ordinary diff, over the real
// repo-relative paths.
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
dir := self.PatchBuilder.TempDir()
if dir == "" {
return nil
}
before := filepath.Join(dir, "a")
after := filepath.Join(dir, "b")
for _, tree := range []string{before, after} {
if err := os.RemoveAll(tree); err != nil {
return err
}
if err := os.MkdirAll(tree, 0o700); err != nil {
return err
}
}
for _, file := range self.PatchBuilder.FilesInPatch() {
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, file.ContentPath).RunWithOutput()
// A file the patch adds has no content on the before side, so git has nothing to
// show for it.
added := err != nil
// The before side holds an added file as an empty file rather than not at all, so
// that the diff pairs the two sides up and states the file's real path, instead of
// reporting a file that only one of the trees has.
if err := self.os.CreateFileWithContent(filepath.Join(before, file.Path),
lo.Ternary(added, "", content)); err != nil {
return err
}
// The after side is seeded with the same content, for the patch to change; a file
// the patch adds is left absent, for the patch to create.
if !added {
if err := self.os.CreateFileWithContent(filepath.Join(after, file.Path), content); err != nil {
return err
}
}
}
// Write added files as creations rather than as diffs against an empty file: the
// patch is applied in one go, so a file it expects to be there already would make
// the whole of it fail.
patchText := self.PatchBuilder.PatchToApply(false, false)
if strings.TrimSpace(patchText) == "" {
// Nothing in the patch, so the two trees are alike and the diff is empty.
return nil
}
patchFilePath, err := self.SaveTemporaryPatch(patchText)
if err != nil {
return err
}
return self.cmd.New(NewGitCmd("apply").
NoLineEndingConversion().
Arg(patchFilePath).
Dir(after).
ToArgv()).Run()
}
type ApplyPatchOpts struct {
ThreeWay bool
Cached bool
Index bool
Reverse bool
}
func (self *PatchCommands) ApplyCustomPatch(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) error {
patch := self.PatchBuilder.PatchToApply(reverse, turnAddedFilesIntoDiffAgainstEmptyFile)
return self.ApplyPatch(patch, ApplyPatchOpts{
Index: true,
ThreeWay: true,
Reverse: reverse,
})
}
func (self *PatchCommands) ApplyPatch(patch string, opts ApplyPatchOpts) error {
filepath, err := self.SaveTemporaryPatch(patch)
if err != nil {
return err
}
return self.applyPatchFile(filepath, opts)
}
func (self *PatchCommands) applyPatchFile(filepath string, opts ApplyPatchOpts) error {
cmdArgs := NewGitCmd("apply").
ArgIf(opts.ThreeWay, "--3way").
ArgIf(opts.Cached, "--cached").
ArgIf(opts.Index, "--index").
ArgIf(opts.Reverse, "--reverse").
Arg(filepath).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *PatchCommands) SaveTemporaryPatch(patch string) (string, error) {
filepath := filepath.Join(self.os.GetTempDir(), self.repoPaths.RepoName(), time.Now().Format("Jan _2 15.04.05.000000000")+".patch")
self.Log.Infof("saving temporary patch to %s", filepath)
if err := self.os.CreateFileWithContent(filepath, patch); err != nil {
return "", err
}
return filepath, nil
}
// DeletePatchesFromCommit applies a patch in reverse for a commit
func (self *PatchCommands) DeletePatchesFromCommit(commits []*models.Commit, commitIndex int) error {
if err := self.rebase.BeginInteractiveRebaseForCommit(commits, commitIndex, false); err != nil {
return err
}
// apply each patch in reverse
if err := self.ApplyCustomPatch(true, true); err != nil {
_ = self.rebase.AbortRebase()
return err
}
// time to amend the selected commit
if err := self.commit.AmendHead(); err != nil {
return err
}
self.rebase.onSuccessfulContinue = func() error {
self.PatchBuilder.Reset()
return nil
}
// continue
return self.rebase.ContinueRebase()
}
func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, sourceCommitIdx int, destinationCommitIdx int) error {
if sourceCommitIdx < destinationCommitIdx {
// Passing true for keepCommitsThatBecomeEmpty: if the moved-from
// commit becomes empty, we want to keep it, mainly for consistency with
// moving the patch to a *later* commit, which behaves the same.
if err := self.rebase.BeginInteractiveRebaseForCommit(commits, destinationCommitIdx, true); err != nil {
return err
}
// apply each patch forward
if err := self.ApplyCustomPatch(false, false); err != nil {
// Don't abort the rebase here; this might cause conflicts, so give
// the user a chance to resolve them
return err
}
// amend the destination commit
if err := self.commit.AmendHead(); err != nil {
return err
}
self.rebase.onSuccessfulContinue = func() error {
self.PatchBuilder.Reset()
return nil
}
// continue
return self.rebase.ContinueRebase()
}
if len(commits)-1 < sourceCommitIdx {
return errors.New("index outside of range of commits")
}
// we can make this GPG thing possible it just means we need to do this in two parts:
// one where we handle the possibility of a credential request, and the other
// where we continue the rebase
if self.config.NeedsGpgSubprocessForCommit() {
return errors.New(self.Tr.DisabledForGPG)
}
baseIndex := sourceCommitIdx + 1
changes := []daemon.ChangeTodoAction{
{Hash: commits[sourceCommitIdx].Hash(), NewAction: todo.Edit},
{Hash: commits[destinationCommitIdx].Hash(), NewAction: todo.Edit},
}
self.os.LogCommand(logTodoChanges(changes), false)
err := self.rebase.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: getBaseHashOrRoot(commits, baseIndex),
overrideEditor: true,
instruction: daemon.NewChangeTodoActionsInstruction(changes),
}).Run()
if err != nil {
return err
}
// apply each patch in reverse
if err := self.ApplyCustomPatch(true, true); err != nil {
_ = self.rebase.AbortRebase()
return err
}
// amend the source commit
if err := self.commit.AmendHead(); err != nil {
return err
}
patch, err := self.diffHeadAgainstCommit(commits[sourceCommitIdx])
if err != nil {
_ = self.rebase.AbortRebase()
return err
}
if self.rebase.onSuccessfulContinue != nil {
return errors.New("You are midway through another rebase operation. Please abort to start again")
}
self.rebase.onSuccessfulContinue = func() error {
// now we should be up to the destination, so let's apply forward these patches to that.
// ideally we would ensure we're on the right commit but I'm not sure if that check is necessary
if err := self.ApplyPatch(patch, ApplyPatchOpts{Index: true, ThreeWay: true}); err != nil {
// Don't abort the rebase here; this might cause conflicts, so give
// the user a chance to resolve them
return err
}
// amend the destination commit
if err := self.commit.AmendHead(); err != nil {
return err
}
self.rebase.onSuccessfulContinue = func() error {
self.PatchBuilder.Reset()
return nil
}
return self.rebase.ContinueRebase()
}
return self.rebase.ContinueRebase()
}
func (self *PatchCommands) MovePatchIntoIndex(commits []*models.Commit, commitIdx int, stash bool) error {
if stash {
if err := self.stash.Push(fmt.Sprintf(self.Tr.AutoStashForMovingPatchToIndex, commits[commitIdx].ShortHash())); err != nil {
return err
}
}
if err := self.rebase.BeginInteractiveRebaseForCommit(commits, commitIdx, false); err != nil {
return err
}
if err := self.ApplyCustomPatch(true, true); err != nil {
if self.status.WorkingTreeState().Rebasing {
_ = self.rebase.AbortRebase()
}
return err
}
// amend the commit
if err := self.commit.AmendHead(); err != nil {
return err
}
patch, err := self.diffHeadAgainstCommit(commits[commitIdx])
if err != nil {
_ = self.rebase.AbortRebase()
return err
}
if self.rebase.onSuccessfulContinue != nil {
return errors.New("You are midway through another rebase operation. Please abort to start again")
}
self.rebase.onSuccessfulContinue = func() error {
// add patches to index
if err := self.ApplyPatch(patch, ApplyPatchOpts{Index: true, ThreeWay: true}); err != nil {
if self.status.WorkingTreeState().Rebasing {
_ = self.rebase.AbortRebase()
}
return err
}
if stash {
if err := self.stash.Pop(0); err != nil {
return err
}
}
self.PatchBuilder.Reset()
return nil
}
return self.rebase.ContinueRebase()
}
func (self *PatchCommands) PullPatchIntoNewCommit(
commits []*models.Commit,
commitIdx int,
commitSummary string,
commitDescription string,
) error {
if err := self.rebase.BeginInteractiveRebaseForCommit(commits, commitIdx, false); err != nil {
return err
}
if err := self.ApplyCustomPatch(true, true); err != nil {
_ = self.rebase.AbortRebase()
return err
}
// amend the commit
if err := self.commit.AmendHead(); err != nil {
return err
}
patch, err := self.diffHeadAgainstCommit(commits[commitIdx])
if err != nil {
_ = self.rebase.AbortRebase()
return err
}
if err := self.ApplyPatch(patch, ApplyPatchOpts{Index: true, ThreeWay: true}); err != nil {
_ = self.rebase.AbortRebase()
return err
}
if err := self.commit.CommitCmdObj(commitSummary, commitDescription, false).Run(); err != nil {
return err
}
if self.rebase.onSuccessfulContinue != nil {
return errors.New("You are midway through another rebase operation. Please abort to start again")
}
self.PatchBuilder.Reset()
return self.rebase.ContinueRebase()
}
func (self *PatchCommands) PullPatchIntoNewCommitBefore(
commits []*models.Commit,
commitIdx int,
commitSummary string,
commitDescription string,
) error {
if err := self.rebase.BeginInteractiveRebaseForCommit(commits, commitIdx+1, true); err != nil {
return err
}
if err := self.ApplyCustomPatch(false, false); err != nil {
_ = self.rebase.AbortRebase()
return err
}
if err := self.commit.CommitCmdObj(commitSummary, commitDescription, false).Run(); err != nil {
return err
}
self.PatchBuilder.Reset()
return self.rebase.ContinueRebase()
}
// We have just applied a patch in reverse to discard it from a commit; if we
// now try to apply the patch again to move it to a later commit, or to the
// index, then this would conflict "with itself" in case the patch contained
// only some lines of a range of adjacent added lines. To solve this, we
// get the diff of HEAD and the original commit and then apply that.
func (self *PatchCommands) diffHeadAgainstCommit(commit *models.Commit) (string, error) {
cmdArgs := NewGitCmd("diff").
Config("diff.noprefix=false").
Arg("--no-ext-diff", "--no-color").
Arg("HEAD.." + commit.Hash()).
ToArgv()
return self.cmd.New(cmdArgs).RunWithOutput()
}