jesseduffield.lazygit/pkg/commands/git_commands/diff.go
Stefan Haller 2a69d4c140 Fall back to the raw diff when focusing a main view under an unresolvable pager
The focused main view is the staging surface, so it has to resolve the diff it
shows to patch-space. A pager that restructures the diff without emitting our
metadata (stock delta-default, plain difftastic, `cat -n`) produces output the
buffer parser can't read, which would leave its diff unstageable.

When such a diff is focused, re-render it raw — git's own colour, no pager — the
same content a no-pager setup shows, which the buffer parser handles. Browsing
keeps the pretty pager output; only focusing to act switches to raw.

Whether the pager is usable is decided by probing it: run it on empty input and
look for the version-only OSC 1717 handshake a metadata-aware pager emits first.
This is a pager-level, content-independent fact (a binary file, which has no
change lines under any pager, can't mislead it) and it's known before we render,
so we never render pretty only to discover mid-flight that we should have rendered
raw. The verdict is cached per pager (reset when the pager changes). No PTY is
needed — git needs a terminal to decide to invoke a pager, but the pager emits the
handshake regardless. A git-config external diff driver (useExternalDiffGitConfig)
is chosen per file via .gitattributes and a single diff can mix drivers, so
there's no one pager to probe; it's treated as unsupported (always raw).

Bypassing the pager needs two things, since a pager reaches the diff by two
routes: an external diff command (suppressed in the cmd via the new
ignoreExternalDiff arg, keeping git's colour, unlike plain) and a stdin pager
(GIT_PAGER, applied by the pty task — so the raw render uses a plain command
task instead).

This wires the files panel; the commit/stash/patch-building panels follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00

165 lines
6 KiB
Go

package git_commands
import (
"fmt"
"os"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/mgutz/str"
)
type DiffCommands struct {
*GitCommon
}
func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
return &DiffCommands{
GitCommon: gitCommon,
}
}
// metadataHandshake is the OSC sequence a metadata-aware pager emits, as its first
// output, to announce that it speaks the diff-line-metadata protocol: a version-only
// OSC 1717 record (no fields). See ProbePagerEmitsDiffMetadata and, for how it's
// swallowed on a real render, escapeInterpreter.dropMetadataIfHandshake.
const metadataHandshake = "\x1b]1717"
// ProbePagerEmitsDiffMetadata reports whether the configured pager speaks the
// diff-line-metadata protocol, by running it on empty input and checking for its
// handshake. It's the focused main view's signal for whether it can act on the
// pager's rendered diff or must fall back to the raw diff (see
// StagingHelper.DiffMainViewShouldRenderRaw). The verdict is content-independent —
// the handshake is the pager's first output regardless of the diff — so the caller
// caches it per pager.
//
// No PTY is needed: git needs a terminal to decide to invoke a pager, but the pager
// itself emits the handshake whenever EMIT_OSC1717_METADATA is set, so we can run it
// directly with empty input.
//
// A git-config external diff driver (useExternalDiffGitConfig) is chosen per file via
// .gitattributes and a single diff can mix drivers, so there's no one pager to probe;
// we conservatively report false (the focused main view then always renders raw).
func (self *DiffCommands) ProbePagerEmitsDiffMetadata() bool {
if extDiffCmd := self.diffRendererConfigManager.GetExternalDiffCommand(3); extDiffCmd != "" {
return self.externalDiffEmitsMetadata(extDiffCmd)
}
if pagerCmd := self.diffRendererConfigManager.GetStdinFilterCommand(0); pagerCmd != "" {
return self.probeEmitsMetadata(self.cmd.NewShell(pagerCmd, ""))
}
return false
}
// externalDiffEmitsMetadata probes an external diff command, invoking it the way git
// invokes a diff.external driver — with 7 positional args
// (path old-file old-hex old-mode new-file new-hex new-mode) — but on two empty temp
// files, so it emits its handshake without there being a real diff to render.
func (self *DiffCommands) externalDiffEmitsMetadata(extDiffCmd string) bool {
oldFile, err := os.CreateTemp("", "lazygit-probe-old-*")
if err != nil {
return false
}
defer os.Remove(oldFile.Name())
oldFile.Close()
newFile, err := os.CreateTemp("", "lazygit-probe-new-*")
if err != nil {
return false
}
defer os.Remove(newFile.Name())
newFile.Close()
args := append(str.ToArgv(extDiffCmd),
"probe", oldFile.Name(), "0000000", "100644", newFile.Name(), "0000000", "100644")
return self.probeEmitsMetadata(self.cmd.New(args))
}
func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool {
cmdObj.AddEnvVars("EMIT_OSC1717_METADATA=V1")
// The pager may exit non-zero on the synthetic input; we only care about whether
// it emitted the handshake first, and the output is captured either way.
output, _ := cmdObj.RunWithOutput()
return strings.Contains(output, metadataHandshake)
}
// This is for generating diffs to be shown in the UI (e.g. rendering a range
// diff to the main view). It uses a custom diff renderer if one is configured.
func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(diffArgs...).
Dir(self.repoPaths.worktreePath).
ToArgv(),
)
}
// This is a basic generic diff command that can be used for any diff operation
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
// and does not use user configs such as ignore whitespace.
// If you want to diff specific refs (one or two), you need to add them yourself
// in additionalArgs; it is recommended to also pass `--` after that. If you
// want to restrict the diff to specific paths, pass them in additionalArgs
// after the `--`.
func (self *DiffCommands) GetDiff(staged bool, additionalArgs ...string) (string, error) {
return self.cmd.New(
NewGitCmd("diff").
Config("diff.noprefix=false").
Arg("--no-ext-diff", "--no-color").
ArgIf(staged, "--staged").
Dir(self.repoPaths.worktreePath).
Arg(additionalArgs...).
ToArgv(),
).DontLog().RunWithOutput()
}
type DiffToolCmdOptions struct {
// The path to show a diff for. Pass "." for the entire repo.
Filepath string
// The commit against which to show the diff. Leave empty to show a diff of
// the working copy.
FromCommit string
// The commit to diff against FromCommit. Leave empty to diff the working
// copy against FromCommit. Leave both FromCommit and ToCommit empty to show
// the diff of the unstaged working copy changes against the index if Staged
// is false, or the staged changes against HEAD if Staged is true.
ToCommit string
// Whether to reverse the left and right sides of the diff.
Reverse bool
// Whether the given Filepath is a directory. We'll pass --dir-diff to
// git-difftool in that case.
IsDirectory bool
// Whether to show the staged or the unstaged changes. Must be false if both
// FromCommit and ToCommit are non-empty.
Staged bool
}
func (self *DiffCommands) OpenDiffToolCmdObj(opts DiffToolCmdOptions) *oscommands.CmdObj {
return self.cmd.New(NewGitCmd("difftool").
Arg("--no-prompt").
ArgIf(opts.IsDirectory, "--dir-diff").
ArgIf(opts.Staged, "--cached").
ArgIf(opts.FromCommit != "", opts.FromCommit).
ArgIf(opts.ToCommit != "", opts.ToCommit).
ArgIf(opts.Reverse, "-R").
Arg("--", opts.Filepath).
ToArgv())
}
func (self *DiffCommands) DiffIndexCmdObj(diffArgs ...string) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff-index").
Config("diff.noprefix=false").
Arg("--submodule", "--no-ext-diff", "--no-color", "--patch").
Arg(diffArgs...).ToArgv(),
)
}