package helpers import ( "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/modes/diffing" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type DiffHelper struct { c *HelperCommon // diffLineHelper says how a diff for the main view is to be produced, which depends // on whether the focused main view could act on what a diff renderer would make of it. diffLineHelper *DiffLineHelper // Diffs of the messages of "amend!" commits, keyed by everything that // shapes them: the two commits, and the diff renderer and width they were // rendered by. An empty diff means that the commit doesn't change the // message. Only accessed on the UI thread, while rendering the main view. commitMessageDiffs map[string]string } func NewDiffHelper(c *HelperCommon, diffLineHelper *DiffLineHelper) *DiffHelper { return &DiffHelper{ c: c, diffLineHelper: diffLineHelper, commitMessageDiffs: make(map[string]string), } } func (self *DiffHelper) DiffArgs() []string { output := []string{"--stat", "-p", self.c.Modes().Diffing.Ref} right := self.currentDiffTerminal() if right != "" { output = append(output, right) } if self.c.Modes().Diffing.Reverse { output = append(output, "-R") } output = append(output, "--") file := self.currentlySelectedFilename() if file != "" { output = append(output, file) } else if self.c.Modes().Filtering.Active() { output = append(output, self.c.Modes().Filtering.GetPath()) } return output } // Returns an update task that can be passed to RenderToMainViews to render a // diff for the selected commit(s). We need to pass both the selected commit // and the refRange for a range selection. If the refRange is nil (meaning that // either there's no range, or it can't be diffed for some reason), then we want // to fall back to rendering the diff for the single commit. // In addition, we need to pass the list of all commits; this is needed for // showing the commit message diff for "amend!" commits. func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff( commits []*models.Commit, commit *models.Commit, refRange *types.RefRange, ) types.UpdateTask { mode := self.diffLineHelper.MainViewDiffMode() if refRange != nil { from, to := refRange.From, refRange.To args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"} args = append(args, "--") if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" { // If both refs are commits, filter by the union of their paths. This is useful for // example when diffing a range of commits in filter-by-path mode across a rename. fromCommit, ok1 := from.(*models.Commit) toCommit, ok2 := to.(*models.Commit) if ok1 && ok2 { paths := append(self.FilterPathsForCommit(fromCommit), self.FilterPathsForCommit(toCommit)...) args = append(args, lo.Uniq(paths)...) } else { // If either ref is not a commit (which is possible in sticky diff mode, when // diffing against a branch or tag), we just filter by the filter path; that's the // best we can do in this case. args = append(args, filterPath) } } cmdObj := self.c.Git().Diff.DiffCmdObj(args, mode) prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName()) return types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), prefix, mode) } cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), mode) return types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), self.commitMessageDiffPrefix(commits, commit), mode) } // For an "amend!" commit, returns a diff of the commit message it sets against // the message it replaces, to be shown above the commit's own diff. Returns an // empty string for any other commit, and for an "amend!" commit that only // changes the contents of the commit it applies to. func (self *DiffHelper) commitMessageDiffPrefix(commits []*models.Commit, commit *models.Commit) string { previousCommit, ok := findCommitWithPreviousMessage(commits, commit) if !ok { return "" } width, height := self.c.Contexts().Normal.GetView().InnerSize() diff := self.commitMessageDiff(commit, previousCommit, width, height) if diff == "" { return "" } header := style.FgYellow.Sprintf("%s\n", utils.ResolvePlaceholderString( self.c.Tr.CommitMessageChanges, map[string]string{"hash": previousCommit.ShortHash()}, )) rule := strings.Repeat("─", width) + "\n" return header + diff + rule } // The names the two messages are diffed under. A diff renderer shows them as the // names of the files being diffed, so they are what tells the reader which side // is which. They are not translated because they end up as file names, and git // mangles paths outside of ASCII when it states them in a diff. const ( oldMessageName = "old message" newMessageName = "new message" ) func (self *DiffHelper) commitMessageDiff( commit *models.Commit, previousCommit *models.Commit, width int, height int, ) string { // The diff renderer lays the diff out, and lays it out for the width it is // shown at, so both belong in the key along with the two messages. key := fmt.Sprintf("%s\x00%s\x00%d\x00%s", commit.Hash(), previousCommit.Hash(), width, self.c.State().GetDiffRendererConfigManager().Signature()) if diff, ok := self.commitMessageDiffs[key]; ok { return diff } messages, err := self.c.Git().Commit.GetCommitMessages([]string{previousCommit.Hash(), commit.Hash()}) if err != nil { self.c.Log.Error(err) return "" } before := messageAfterAmending(messages[0]) after := messageAfterAmending(messages[1]) diff := "" if before != after { diff, err = self.c.Git().Diff.RenderedTextDiff( git_commands.NamedText{Name: oldMessageName, Content: before}, git_commands.NamedText{Name: newMessageName, Content: after}, width, height) if err != nil { self.c.Log.Error(err) return "" } } self.commitMessageDiffs[key] = diff return diff } // PlainDiffBetweenRefs returns the diff of the given files between two refs as git // writes it, without colour or a diff renderer's involvement — what a panel showing // a commit's diff hands out as the diff behind its rendering (see // types.FocusedMainViewDiffSource). It honours diffing mode, so that the diff is of // the same two ends the main view is showing. func (self *DiffHelper) PlainDiffBetweenRefs(from string, to string, paths []string) string { from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) // An error means there is no diff to be had, which for our purposes is the same // as an empty one. diff, _ := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, git_commands.DiffModePlain).RunWithOutput() return diff } func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string { filterPath := self.c.Modes().Filtering.GetPath() if filterPath != "" { if len(commit.FilterPaths) > 0 { return commit.FilterPaths } return []string{filterPath} } return nil } func (self *DiffHelper) ExitDiffMode() error { self.c.Modes().Diffing = diffing.New() self.c.Refresh(types.RefreshOptions{}) return nil } func (self *DiffHelper) RenderDiff() { args := self.DiffArgs() cmdObj := self.c.Git().Diff.DiffCmdObj(args, git_commands.DiffModeRendered) prefix := style.FgMagenta.Sprintf( "%s %s\n\n", self.c.Tr.ShowingGitDiff, "git diff "+strings.Join(args, " "), ) task := types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix) self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: "Diff", SubTitle: self.IgnoringWhitespaceSubTitle(), Task: task, }, }) } // CurrentDiffTerminals returns the current diff terminals of the currently selected item. // in the case of a branch it returns both the branch and it's upstream name, // which becomes an option when you bring up the diff menu, but when you're just // flicking through branches it will be using the local branch name. func (self *DiffHelper) CurrentDiffTerminals() []string { c := self.c.Context().CurrentSide() if c.GetKey() == "" { return nil } switch v := c.(type) { case types.DiffableContext: return v.GetDiffTerminals() } return nil } func (self *DiffHelper) currentDiffTerminal() string { names := self.CurrentDiffTerminals() if len(names) == 0 { return "" } return names[0] } func (self *DiffHelper) currentlySelectedFilename() string { currentContext := self.c.Context().Current() switch currentContext := currentContext.(type) { case types.IListContext: if lo.Contains([]types.ContextKey{context.FILES_CONTEXT_KEY, context.COMMIT_FILES_CONTEXT_KEY}, currentContext.GetKey()) { return currentContext.GetSelectedItemId() } } return "" } func (self *DiffHelper) WithDiffModeCheck(f func()) { if self.c.Modes().Diffing.Active() { self.RenderDiff() } else { f() } } func (self *DiffHelper) IgnoringWhitespaceSubTitle() string { if self.c.UserConfig().Git.IgnoreWhitespaceInDiffView { return self.c.Tr.IgnoreWhitespaceDiffViewSubTitle } return "" } func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error { to := selectedRef.RefName() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff("") _, err := self.c.RunSubprocess(self.c.Git().Diff.OpenDiffToolCmdObj( git_commands.DiffToolCmdOptions{ Filepath: ".", FromCommit: from, ToCommit: to, Reverse: reverse, IsDirectory: true, Staged: false, })) return err } // AdjustLineNumber is used to adjust a line number in the diff that's currently // being viewed, so that it corresponds to the line number in the actual working // copy state of the file. It is used when clicking on a delta hyperlink in a // diff, or when pressing `e` in a focused diff. It works // by getting a diff of what's being viewed in the main view against the working // copy, and then using that diff to adjust the line number. // path is the file path of the file being viewed // linenumber is the line number to adjust (one-based) // viewname is the name of the view that shows the diff. We need to pass it // because the diff adjustment is slightly different depending on which view is // showing the diff. func (self *DiffHelper) AdjustLineNumber(path string, linenumber int, viewname string) int { switch viewname { case "main": if diffableContext, ok := self.c.Context().CurrentSide().(types.DiffableContext); ok { ref := diffableContext.RefForAdjustingLineNumberInDiff() if len(ref) != 0 { return self.adjustLineNumber(linenumber, ref, "--", path) } } // if the type cast to DiffableContext returns false, we are in the // unstaged changes view of the Files panel; no need to adjust line // numbers in this case case "secondary": return self.adjustLineNumber(linenumber, "--", path) } return linenumber } func (self *DiffHelper) adjustLineNumber(linenumber int, diffArgs ...string) int { args := append([]string{"--unified=0"}, diffArgs...) diff, err := self.c.Git().Diff.GetDiff(false, args...) if err != nil { return linenumber } patch := patch.Parse(diff) return patch.AdjustLineNumber(linenumber) }