mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 21:46:49 -04:00
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>
444 lines
13 KiB
Go
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()
|
|
}
|