diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index d5cd75bac..7a50ef774 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -3,6 +3,7 @@ package git_commands import ( "fmt" "os" + "regexp" "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -25,20 +26,23 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands { // 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. +// metadataRecordPattern matches a per-line record rather than the handshake: a record +// carries fields after the version, so a second ";" follows it. Used where the +// handshake alone doesn't answer whether records will actually follow (see +// rawGitEmitsMetadata). +var metadataRecordPattern = regexp.MustCompile(`\x1b]1717;\d+;`) + +// ProbePagerEmitsDiffMetadata reports whether the configured diff renderer speaks the +// diff-line-metadata protocol, by running it and checking its output. It's the focused +// main view's signal for whether it can act on the rendered diff or must fall back to +// the raw diff (see StagingHelper.DiffMainViewShouldRenderRaw). The verdict is +// content-independent, so the caller caches it per renderer. // -// No PTY is needed: git needs a terminal to decide to invoke a pager, but the pager -// itself emits the handshake whenever OSC1717 is set, so we can run it -// directly with empty input. +// No PTY is needed: git needs a terminal to decide to invoke a pager, but a renderer +// emits its records whenever OSC1717 is set, so we can run it directly. // // 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; +// .gitattributes and a single diff can mix drivers, so there's no one renderer 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 != "" { @@ -47,9 +51,52 @@ func (self *DiffCommands) ProbePagerEmitsDiffMetadata() bool { if pagerCmd := self.diffRendererConfigManager.GetStdinFilterCommand(0); pagerCmd != "" { return self.probeEmitsMetadata(self.cmd.NewShell(pagerCmd, "")) } + if rawGitArgs := self.diffRendererConfigManager.GetRawGitArgs(); len(rawGitArgs) > 0 { + return self.rawGitEmitsMetadata(rawGitArgs) + } return false } +// rawGitEmitsMetadata probes git itself, run with the diff renderer's own arguments, on +// a synthetic diff of two differing temp files. +// +// It looks for an actual record where the other probes settle for the handshake. What we +// depend on is that the rows we are about to show carry records, and only running git +// the way we will run it answers that: git annotates only the formats whose output can't +// be read back from its own text, so an installed git that doesn't speak the protocol +// and arguments that select no word diff both leave us without records. Looking for a +// record covers both, and keeps this independent of how git decides to announce itself. +func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool { + oldFile, err := os.CreateTemp("", "lazygit-probe-old-*") + if err != nil { + return false + } + defer os.Remove(oldFile.Name()) + fmt.Fprintln(oldFile, "old") + oldFile.Close() + + newFile, err := os.CreateTemp("", "lazygit-probe-new-*") + if err != nil { + return false + } + defer os.Remove(newFile.Name()) + fmt.Fprintln(newFile, "new") + newFile.Close() + + cmdObj := self.cmd.New( + NewGitCmd("diff"). + Arg("--no-index"). + Arg(rawGitArgs...). + Arg(oldFile.Name(), newFile.Name()). + ToArgv(), + ) + cmdObj.AddEnvVars("OSC1717=V1") + // git exits non-zero because the two files differ, which is the point; the output + // is captured either way. + output, _ := cmdObj.RunWithOutput() + return metadataRecordPattern.MatchString(output) +} + // 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 diff --git a/pkg/gui/controllers/helpers/staging_helper.go b/pkg/gui/controllers/helpers/staging_helper.go index 2666c56ce..29330b0c4 100644 --- a/pkg/gui/controllers/helpers/staging_helper.go +++ b/pkg/gui/controllers/helpers/staging_helper.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" "regexp" + "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" @@ -898,21 +899,26 @@ func (self *StagingHelper) diffLineInfoFromHyperlink(hyperlink string) (types.Di }, true } -// --- The raw-diff fallback for unsupported pagers --- +// --- The raw-diff fallback for unsupported diff renderers --- // // The focused main view is the staging surface, so it must be able to resolve the -// diff it shows to patch-space. A pager that restructures the diff without emitting +// diff it shows to patch-space. A renderer that restructures the diff without emitting // our metadata (stock delta-default, plain difftastic, `cat -n`) produces output we // can't resolve, so when such a diff is focused we 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. +// colour, no renderer) — the same content a no-renderer setup shows, which the buffer +// parser handles. Browsing keeps the pretty output; only focusing to act switches to +// raw. // -// Whether a pager is usable is decided by probing it for the metadata handshake (see -// DiffCommands.ProbePagerEmitsDiffMetadata) — a pager-level, content-independent fact, -// so the verdict is stable (a binary file, which has no change lines under any pager, -// can't mislead it) and known before we render, so we never render pretty only to -// discover mid-flight that we should have rendered raw. +// This is not only about pagers: git's own word-diff formats (a rawGit renderer whose +// arguments include --color-words or --word-diff) restructure the diff just as much, +// their markup being inline, and a git that speaks the protocol annotates them for +// exactly that reason. So the question is asked of whatever renders, git included. +// +// Whether a renderer is usable is decided by probing it (see +// DiffCommands.ProbePagerEmitsDiffMetadata) — a renderer-level, content-independent +// fact, so the verdict is stable (a binary file, which has no change lines under any +// renderer, can't mislead it) and known before we render, so we never render pretty +// only to discover mid-flight that we should have rendered raw. // pagerSupportsMetadata returns whether the current pager speaks the metadata // protocol, probing it once and caching the result, re-probing when the pager changes. @@ -932,8 +938,9 @@ func (self *StagingHelper) pagerSupportsMetadata() bool { func (self *StagingHelper) currentPagerSignature() string { pc := self.c.State().GetDiffRendererConfigManager() index, _ := pc.CurrentDiffRendererIndex() - return fmt.Sprintf("%d\x00%s\x00%s", - index, pc.GetExternalDiffCommand(3), pc.GetStdinFilterCommand(0)) + return fmt.Sprintf("%d\x00%s\x00%s\x00%s", + index, pc.GetExternalDiffCommand(3), pc.GetStdinFilterCommand(0), + strings.Join(pc.GetRawGitArgs(), "\x00")) } // MainViewPagerConfigured reports whether any custom pager is in effect for the @@ -951,15 +958,25 @@ func (self *StagingHelper) rendererIsRawGitWithArgs() bool { len(pc.GetRawGitArgs()) > 0 } +// mainViewDiffNeedsMetadata reports whether the diff shown in the main view is one we +// can't recover the patch-space identity of a row from by reading the diff's own text, +// and so can only act on through the metadata records. Any custom renderer restructures +// the diff; so does git itself once the renderer's arguments select a word diff, whose +// markup is inline. Plain `git diff` output is its own answer and needs no records. +func (self *StagingHelper) mainViewDiffNeedsMetadata() bool { + return self.MainViewPagerConfigured() || self.rendererIsRawGitWithArgs() +} + // DiffMainViewShouldRenderRaw reports whether a side panel rendering its diff into -// the focused main view should bypass the pager and render the raw (git-coloured) -// diff. This holds while the main view holds focus and the configured pager doesn't -// speak the metadata protocol. Side panels consult it when building their main-view -// diff (see e.g. FilesController.GetOnRenderToMain) so that a re-render while focused -// — after staging a hunk, say — stays raw. +// the focused main view should bypass the diff renderer and render the raw +// (git-coloured) diff. This holds while the main view holds focus and the diff we would +// otherwise show needs the metadata records to be resolvable but won't carry them. +// Side panels consult it when building their main-view diff (see e.g. +// FilesController.GetOnRenderToMain) so that a re-render while focused — after staging +// a hunk, say — stays raw. func (self *StagingHelper) DiffMainViewShouldRenderRaw() bool { - return self.focusedOnMainView() && ((self.MainViewPagerConfigured() && !self.pagerSupportsMetadata()) || - self.rendererIsRawGitWithArgs()) + return self.focusedOnMainView() && self.mainViewDiffNeedsMetadata() && + !self.pagerSupportsMetadata() } func (self *StagingHelper) focusedOnMainView() bool {