mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 08:06:25 -04:00
The secondary pane showed the custom patch with a bespoke in-memory render (PatchBuilder.RenderAggregatedPatch / FormatView). That had two problems: it could never be fed to a pager the way the main view's diff is (a stdin pager might have worked, but an external diff tool like difftastic, which diffs two files rather than a unified diff, could not), and its hand-rolled hunk/context handling differed subtly from git's. Materialize the patch instead as two real file trees under a temp dir — a/ holds each patched file's "from"-side content, b/ that content with the patch applied — and render it with `git diff --no-index`, reusing the exact pager wiring the main view uses (stdin pager, external diff, or git's own colour as the raw fallback). `--no-index` honors both GIT_PAGER and --ext-diff, so every pager type now renders the custom patch like any other diff, and git computes the context, fixing the quirks. Because the secondary is now an async diff task, the post-removal selection reveal (which rides a task's restore) finally takes effect. The trees are named a/b so that with --no-prefix the diff shows the real repo-relative paths; added files are seeded empty in a/ so they pair up and show their real paths rather than git's directory-comparison "added in b" form. The patch builder owns the temp dir's lifetime (created on Start, removed on Reset) and bumps a generation counter on every change, so the trees are rebuilt only when the patch actually changes — covering the focused-main view and the old explorer alike, without rebuilding on mere navigation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
447 lines
13 KiB
Go
447 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/stefanhaller/git-todo-parser/todo"
|
|
)
|
|
|
|
type PatchCommands struct {
|
|
*GitCommon
|
|
rebase *RebaseCommands
|
|
commit *CommitCommands
|
|
status *StatusCommands
|
|
stash *StashCommands
|
|
|
|
PatchBuilder *patch.PatchBuilder
|
|
|
|
// lastBuiltTreeGeneration is the patch builder generation the diff trees were last
|
|
// materialized for, so EnsureCustomPatchDiffTrees rebuilds them only when the patch
|
|
// actually changed (not on every render / navigation).
|
|
lastBuiltTreeGeneration 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 materializes the custom patch's diff trees if they're stale —
|
|
// i.e. the patch changed since they were last built. Called before rendering the secondary
|
|
// pane, so a mere re-render (e.g. navigating commits, which doesn't change the patch) reuses
|
|
// the existing trees, while a toggle/removal/whole-file change rebuilds them. This is what
|
|
// keeps the trees current across every path that mutates the patch.
|
|
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
|
|
if self.PatchBuilder.Generation() == self.lastBuiltTreeGeneration {
|
|
return nil
|
|
}
|
|
if err := self.WriteCustomPatchDiffTrees(); err != nil {
|
|
return err
|
|
}
|
|
self.lastBuiltTreeGeneration = self.PatchBuilder.Generation()
|
|
return nil
|
|
}
|
|
|
|
// WriteCustomPatchDiffTrees materializes the custom patch under the patch builder's temp
|
|
// dir as two file trees — a/ holds each patched file's "from"-side content, b/ holds that
|
|
// content with the patch applied — so the patch can be re-diffed with `git diff --no-index`
|
|
// and rendered through any pager (see DiffCommands.CustomPatchDiffCmdObj). This is what lets
|
|
// a partial, in-memory custom patch be shown the same way as any other diff; the in-memory
|
|
// aggregated patch on its own could only be fed to a stdin pager, never an external diff
|
|
// tool. The dirs are named a/b so that, with `--no-prefix`, the diff's paths come out as the
|
|
// real repo-relative paths (git's conventional a//b/ prefixes).
|
|
//
|
|
// Called whenever the patch's contents change. The temp dir's lifetime is owned by the
|
|
// patch builder (created on Start, removed on Reset), so there's nothing to clean up here
|
|
// beyond wiping the trees before rebuilding them.
|
|
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
|
|
dir := self.PatchBuilder.TempDir()
|
|
if dir == "" {
|
|
return nil
|
|
}
|
|
|
|
aDir := filepath.Join(dir, "a")
|
|
bDir := filepath.Join(dir, "b")
|
|
for _, d := range []string{aDir, bDir} {
|
|
if err := os.RemoveAll(d); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(d, 0o700); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, filename := range self.PatchBuilder.ActiveFilenames() {
|
|
// The "from"-side content; the patch applied below turns b/ into the "after" side
|
|
// while a/ stays the "before".
|
|
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, filename).RunWithOutput()
|
|
added := err != nil // absent on the "from" side — a file the patch adds
|
|
|
|
// a/ always holds the "before" content — empty for an added file, so the rendered
|
|
// diff still pairs it with b/ and shows the real a//b/ paths (rather than git's
|
|
// directory-comparison "added in b" form, which mangles the header).
|
|
beforeContent := content
|
|
if added {
|
|
beforeContent = ""
|
|
}
|
|
if err := self.os.CreateFileWithContent(filepath.Join(aDir, filename), beforeContent); err != nil {
|
|
return err
|
|
}
|
|
// b/ is seeded only for existing files (which the patch modifies in place); an added
|
|
// file is left absent so the patch — rendered as a /dev/null creation — creates it.
|
|
if !added {
|
|
if err := self.os.CreateFileWithContent(filepath.Join(bDir, filename), content); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render added files as /dev/null creations (the natural form), not as diffs against an
|
|
// empty file: the latter (--- a/file) would make the atomic `git apply` expect them to
|
|
// already exist in b/, where they don't.
|
|
patchText := self.PatchBuilder.PatchToApply(false, false)
|
|
if strings.TrimSpace(patchText) == "" {
|
|
// An empty patch leaves a/ and b/ identical (or empty), so the diff is empty.
|
|
return nil
|
|
}
|
|
patchFilePath, err := self.SaveTemporaryPatch(patchText)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return self.cmd.New(NewGitCmd("apply").Arg(patchFilePath).Dir(bDir).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()
|
|
}
|