mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-28 02:07:09 -04:00
Compare commits
81
Commits
@@ -0,0 +1,142 @@
|
||||
// author_colors_repo creates a git repository for checking that the colors
|
||||
// lazygit gives to authors are readable.
|
||||
//
|
||||
// If gui.authorColors names no color for an author, lazygit derives one from a
|
||||
// hash of their name. The authors of an ordinary repository rarely land near the
|
||||
// edges of the range that this color is picked from. Every commit in the
|
||||
// repository created here is by an author at one of those edges: the lowest or
|
||||
// highest lightness, combined with the lowest or highest saturation, at twelve
|
||||
// hues around the color wheel. The commit subject says which edge it is.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/author_colors_repo <path>
|
||||
//
|
||||
// Then open the repository with lazygit in each terminal theme you want to check.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
|
||||
)
|
||||
|
||||
type extreme int
|
||||
|
||||
const (
|
||||
lowest extreme = iota
|
||||
highest
|
||||
)
|
||||
|
||||
func (self extreme) String() string {
|
||||
if self == lowest {
|
||||
return "min"
|
||||
}
|
||||
return "max"
|
||||
}
|
||||
|
||||
func (self extreme) matches(fraction float64) bool {
|
||||
if self == lowest {
|
||||
return fraction < 0.01
|
||||
}
|
||||
return fraction >= 0.99
|
||||
}
|
||||
|
||||
const (
|
||||
numHues = 12
|
||||
|
||||
// Small enough that the windows of neighbouring hues don't overlap
|
||||
hueTolerance = 0.02
|
||||
)
|
||||
|
||||
type commit struct {
|
||||
author string
|
||||
subject string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
log.Fatalf("usage: %s <path>", os.Args[0])
|
||||
}
|
||||
path := os.Args[1]
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log.Fatalf("%s already exists", path)
|
||||
}
|
||||
|
||||
extremes := []extreme{lowest, highest}
|
||||
commits := make([]commit, 0, len(extremes)*len(extremes)*numHues)
|
||||
for _, lightness := range extremes {
|
||||
for _, saturation := range extremes {
|
||||
for i := range numHues {
|
||||
hue := float64(i) / numHues
|
||||
author, actualHue := findAuthor(lightness, saturation, hue)
|
||||
subject := fmt.Sprintf("lightness %s, saturation %s, hue %.0f°", lightness, saturation, actualHue*360)
|
||||
commits = append(commits, commit{author: author, subject: subject})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := runGit("", nil, "init", "-q", path); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Commit in reverse, so that the commits panel lists them in the order above
|
||||
startTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range commits {
|
||||
c := commits[len(commits)-1-i]
|
||||
date := fmt.Sprintf("%d +0000", startTime.Add(time.Duration(i)*time.Hour).Unix())
|
||||
env := []string{
|
||||
"GIT_AUTHOR_NAME=" + c.author,
|
||||
"GIT_AUTHOR_EMAIL=author@example.com",
|
||||
"GIT_AUTHOR_DATE=" + date,
|
||||
"GIT_COMMITTER_NAME=" + c.author,
|
||||
"GIT_COMMITTER_EMAIL=author@example.com",
|
||||
"GIT_COMMITTER_DATE=" + date,
|
||||
}
|
||||
if err := runGit(path, env, "commit", "-q", "--allow-empty", "-m", c.subject); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Created %s with %d commits\n", path, len(commits))
|
||||
}
|
||||
|
||||
// findAuthor returns the first name of the form "L<lightness> S<saturation> <n>"
|
||||
// whose color lies at the given extremes of lightness and saturation, and close
|
||||
// to the given hue. It also returns the hue that the name lands on.
|
||||
//
|
||||
// Every name of this form has the same initials, so the color is the only
|
||||
// thing that differs between authors in the commits panel.
|
||||
func findAuthor(lightness extreme, saturation extreme, hue float64) (string, float64) {
|
||||
for n := 1; ; n++ {
|
||||
name := fmt.Sprintf("L%s S%s %d", lightness, saturation, n)
|
||||
h, s, l := authors.ColorPosition(name)
|
||||
if lightness.matches(l) && saturation.matches(s) && hueDistance(h, hue) <= hueTolerance {
|
||||
return name, h
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hueDistance is the distance between two hues on the color wheel, where each
|
||||
// hue is a fraction of a full turn.
|
||||
func hueDistance(a float64, b float64) float64 {
|
||||
d := math.Abs(a - b)
|
||||
return math.Min(d, 1-d)
|
||||
}
|
||||
|
||||
func runGit(dir string, env []string, args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
// Keep the user's git config out of it, so that hooks, commit signing and
|
||||
// the like don't apply, and every run creates the same commits
|
||||
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1")
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
+88
-23
@@ -37,12 +37,6 @@ This is only meant as a reference for what config options exist, and what their
|
||||
```yaml
|
||||
# Config relating to the Lazygit UI
|
||||
gui:
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
|
||||
authorColors: {}
|
||||
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
branchColorPatterns: {}
|
||||
|
||||
# Custom icons for filenames and file extensions
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color
|
||||
customIcons:
|
||||
@@ -169,6 +163,14 @@ gui:
|
||||
# Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
|
||||
shortTimeFormat: 3:04PM
|
||||
|
||||
# Whether the terminal has a dark or a light background. This decides whether
|
||||
# 'darkTheme' or 'lightTheme' applies, and the colors of authors are picked to
|
||||
# stand out against it.
|
||||
# One of: 'auto' (default) | 'dark' | 'light'
|
||||
# With 'auto', lazygit asks the terminal, and assumes a dark background if the
|
||||
# terminal doesn't tell.
|
||||
colorScheme: auto
|
||||
|
||||
# Config relating to colors and styles.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
|
||||
theme:
|
||||
@@ -179,7 +181,7 @@ gui:
|
||||
|
||||
# Border color of non-focused windows
|
||||
inactiveBorderColor:
|
||||
- default
|
||||
- dim
|
||||
|
||||
# Border color of focused window when searching in that window
|
||||
searchingActiveBorderColor:
|
||||
@@ -190,14 +192,22 @@ gui:
|
||||
optionsTextColor:
|
||||
- blue
|
||||
|
||||
# Color and attributes of the text of the selected line. The attributes are
|
||||
# added to those of the text, and a color replaces the colors of the text.
|
||||
# Set it to 'default' to leave the text as it is, e.g. if you don't want the
|
||||
# selected line in bold.
|
||||
selectedLineFgColor:
|
||||
- bold
|
||||
|
||||
# Background color of selected line.
|
||||
# Default: 'blue' if the terminal has a dark background, or a suitable RGB blue
|
||||
# computed from the background color if it is light.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
|
||||
selectedLineBgColor:
|
||||
- blue
|
||||
selectedLineBgColor: []
|
||||
|
||||
# Background color of selected line when view doesn't have focus.
|
||||
inactiveViewSelectedLineBgColor:
|
||||
- bold
|
||||
# Default: a suitable RGB grey computed from the terminal's background color.
|
||||
inactiveViewSelectedLineBgColor: []
|
||||
|
||||
# Foreground color of copied commit
|
||||
cherryPickedCommitFgColor:
|
||||
@@ -223,6 +233,22 @@ gui:
|
||||
defaultFgColor:
|
||||
- default
|
||||
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
|
||||
authorColors: {}
|
||||
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
branchColorPatterns: {}
|
||||
|
||||
# Colors and styles that override those in 'theme' when the terminal has a dark
|
||||
# background. It has the same fields as 'theme'.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
|
||||
darkTheme: {}
|
||||
|
||||
# Colors and styles that override those in 'theme' when the terminal has a light
|
||||
# background. It has the same fields as 'theme'.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
|
||||
lightTheme: {}
|
||||
|
||||
# Config relating to the commit length indicator
|
||||
commitLength:
|
||||
# If true, show an indicator of commit message length
|
||||
@@ -443,7 +469,9 @@ git:
|
||||
# If not "none", lazygit will automatically fast-forward local branches to match
|
||||
# their upstream after fetching. Applies to branches that are not the currently
|
||||
# checked out branch, and only to those that are strictly behind their upstream
|
||||
# (as opposed to diverged).
|
||||
# (as opposed to diverged). A branch that is checked out in another worktree is
|
||||
# fast-forwarded there, unless that worktree has changes to tracked files or is
|
||||
# in the middle of a rebase or bisect.
|
||||
# Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
|
||||
autoForwardBranches: onlyMainBranches
|
||||
|
||||
@@ -999,6 +1027,7 @@ The available attributes are:
|
||||
|
||||
- bold
|
||||
- default
|
||||
- dim # faint text; not supported by every terminal
|
||||
- reverse # useful for high-contrast
|
||||
- underline
|
||||
- strikethrough
|
||||
@@ -1023,28 +1052,61 @@ gui:
|
||||
- reverse
|
||||
```
|
||||
|
||||
The text of the selected line is bold by default. If you don't want that, set `selectedLineFgColor` to `default`:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
theme:
|
||||
selectedLineFgColor:
|
||||
- default
|
||||
```
|
||||
|
||||
## Themes for dark and light backgrounds
|
||||
|
||||
The colors in `gui.theme` apply whether your terminal has a dark or a light background. If you want different colors for the two, set them in `gui.darkTheme` or `gui.lightTheme`. These have the same fields as `gui.theme`, and a field that you set in them overrides the one in `gui.theme`:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
theme:
|
||||
activeBorderColor:
|
||||
- green
|
||||
- bold
|
||||
lightTheme:
|
||||
activeBorderColor:
|
||||
- blue
|
||||
- bold
|
||||
```
|
||||
|
||||
For `authorColors` and `branchColorPatterns`, each entry overrides the one with the same key in `gui.theme`, and the other entries of `gui.theme` still apply. Branch color patterns of `gui.darkTheme` or `gui.lightTheme` come before those of `gui.theme`.
|
||||
|
||||
Lazygit asks the terminal whether its background is dark or light. If your terminal doesn't tell, lazygit assumes a dark background; set `gui.colorScheme` to `light` if yours is light.
|
||||
|
||||
## Custom Author Color
|
||||
|
||||
Lazygit will assign a random color for every commit author in the commits pane by default.
|
||||
|
||||
These colors are picked to be readable against the background of your terminal, and lazygit asks the terminal whether its background is dark or light. If your terminal doesn't tell, lazygit assumes a dark background; set `gui.colorScheme` to `light` if yours is light.
|
||||
|
||||
You can customize the color in case you're not happy with the randomly assigned one:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
authorColors:
|
||||
'John Smith': 'red' # use red for John Smith
|
||||
'Alan Smithee': '#00ff00' # use green for Alan Smithee
|
||||
theme:
|
||||
authorColors:
|
||||
'John Smith': 'red' # use red for John Smith
|
||||
'Alan Smithee': '#00ff00' # use green for Alan Smithee
|
||||
```
|
||||
|
||||
You can use wildcard to set a unified color in case your are lazy to customize the color for every author or you just want a single color for all/other authors:
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
authorColors:
|
||||
# use red for John Smith
|
||||
'John Smith': 'red'
|
||||
# use blue for other authors
|
||||
'*': '#0000ff'
|
||||
theme:
|
||||
authorColors:
|
||||
# use red for John Smith
|
||||
'John Smith': 'red'
|
||||
# use blue for other authors
|
||||
'*': '#0000ff'
|
||||
```
|
||||
|
||||
## Custom Branch Color
|
||||
@@ -1053,13 +1115,16 @@ You can customize the color of branches based on branch patterns (regular expres
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
branchColorPatterns:
|
||||
'^docs/': '#11aaff' # use a light blue for branches beginning with 'docs/'
|
||||
'ISSUE-\d+': '#ff5733' # use a bright orange for branches containing 'ISSUE-<some-number>'
|
||||
theme:
|
||||
branchColorPatterns:
|
||||
'^docs/': '#11aaff' # use a light blue for branches beginning with 'docs/'
|
||||
'ISSUE-\d+': '#ff5733' # use a bright orange for branches containing 'ISSUE-<some-number>'
|
||||
```
|
||||
|
||||
Note that the regular expressions are not implicitly anchored to the beginning/end of the branch name. If you want to do that, add leading `^` and/or trailing `$` as needed.
|
||||
|
||||
If several patterns match a branch, the first one wins.
|
||||
|
||||
## Custom Files Icon & Color
|
||||
|
||||
You can customize the icon and color of files based on filenames or extensions:
|
||||
|
||||
@@ -23,22 +23,29 @@ Fields only for `extDiff`:
|
||||
|
||||
- **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
|
||||
You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool.
|
||||
|
||||
Fields only for `rawGit`:
|
||||
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
|
||||
|
||||
The `command` of a `stdinFilter` or `extDiff` renderer is a [Go template](https://pkg.go.dev/text/template) with these variables:
|
||||
|
||||
- `{{width}}`: the width of the view that the diff is rendered into.
|
||||
- `{{colorScheme}}`: `dark` or `light`, depending on whether the terminal has a dark or a light background. Lazygit asks the terminal about this; if yours doesn't tell, set `gui.colorScheme`.
|
||||
- `{{columnWidth}}` (only for `stdinFilter`): the width of one side of a side-by-side rendering, e.g. for `ydiff -p cat -s -w {{columnWidth}}`.
|
||||
- `{{diffContext}}` (only for `extDiff`): lazygit's current diff context size, the value controlled by the `{`/`}` keybindings.
|
||||
|
||||
A variable can also be written with a leading dot, as in `{{.width}}`. The command can use template expressions too; for example, `delta --paging=never {{if gt .width 160}}--side-by-side{{end}}` shows the diff side by side only when there is room for it, or `delta --syntax-theme={{if eq .colorScheme "light"}}Github{{else}}Dracula{{end}}` picks a different syntax theme based on the background.
|
||||
|
||||
Here's an example for a multi-renderer setup:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: delta --dark --paging=never
|
||||
- command: delta --{{colorScheme}} --paging=never
|
||||
- command: ydiff -p cat
|
||||
colorArg: never
|
||||
- type: extDiff
|
||||
command: difft --color=always --context={{diffContext}}
|
||||
command: difft --color=always --background={{colorScheme}} --context={{diffContext}}
|
||||
- type: rawGit
|
||||
args: [--color-words]
|
||||
name: color-words
|
||||
@@ -51,12 +58,14 @@ git:
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: delta --dark --paging=never
|
||||
- command: delta --{{colorScheme}} --paging=never
|
||||
```
|
||||
|
||||

|
||||
|
||||
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
|
||||
`--{{colorScheme}}` passes `--dark` or `--light` to delta, so that it matches the background of your terminal.
|
||||
|
||||
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --{{colorScheme}} --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
|
||||
|
||||
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
|
||||
|
||||
|
||||
@@ -16,3 +16,36 @@ branches properly stacked onto it.
|
||||
Lazygit visualizes the individual branch heads in the stack by marking them with a
|
||||
cyan asterisk (or a cyan branch symbol if you are using [nerd
|
||||
fonts](Config.md#display-nerd-fonts-icons)).
|
||||
|
||||
When you push the topmost branch of the stack with `P`, and the branches below
|
||||
it have commits that haven't been pushed yet, lazygit offers to push them along
|
||||
with it. After rebasing the stack this saves you from checking out and
|
||||
force-pushing every branch one by one; you are asked to confirm the force push
|
||||
once for all of them. Only branches that already have an upstream are included.
|
||||
Each of them is pushed to where `git push` would push it if it were checked out,
|
||||
so your push configuration applies to them as usual.
|
||||
|
||||
When somebody else rebases the stack and force-pushes it, all your branches
|
||||
show up as diverged, for example `↓5↑3`, even though the commits they are ahead
|
||||
by are only the old versions of the ones that are now on the remote. Lazygit
|
||||
tells this apart from a branch that carries work of your own, and shows the
|
||||
divergence dimmed for such a branch. Pressing `f` on it resets it to its
|
||||
upstream instead of refusing, so you don't have to check the branch out and pull
|
||||
it. Lazygit only does this when every commit of the branch was on its remote
|
||||
branch at some point. It finds that out from the reflog of the remote-tracking
|
||||
branch. Reflogs are enabled by default, except in a bare repository; if you work
|
||||
in one with linked worktrees, set `core.logAllRefUpdates` to true there to make
|
||||
this work.
|
||||
|
||||
`f` works on a [range selection](Range_Select.md) too, so you can select the
|
||||
whole stack and bring all of it back in sync at once. If any of the selected
|
||||
branches can't be updated, none of them is, so that you don't end up with half
|
||||
of the stack updated.
|
||||
|
||||
Alternatively, check out the topmost branch of the stack and pull it with `p`.
|
||||
If branches below it can be updated this way, or are simply behind their
|
||||
upstream, lazygit offers to update them along with it. Lazygit decides this
|
||||
from the last fetch, so a branch whose changes on the remote haven't been
|
||||
fetched yet isn't offered; with auto-fetch turned off, pull a second time after
|
||||
the first pull has fetched them. Branches that are checked out in another
|
||||
worktree are left alone. The topmost branch itself is pulled as usual.
|
||||
|
||||
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Scroll up main window | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Scroll down main window | |
|
||||
| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
|
||||
| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
@@ -189,7 +189,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` d `` | Delete | View delete options for local/remote branch. |
|
||||
| `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. |
|
||||
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
|
||||
| `` T `` | New tag | |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | Reset | |
|
||||
|
||||
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | |
|
||||
| `` @ `` | 명령어 로그 메뉴 열기 | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
|
||||
| `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | Diff 보기의 변경 사항 주위에 표시되는 컨텍스트의 크기를 늘리기 | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
@@ -223,7 +223,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` d `` | 삭제 | View delete options for local/remote branch. |
|
||||
| `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. |
|
||||
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
|
||||
| `` T `` | 태그를 생성 | |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | View reset options | |
|
||||
|
||||
@@ -113,7 +113,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` d `` | Verwijderen | View delete options for local/remote branch. |
|
||||
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
|
||||
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. |
|
||||
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
|
||||
| `` T `` | Creëer tag | |
|
||||
| `` s `` | Sort order | |
|
||||
| `` g `` | Bekijk reset opties | |
|
||||
|
||||
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Прокрутить вверх главную панель | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Прокрутить вниз главную панель | |
|
||||
| `` @ `` | Открыть меню журнала команд | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
|
||||
| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
|
||||
| `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | Увеличить размер контекста, отображаемого вокруг изменений в просмотрщике сравнении | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
@@ -223,7 +223,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` d `` | Delete | View delete options for local/remote branch. |
|
||||
| `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. |
|
||||
| `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) |
|
||||
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. |
|
||||
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
|
||||
| `` T `` | Создать тег | |
|
||||
| `` s `` | Порядок сортировки | |
|
||||
| `` g `` | Просмотреть параметры сброса | |
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ func NewGitCommandAux(
|
||||
gitHubCommands := git_commands.NewGitHubCommands(gitCommon)
|
||||
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
|
||||
|
||||
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
|
||||
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, branchCommands.HasLocalOnlyCommits, configCommands)
|
||||
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
|
||||
commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon)
|
||||
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
|
||||
|
||||
@@ -285,11 +285,26 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// Returns whether refName can be fast-forward merged into the current branch
|
||||
func (self *BranchCommands) CanDoFastForwardMerge(refName string) bool {
|
||||
// Fast-forwards the branch that is checked out in the given worktree to the
|
||||
// given ref. Fails if that can't be done without a merge commit. Pass empty
|
||||
// strings for the worktree to use the current one.
|
||||
func (self *BranchCommands) FastForwardMerge(refName string, worktreeGitDir string, worktreePath string) error {
|
||||
cmdArgs := NewGitCmd("merge").
|
||||
Arg("--ff-only").
|
||||
Arg(refName).
|
||||
GitDirIf(worktreeGitDir != "", worktreeGitDir).
|
||||
WorktreePathIf(worktreePath != "", worktreePath).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// Returns whether the first ref is an ancestor of the second one, which also
|
||||
// means that the second one can be fast-forward merged into the first one
|
||||
func (self *BranchCommands) IsAncestor(ancestorRefName string, refName string) bool {
|
||||
cmdArgs := NewGitCmd("merge-base").
|
||||
Arg("--is-ancestor").
|
||||
Arg("HEAD", refName).
|
||||
Arg(ancestorRefName, refName).
|
||||
ToArgv()
|
||||
err := self.cmd.New(cmdArgs).DontLog().Run()
|
||||
return err == nil
|
||||
@@ -353,9 +368,85 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *
|
||||
return stdout == "", nil
|
||||
}
|
||||
|
||||
func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error {
|
||||
// Returns whether the given branch has commits of its own, meaning commits
|
||||
// that its remote branch never contained. Those are the commits that would be
|
||||
// lost if we reset the branch to its upstream.
|
||||
//
|
||||
// A branch that has diverged from its upstream doesn't necessarily have any
|
||||
// commits of its own. If somebody else rewrote the remote branch and
|
||||
// force-pushed it, our branch is still at the commits it had before, and all
|
||||
// of those were on the remote branch at some point. The reflog of the
|
||||
// remote-tracking branch records the values it had before it was rewritten, so
|
||||
// a commit that was ever on the remote branch is contained in one of them.
|
||||
func (self *BranchCommands) HasLocalOnlyCommits(branch *models.Branch) (bool, error) {
|
||||
upstreamValues := append(
|
||||
[]string{branch.FullUpstreamRefName()},
|
||||
self.previousUpstreamValues(branch.FullUpstreamRefName())...,
|
||||
)
|
||||
|
||||
cmdArgs := NewGitCmd("rev-list").
|
||||
Arg("--max-count=1").
|
||||
// A value that the remote-tracking branch had long ago might not be
|
||||
// available any more, e.g. in a partial clone. Skip it rather than
|
||||
// failing; it only means we exclude fewer commits.
|
||||
Arg("--ignore-missing").
|
||||
Arg(branch.FullRefName()).
|
||||
Arg(lo.Map(upstreamValues, func(value string, _ int) string {
|
||||
return "^" + value
|
||||
})...).
|
||||
Arg("--").
|
||||
ToArgv()
|
||||
|
||||
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return stdout != "", nil
|
||||
}
|
||||
|
||||
// Returns the values that the given remote-tracking branch had before its
|
||||
// current one, as far back as its reflog goes. Returns nothing if the reflog
|
||||
// is unavailable, for example because core.logAllRefUpdates is false; a branch
|
||||
// that is strictly behind its upstream is recognized without it.
|
||||
func (self *BranchCommands) previousUpstreamValues(upstreamRef string) []string {
|
||||
cmdArgs := NewGitCmd("reflog").
|
||||
Arg("show").
|
||||
Arg("--format=%H").
|
||||
Arg(upstreamRef).
|
||||
ToArgv()
|
||||
|
||||
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Each entry holds the value that the ref was updated to.
|
||||
values := utils.SplitLines(stdout)
|
||||
|
||||
// The value it had before the oldest entry is that entry's old value, and
|
||||
// the only way to name it is <ref>@{<number of entries>}. It doesn't exist
|
||||
// if the oldest entry is the one that created the ref, and asking for it
|
||||
// then is an error rather than an empty result.
|
||||
cmdArgs = NewGitCmd("rev-parse").
|
||||
Arg("-q", "--verify").
|
||||
Arg(fmt.Sprintf("%s@{%d}", upstreamRef, len(values))).
|
||||
ToArgv()
|
||||
|
||||
if stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs(); err == nil {
|
||||
values = append(values, strings.TrimSpace(stdout))
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// Moves branches by writing refs directly. The reflog message is what
|
||||
// `git reflog <branch>` shows for the update; it is the only hint about who
|
||||
// moved the branch, as no git command shows up in the reflog for this.
|
||||
func (self *BranchCommands) UpdateBranchRefs(updateCommands string, reflogMessage string) error {
|
||||
cmdArgs := NewGitCmd("update-ref").
|
||||
Arg("--stdin").
|
||||
Arg("-m", reflogMessage).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).SetStdin(updateCommands).Run()
|
||||
|
||||
@@ -44,6 +44,7 @@ type BranchLoader struct {
|
||||
*GitCommon
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
getCurrentBranchInfo func() (BranchInfo, error)
|
||||
hasLocalOnlyCommits func(*models.Branch) (bool, error)
|
||||
config BranchLoaderConfigCommands
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ func NewBranchLoader(
|
||||
gitCommon *GitCommon,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
getCurrentBranchInfo func() (BranchInfo, error),
|
||||
hasLocalOnlyCommits func(*models.Branch) (bool, error),
|
||||
config BranchLoaderConfigCommands,
|
||||
) *BranchLoader {
|
||||
return &BranchLoader{
|
||||
@@ -59,6 +61,7 @@ func NewBranchLoader(
|
||||
GitCommon: gitCommon,
|
||||
cmd: cmd,
|
||||
getCurrentBranchInfo: getCurrentBranchInfo,
|
||||
hasLocalOnlyCommits: hasLocalOnlyCommits,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
@@ -67,7 +70,7 @@ func NewBranchLoader(
|
||||
func (self *BranchLoader) Load(reflogCommits []*models.Commit,
|
||||
mainBranches *MainBranches,
|
||||
oldBranches []*models.Branch,
|
||||
loadBehindCounts bool,
|
||||
loadExtraInfo bool,
|
||||
onWorker func(func() error),
|
||||
renderFunc func(),
|
||||
) ([]*models.Branch, error) {
|
||||
@@ -135,24 +138,63 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit,
|
||||
branch.UpstreamBranch = match.Merge
|
||||
}
|
||||
|
||||
// If the branch already existed, take over its BehindBaseBranch value
|
||||
// to reduce flicker
|
||||
// If the branch already existed, take over the values that are
|
||||
// determined in the background, to reduce flicker
|
||||
if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool {
|
||||
return b.Name == branch.Name
|
||||
}); found {
|
||||
branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load())
|
||||
branch.UpstreamRewritten.Store(oldBranch.UpstreamRewritten.Load())
|
||||
}
|
||||
}
|
||||
|
||||
if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
|
||||
if loadExtraInfo {
|
||||
if self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
|
||||
onWorker(func() error {
|
||||
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
|
||||
})
|
||||
}
|
||||
|
||||
onWorker(func() error {
|
||||
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
|
||||
return self.checkForRewrittenUpstreams(branches, renderFunc)
|
||||
})
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// For each branch that has diverged from its upstream, determines whether the
|
||||
// divergence comes from the upstream branch having been rewritten, and stores
|
||||
// the answer in the branch. A branch that we can't determine it for keeps the
|
||||
// answer "no", so that we don't offer anything we aren't sure about.
|
||||
func (self *BranchLoader) checkForRewrittenUpstreams(branches []*models.Branch, renderFunc func()) error {
|
||||
t := time.Now()
|
||||
errg := errgroup.Group{}
|
||||
|
||||
for _, branch := range branches {
|
||||
if !branch.IsAheadForPull() || !branch.IsBehindForPull() {
|
||||
branch.UpstreamRewritten.Store(false)
|
||||
continue
|
||||
}
|
||||
|
||||
errg.Go(func() error {
|
||||
hasLocalOnlyCommits, err := self.hasLocalOnlyCommits(branch)
|
||||
if err != nil {
|
||||
// Not worth bothering the user about; it only means that we
|
||||
// don't show this branch differently.
|
||||
self.Log.Errorf("Failed to check whether branch %s has commits of its own: %v", branch.Name, err)
|
||||
}
|
||||
branch.UpstreamRewritten.Store(err == nil && !hasLocalOnlyCommits)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err := errg.Wait()
|
||||
self.Log.Debugf("time to check for rewritten upstreams for all branches: %s", time.Since(t))
|
||||
renderFunc()
|
||||
return err
|
||||
}
|
||||
|
||||
func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
|
||||
branches []*models.Branch,
|
||||
mainBranches *MainBranches,
|
||||
@@ -349,6 +391,7 @@ var branchFields = []string{
|
||||
"upstream:short",
|
||||
"upstream:track",
|
||||
"push:track",
|
||||
"push",
|
||||
"subject",
|
||||
"objectname",
|
||||
"committerdate:unix",
|
||||
@@ -361,13 +404,15 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch
|
||||
upstreamName := split[2]
|
||||
track := split[3]
|
||||
pushTrack := split[4]
|
||||
subject := split[5]
|
||||
commitHash := split[6]
|
||||
commitDate := split[7]
|
||||
pushRef := split[5]
|
||||
subject := split[6]
|
||||
commitHash := split[7]
|
||||
commitDate := split[8]
|
||||
|
||||
name := strings.TrimPrefix(fullName, "heads/")
|
||||
aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track)
|
||||
aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack)
|
||||
pushRemote, pushBranch := parsePushDestination(pushRef)
|
||||
|
||||
recency := ""
|
||||
if storeCommitDateAsRecency {
|
||||
@@ -383,6 +428,8 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch
|
||||
BehindForPull: behindForPull,
|
||||
AheadForPush: aheadForPush,
|
||||
BehindForPush: behindForPush,
|
||||
PushRemote: pushRemote,
|
||||
PushBranch: pushBranch,
|
||||
UpstreamGone: gone,
|
||||
Head: headMarker == "*",
|
||||
Subject: subject,
|
||||
@@ -410,6 +457,25 @@ func parseUpstreamInfo(upstreamName string, track string) (string, string, bool)
|
||||
return ahead, behind, false
|
||||
}
|
||||
|
||||
// Splits the remote-tracking ref that the %(push) field names, e.g.
|
||||
// refs/remotes/origin/main, into the remote and the remote branch. Returns
|
||||
// empty strings if the field is empty because git has no push destination for
|
||||
// the branch, or if the ref isn't under refs/remotes/.
|
||||
func parsePushDestination(pushRef string) (string, string) {
|
||||
remoteAndBranch, ok := strings.CutPrefix(pushRef, "refs/remotes/")
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Remote names can't contain slashes, so the first one ends the remote name
|
||||
remote, branch, ok := strings.Cut(remoteAndBranch, "/")
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return remote, branch
|
||||
}
|
||||
|
||||
func parseDifference(track string, regexStr string) string {
|
||||
re := regexp.MustCompile(regexStr)
|
||||
match := re.FindStringSubmatch(track)
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/sasha-s/go-deadlock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -26,7 +28,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "TrimHeads",
|
||||
input: []string{"", "heads/a_branch", "", "", "", "subject", "123", timeStamp},
|
||||
input: []string{"", "heads/a_branch", "", "", "", "", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -41,7 +43,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "NoUpstream",
|
||||
input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
|
||||
input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -56,7 +58,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "IsHead",
|
||||
input: []string{"*", "a_branch", "", "", "", "subject", "123", timeStamp},
|
||||
input: []string{"*", "a_branch", "", "", "", "", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -71,7 +73,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "IsBehindAndAhead",
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "subject", "123", timeStamp},
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -79,6 +81,40 @@ func TestObtainBranch(t *testing.T) {
|
||||
BehindForPull: "2",
|
||||
AheadForPush: "3",
|
||||
BehindForPush: "2",
|
||||
PushRemote: "a_remote",
|
||||
PushBranch: "a_branch",
|
||||
Head: false,
|
||||
Subject: "subject",
|
||||
CommitHash: "123",
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "PushDestinationDiffersFromUpstream",
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[ahead 3]", "[ahead 5]", "refs/remotes/my_fork/feature/a_branch", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
AheadForPull: "3",
|
||||
BehindForPull: "0",
|
||||
AheadForPush: "5",
|
||||
BehindForPush: "0",
|
||||
PushRemote: "my_fork",
|
||||
PushBranch: "feature/a_branch",
|
||||
Head: false,
|
||||
Subject: "subject",
|
||||
CommitHash: "123",
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "PushDestinationNotARemoteTrackingRef",
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "", "", "refs/published/a_branch", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
AheadForPull: "0",
|
||||
BehindForPull: "0",
|
||||
AheadForPush: "0",
|
||||
BehindForPush: "0",
|
||||
Head: false,
|
||||
Subject: "subject",
|
||||
CommitHash: "123",
|
||||
@@ -86,7 +122,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "RemoteBranchIsGone",
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "subject", "123", timeStamp},
|
||||
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: false,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -95,6 +131,8 @@ func TestObtainBranch(t *testing.T) {
|
||||
BehindForPull: "?",
|
||||
AheadForPush: "?",
|
||||
BehindForPush: "?",
|
||||
PushRemote: "a_remote",
|
||||
PushBranch: "a_branch",
|
||||
Head: false,
|
||||
Subject: "subject",
|
||||
CommitHash: "123",
|
||||
@@ -102,7 +140,7 @@ func TestObtainBranch(t *testing.T) {
|
||||
},
|
||||
{
|
||||
testName: "WithCommitDateAsRecency",
|
||||
input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
|
||||
input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp},
|
||||
storeCommitDateAsRecency: true,
|
||||
expectedBranch: &models.Branch{
|
||||
Name: "a_branch",
|
||||
@@ -255,3 +293,62 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) {
|
||||
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestCheckForRewrittenUpstreams(t *testing.T) {
|
||||
branch := func(name string, ahead string, behind string) *models.Branch {
|
||||
return &models.Branch{
|
||||
Name: name,
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: name,
|
||||
AheadForPull: ahead,
|
||||
BehindForPull: behind,
|
||||
}
|
||||
}
|
||||
|
||||
notDiverged := branch("not-diverged", "0", "2")
|
||||
rewritten := branch("rewritten", "3", "5")
|
||||
ownCommits := branch("own-commits", "3", "5")
|
||||
failing := branch("failing", "1", "1")
|
||||
|
||||
// A branch that is no longer diverged must lose the value it had before
|
||||
notDiverged.UpstreamRewritten.Store(true)
|
||||
|
||||
branches := []*models.Branch{notDiverged, rewritten, ownCommits, failing}
|
||||
|
||||
var mutex deadlock.Mutex
|
||||
queried := []string{}
|
||||
hasLocalOnlyCommits := func(branch *models.Branch) (bool, error) {
|
||||
mutex.Lock()
|
||||
queried = append(queried, branch.Name)
|
||||
mutex.Unlock()
|
||||
|
||||
switch branch.Name {
|
||||
case "own-commits":
|
||||
return true, nil
|
||||
case "failing":
|
||||
return false, errors.New("error")
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
gitCommon := buildGitCommon(commonDeps{})
|
||||
loader := &BranchLoader{
|
||||
Common: gitCommon.Common,
|
||||
GitCommon: gitCommon,
|
||||
cmd: gitCommon.cmd,
|
||||
hasLocalOnlyCommits: hasLocalOnlyCommits,
|
||||
}
|
||||
|
||||
rendered := false
|
||||
err := loader.checkForRewrittenUpstreams(branches, func() { rendered = true })
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, rendered, "renderFunc should have been called")
|
||||
|
||||
assert.ElementsMatch(t, []string{"rewritten", "own-commits", "failing"}, queried,
|
||||
"only diverged branches should be looked at")
|
||||
assert.False(t, notDiverged.UpstreamRewritten.Load())
|
||||
assert.True(t, rewritten.UpstreamRewritten.Load())
|
||||
assert.False(t, ownCommits.UpstreamRewritten.Load())
|
||||
assert.False(t, failing.UpstreamRewritten.Load(), "a failed check should not claim anything")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -317,3 +318,111 @@ func TestBranchCurrentBranchInfo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchHasLocalOnlyCommits(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
test func(bool, error)
|
||||
}
|
||||
|
||||
branch := &models.Branch{
|
||||
Name: "branch",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "branch",
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
"branch is strictly behind its upstream, and there are no reflogs",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch", "--",
|
||||
}, "", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"the upstream branch was rewritten, so all our commits were on it before",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
|
||||
"1111111111111111111111111111111111111111\n2222222222222222222222222222222222222222\n", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{2}"},
|
||||
"3333333333333333333333333333333333333333\n", nil).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch",
|
||||
"^1111111111111111111111111111111111111111",
|
||||
"^2222222222222222222222222222222222222222",
|
||||
"^3333333333333333333333333333333333333333",
|
||||
"--",
|
||||
}, "", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"the oldest reflog entry is the one that created the ref",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
|
||||
"1111111111111111111111111111111111111111\n", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"}, "", errors.New("error")).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch",
|
||||
"^1111111111111111111111111111111111111111",
|
||||
"--",
|
||||
}, "", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"the branch has a commit that was never on the upstream branch",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
|
||||
"1111111111111111111111111111111111111111\n", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"},
|
||||
"2222222222222222222222222222222222222222\n", nil).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch",
|
||||
"^1111111111111111111111111111111111111111",
|
||||
"^2222222222222222222222222222222222222222",
|
||||
"--",
|
||||
}, "4444444444444444444444444444444444444444\n", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"bubbles up an error from rev-list",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch", "--",
|
||||
}, "", errors.New("error")),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.Error(t, err)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
instance := buildBranchCommands(commonDeps{runner: s.runner})
|
||||
s.test(instance.HasLocalOnlyCommits(branch))
|
||||
s.runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff using git's external diff config",
|
||||
|
||||
@@ -125,11 +125,9 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
|
||||
|
||||
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
|
||||
contextSize := userConfig.Git.DiffContextSize
|
||||
extDiffCmd := diffRendererConfigManager.GetExternalDiffCommand(contextSize)
|
||||
useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
|
||||
|
||||
return self.
|
||||
ConfigIf(forUI && extDiffCmd != "", "diff.external="+extDiffCmd).
|
||||
ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff").
|
||||
Arg(fmt.Sprintf("--unified=%d", contextSize)).
|
||||
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
|
||||
|
||||
@@ -59,9 +59,10 @@ func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName strin
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
}
|
||||
|
||||
func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagName string) error {
|
||||
func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagNames []string) error {
|
||||
cmdArgs := NewGitCmd("push").
|
||||
Arg(remoteName, "--delete", "refs/tags/"+tagName).
|
||||
Arg(remoteName, "--delete").
|
||||
Arg(lo.Map(tagNames, func(t string, _ int) string { return "refs/tags/" + t })...).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
|
||||
@@ -139,7 +139,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff using git's external diff config",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type SyncCommands struct {
|
||||
@@ -18,18 +19,21 @@ func NewSyncCommands(gitCommon *GitCommon) *SyncCommands {
|
||||
}
|
||||
}
|
||||
|
||||
// Push pushes to a branch
|
||||
type PushOpts struct {
|
||||
Force bool
|
||||
ForceWithLease bool
|
||||
CurrentBranch string
|
||||
UpstreamRemote string
|
||||
UpstreamBranch string
|
||||
SetUpstream bool
|
||||
// The remote to push to. If empty, git picks it from its configuration,
|
||||
// and Refspecs must be empty too.
|
||||
Remote string
|
||||
// What to push, each in the form "refs/heads/<local branch>:<remote ref>".
|
||||
// If empty, git decides what to push based on push.default and
|
||||
// remote.<name>.push.
|
||||
Refspecs []string
|
||||
}
|
||||
|
||||
func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommands.CmdObj, error) {
|
||||
if opts.UpstreamBranch != "" && opts.UpstreamRemote == "" {
|
||||
if len(opts.Refspecs) > 0 && opts.Remote == "" {
|
||||
return nil, errors.New(self.Tr.MustSpecifyOriginError)
|
||||
}
|
||||
|
||||
@@ -37,8 +41,8 @@ func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommand
|
||||
ArgIf(opts.Force, "--force").
|
||||
ArgIf(opts.ForceWithLease, "--force-with-lease").
|
||||
ArgIf(opts.SetUpstream, "--set-upstream").
|
||||
ArgIf(opts.UpstreamRemote != "", opts.UpstreamRemote).
|
||||
ArgIf(opts.UpstreamBranch != "", fmt.Sprintf("refs/heads/%s:%s", opts.CurrentBranch, opts.UpstreamBranch)).
|
||||
ArgIf(opts.Remote != "", opts.Remote).
|
||||
Arg(opts.Refspecs...).
|
||||
ToArgv()
|
||||
|
||||
cmdObj := self.cmd.New(cmdArgs).PromptOnCredentialRequest(task)
|
||||
@@ -110,15 +114,25 @@ func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error {
|
||||
return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run()
|
||||
}
|
||||
|
||||
func (self *SyncCommands) FastForward(
|
||||
// Fetches the given branches of the given remote, updating their
|
||||
// remote-tracking branches. Local branches are left alone, including the ones
|
||||
// that track them.
|
||||
func (self *SyncCommands) FetchRemoteBranches(
|
||||
task gocui.Task,
|
||||
branchName string,
|
||||
remoteName string,
|
||||
remoteBranchName string,
|
||||
remoteBranchNames []string,
|
||||
) error {
|
||||
// The explicit destinations and the leading + make sure that the
|
||||
// remote-tracking branches are updated even when the remote branches were
|
||||
// rewritten, whatever the remote's fetch refspec says
|
||||
refspecs := lo.Map(remoteBranchNames, func(remoteBranchName string, _ int) string {
|
||||
return fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
|
||||
remoteBranchName, remoteName, remoteBranchName)
|
||||
})
|
||||
|
||||
cmdArgs := self.fetchCommandBuilder(false).
|
||||
Arg(remoteName).
|
||||
Arg("refs/heads/" + remoteBranchName + ":" + branchName).
|
||||
Arg(refspecs...).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
|
||||
@@ -41,12 +41,11 @@ func TestSyncPush(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with force disabled, upstream supplied",
|
||||
testName: "Push with force disabled, refspec supplied",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: false,
|
||||
CurrentBranch: "master",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "master",
|
||||
Remote: "origin",
|
||||
Refspecs: []string{"refs/heads/master:master"},
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "origin", "refs/heads/master:master"})
|
||||
@@ -57,9 +56,8 @@ func TestSyncPush(t *testing.T) {
|
||||
testName: "Push with force disabled, setting upstream",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: false,
|
||||
CurrentBranch: "master-local",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "master",
|
||||
Remote: "origin",
|
||||
Refspecs: []string{"refs/heads/master-local:master"},
|
||||
SetUpstream: true,
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
@@ -71,9 +69,8 @@ func TestSyncPush(t *testing.T) {
|
||||
testName: "Push with force-with-lease enabled, setting upstream",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: true,
|
||||
CurrentBranch: "master",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "master",
|
||||
Remote: "origin",
|
||||
Refspecs: []string{"refs/heads/master:master"},
|
||||
SetUpstream: true,
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
@@ -82,11 +79,23 @@ func TestSyncPush(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with remote branch but no origin",
|
||||
testName: "Push several refspecs",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: true,
|
||||
UpstreamRemote: "",
|
||||
UpstreamBranch: "master",
|
||||
Remote: "origin",
|
||||
Refspecs: []string{"refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"},
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force-with-lease", "origin", "refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"})
|
||||
assert.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Push with refspec but no remote",
|
||||
opts: PushOpts{
|
||||
ForceWithLease: true,
|
||||
Remote: "",
|
||||
Refspecs: []string{"refs/heads/master:master"},
|
||||
SetUpstream: true,
|
||||
},
|
||||
test: func(cmdObj *oscommands.CmdObj, err error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type TagCommands struct {
|
||||
@@ -46,15 +47,16 @@ func (self *TagCommands) HasTag(tagName string) bool {
|
||||
return self.cmd.New(cmdArgs).DontLog().Run() == nil
|
||||
}
|
||||
|
||||
func (self *TagCommands) LocalDelete(tagName string) error {
|
||||
cmdArgs := NewGitCmd("tag").Arg("-d", tagName).
|
||||
func (self *TagCommands) LocalDelete(tagNames []string) error {
|
||||
cmdArgs := NewGitCmd("tag").Arg("-d").Arg(tagNames...).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
func (self *TagCommands) Push(task gocui.Task, remoteName string, tagName string) error {
|
||||
cmdArgs := NewGitCmd("push").Arg(remoteName, "tag", tagName).
|
||||
func (self *TagCommands) Push(task gocui.Task, remoteName string, tagNames []string) error {
|
||||
cmdArgs := NewGitCmd("push").Arg(remoteName).
|
||||
Arg(lo.FlatMap(tagNames, func(t string, _ int) []string { return []string{"tag", t} })...).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
|
||||
@@ -524,6 +524,42 @@ func (self *WorkingTreeCommands) ResetSoft(ref string) error {
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// ResetKeep runs `git reset --keep` in the given worktree, which moves the
|
||||
// checked out branch to the given ref while keeping local modifications. It
|
||||
// fails rather than overwriting a file that differs between the two commits.
|
||||
// Pass empty strings for the worktree to use the current one.
|
||||
func (self *WorkingTreeCommands) ResetKeep(ref string, worktreeGitDir string, worktreePath string) error {
|
||||
cmdArgs := NewGitCmd("reset").Arg("--keep", ref).
|
||||
GitDirIf(worktreeGitDir != "", worktreeGitDir).
|
||||
WorktreePathIf(worktreePath != "", worktreePath).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// Returns whether the given worktree has changes to tracked files, either in
|
||||
// its working tree or in its index. Untracked files don't count, and neither do
|
||||
// submodules. A submodule that is checked out at a different commit than the
|
||||
// one recorded, or that has changes of its own, doesn't get in the way of
|
||||
// moving the branch, because moving it leaves the submodules alone. Pass empty
|
||||
// strings for the worktree to use the current one.
|
||||
func (self *WorkingTreeCommands) HasChangesToTrackedFiles(worktreeGitDir string, worktreePath string) (bool, error) {
|
||||
cmdArgs := NewGitCmd("status").
|
||||
Arg("--porcelain").
|
||||
Arg("--untracked-files=no").
|
||||
Arg("--ignore-submodules").
|
||||
GitDirIf(worktreeGitDir != "", worktreeGitDir).
|
||||
WorktreePathIf(worktreePath != "", worktreePath).
|
||||
ToArgv()
|
||||
|
||||
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return stdout != "", nil
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) ResetMixed(ref string) error {
|
||||
cmdArgs := NewGitCmd("reset").Arg("--mixed", ref).
|
||||
ToArgv()
|
||||
|
||||
@@ -140,12 +140,14 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
||||
rebasedBranch, ok := self.rebasedBranch(worktree)
|
||||
if ok {
|
||||
worktree.Branch = rebasedBranch
|
||||
worktree.IsRebasingOrBisecting = true
|
||||
continue
|
||||
}
|
||||
|
||||
bisectedBranch, ok := self.bisectedBranch(worktree)
|
||||
if ok {
|
||||
worktree.Branch = bisectedBranch
|
||||
worktree.IsRebasingOrBisecting = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ type Branch struct {
|
||||
// 'git@github.com:tiwood/lazygit.git'
|
||||
UpstreamRemote string
|
||||
UpstreamBranch string
|
||||
// The remote and the remote branch that `git push` would push this branch
|
||||
// to, as git determines them from push.default, remote.pushDefault and
|
||||
// branch.<name>.pushRemote. In a triangular workflow these differ from the
|
||||
// upstream. Both are empty if git has no push destination for the branch,
|
||||
// e.g. because push.default is "upstream" and the branch has no upstream.
|
||||
PushRemote string
|
||||
PushBranch string
|
||||
// subject line in commit message
|
||||
Subject string
|
||||
// commit hash
|
||||
@@ -40,6 +47,13 @@ type Branch struct {
|
||||
// determined yet, or up to date with base branch. (We don't need to
|
||||
// distinguish the two, as we don't draw anything in both cases.)
|
||||
BehindBaseBranch atomic.Int32
|
||||
|
||||
// Whether the branch has diverged from its upstream because the upstream
|
||||
// branch was rewritten, and not because the branch has commits of its own.
|
||||
// Such a branch can be reset to its upstream without losing anything.
|
||||
// False for branches that haven't diverged, and for those we haven't
|
||||
// determined it for yet.
|
||||
UpstreamRewritten atomic.Bool
|
||||
}
|
||||
|
||||
func (b *Branch) FullRefName() string {
|
||||
@@ -119,6 +133,13 @@ func (b *Branch) IsBehindForPush() bool {
|
||||
return b.RemoteBranchStoredLocally() && b.BehindForPush != "0"
|
||||
}
|
||||
|
||||
// Whether the branch has commits that its push destination doesn't have. False
|
||||
// if the remote branch it would be pushed to isn't stored locally, in which
|
||||
// case the count is "?".
|
||||
func (b *Branch) IsAheadForPush() bool {
|
||||
return b.RemoteBranchStoredLocally() && b.AheadForPush != "0" && b.AheadForPush != "?"
|
||||
}
|
||||
|
||||
// for when we're in a detached head state
|
||||
func (b *Branch) IsRealBranch() bool {
|
||||
return b.AheadForPull != "" && b.BehindForPull != ""
|
||||
|
||||
@@ -19,6 +19,9 @@ type Worktree struct {
|
||||
// * the worktree is mid-rebase on the branch
|
||||
// * the worktree is mid-bisect on the branch
|
||||
Branch string
|
||||
// If true, the worktree is mid-rebase or mid-bisect on Branch, so its HEAD
|
||||
// is detached rather than pointing at the branch
|
||||
IsRebasingOrBisecting bool
|
||||
// The HEAD sha of the worktree. Always populated (even when Branch is set).
|
||||
// Used for display when Branch is empty (detached HEAD state).
|
||||
Head string
|
||||
|
||||
@@ -108,6 +108,14 @@ func (self *CmdObj) GetEnvVars() []string {
|
||||
return self.cmd.Env
|
||||
}
|
||||
|
||||
// SetEnviron replaces the command's whole environment, for a command that has
|
||||
// to run in the same one as another command rather than in this process's.
|
||||
func (self *CmdObj) SetEnviron(env []string) *CmdObj {
|
||||
self.cmd.Env = env
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// sets the working directory
|
||||
func (self *CmdObj) SetWd(wd string) *CmdObj {
|
||||
self.cmd.Dir = wd
|
||||
|
||||
@@ -33,6 +33,16 @@ func (self *CmdObjBuilder) New(args []string) *CmdObj {
|
||||
return cmdObj
|
||||
}
|
||||
|
||||
// NewFromCmd wraps a command that has already been built, for a caller that
|
||||
// holds an *exec.Cmd and needs it as a CmdObj. The command itself is shared,
|
||||
// not copied, so whatever was set on it still applies.
|
||||
func (self *CmdObjBuilder) NewFromCmd(cmd *exec.Cmd) *CmdObj {
|
||||
return &CmdObj{
|
||||
cmd: cmd,
|
||||
runner: self.runner,
|
||||
}
|
||||
}
|
||||
|
||||
// A command with explicit environment from env
|
||||
func (self *CmdObjBuilder) NewWithEnviron(args []string, env []string) *CmdObj {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
|
||||
@@ -4,12 +4,10 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
@@ -203,61 +201,25 @@ func (c *OSCommand) FileExists(path string) (bool, error) {
|
||||
|
||||
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
|
||||
func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
|
||||
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
|
||||
return cmdObj.GetCmd()
|
||||
})
|
||||
c.logPipeline(cmdObjs)
|
||||
|
||||
logCmdStr := strings.Join(
|
||||
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
|
||||
return cmdObj.ToString()
|
||||
}),
|
||||
" | ",
|
||||
)
|
||||
|
||||
c.LogCommand(logCmdStr, true)
|
||||
|
||||
for i := range len(cmds) - 1 {
|
||||
stdout, err := cmds[i].StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmds[i+1].Stdin = stdout
|
||||
cmds, parentEnds, err := wirePipeline(cmdObjs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// keeping this here in case I adapt this code for some other purpose in the future
|
||||
// cmds[len(cmds)-1].Stdout = os.Stdout
|
||||
|
||||
stderrs := make([]bytes.Buffer, len(cmds))
|
||||
for i := range cmds {
|
||||
cmds[i].Stderr = &stderrs[i]
|
||||
}
|
||||
|
||||
// Start every command before waiting for any of them: waiting for a command
|
||||
// closes our end of the pipe that feeds the next one, and a command that
|
||||
// hasn't been started by then would inherit a closed stdin.
|
||||
started := 0
|
||||
var startErr error
|
||||
for _, cmd := range cmds {
|
||||
if err := cmd.Start(); err != nil {
|
||||
startErr = err
|
||||
break
|
||||
}
|
||||
|
||||
started++
|
||||
}
|
||||
started, startErr := startPipeline(cmds, parentEnds)
|
||||
|
||||
finalErrors := []string{}
|
||||
|
||||
if startErr != nil {
|
||||
c.Log.Error(startErr)
|
||||
finalErrors = append(finalErrors, startErr.Error())
|
||||
|
||||
// Without the rest of the pipeline to drain them, the commands we did
|
||||
// start could block forever writing to a full pipe.
|
||||
for _, cmd := range cmds[:started] {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
for i, cmd := range cmds[:started] {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package oscommands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Pipeline is a chain of running commands, each one's output feeding the next
|
||||
// one's input, with the last one's output going somewhere the caller reads. Its
|
||||
// method set is the one a render task expects of a command (see tasks.Cmd), so
|
||||
// a pipeline can render a view just as a single command can.
|
||||
type Pipeline struct {
|
||||
cmds []*exec.Cmd
|
||||
cmdStr string
|
||||
}
|
||||
|
||||
// StartPipeline starts the given commands wired A | B | C and returns the
|
||||
// pipeline together with the reader for its output.
|
||||
//
|
||||
// Every command's stderr goes to that same output, so whatever a command
|
||||
// complains about is part of what the caller reads. A diff renderer's error
|
||||
// message belongs on screen with the diff it failed to render.
|
||||
//
|
||||
// Closing the reader is how a pipeline is brought down. The last command's next
|
||||
// write fails, so it exits, and the failure travels back up the chain as each
|
||||
// command in turn writes into a pipe whose reader is gone.
|
||||
func (c *OSCommand) StartPipeline(cmdObjs ...*CmdObj) (*Pipeline, io.ReadCloser, error) {
|
||||
c.logPipeline(cmdObjs)
|
||||
|
||||
cmds, parentEnds, err := wirePipeline(cmdObjs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
closeAll(parentEnds)
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, cmd := range cmds {
|
||||
cmd.Stderr = writer
|
||||
}
|
||||
cmds[len(cmds)-1].Stdout = writer
|
||||
parentEnds = append(parentEnds, writer)
|
||||
|
||||
started, err := startPipeline(cmds, parentEnds)
|
||||
if err != nil {
|
||||
for _, cmd := range cmds[:started] {
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
_ = reader.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &Pipeline{cmds: cmds, cmdStr: pipelineString(cmdObjs)}, reader, nil
|
||||
}
|
||||
|
||||
func (self *Pipeline) String() string {
|
||||
return self.cmdStr
|
||||
}
|
||||
|
||||
// Wait waits for every command to exit and reports the failure nearest the end
|
||||
// of the pipeline. A command that fails leaves the ones before it writing into
|
||||
// a pipe nobody reads, so their own broken-pipe failures are consequences of it
|
||||
// rather than the cause worth reporting, while a command that fails early
|
||||
// leaves the ones after it with nothing to read and no reason to fail at all.
|
||||
func (self *Pipeline) Wait() error {
|
||||
var lastErr error
|
||||
for _, cmd := range self.cmds {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
lastErr = fmt.Errorf("%s: %w", cmd.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Terminate asks every command to stop, without waiting for any of them. On
|
||||
// platforms where that does nothing, the pipeline comes down when its output
|
||||
// reader is closed; see StartPipeline.
|
||||
func (self *Pipeline) Terminate() error {
|
||||
var firstErr error
|
||||
for _, cmd := range self.cmds {
|
||||
if err := TerminateProcessGracefully(cmd.Process); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// logPipeline enters a chain of commands into the command log, unless the first
|
||||
// command was marked not to be logged; it speaks for the pipeline. A render runs
|
||||
// its pipeline again on every selection change, so a caller has to be able to
|
||||
// keep it out of the log.
|
||||
func (c *OSCommand) logPipeline(cmdObjs []*CmdObj) {
|
||||
if cmdObjs[0].ShouldLog() {
|
||||
c.LogCommand(pipelineString(cmdObjs), true)
|
||||
}
|
||||
}
|
||||
|
||||
// pipelineString names a chain of commands the way a shell would write it.
|
||||
func pipelineString(cmdObjs []*CmdObj) string {
|
||||
return strings.Join(
|
||||
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
|
||||
return cmdObj.ToString()
|
||||
}),
|
||||
" | ",
|
||||
)
|
||||
}
|
||||
|
||||
// wirePipeline connects each command's output to the next one's input, like
|
||||
// A | B | C, and returns the commands along with the parent's ends of those
|
||||
// pipes. The last command's output is left for the caller to direct.
|
||||
//
|
||||
// The parent's ends have to be closed once the commands are running.
|
||||
// startPipeline does that; see there for why it matters.
|
||||
func wirePipeline(cmdObjs []*CmdObj) ([]*exec.Cmd, []io.Closer, error) {
|
||||
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
|
||||
return cmdObj.GetCmd()
|
||||
})
|
||||
|
||||
parentEnds := []io.Closer{}
|
||||
for i := range len(cmds) - 1 {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
closeAll(parentEnds)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cmds[i].Stdout = writer
|
||||
cmds[i+1].Stdin = reader
|
||||
parentEnds = append(parentEnds, reader, writer)
|
||||
}
|
||||
|
||||
return cmds, parentEnds, nil
|
||||
}
|
||||
|
||||
// startPipeline starts every command and reports how many it got going. Every
|
||||
// one is started before any of them is waited for: waiting closes our end of
|
||||
// the pipe that feeds the next command, and one that hasn't been started by
|
||||
// then would inherit a closed stdin.
|
||||
//
|
||||
// Once they are all running, each of them holds its own ends of the pipes it
|
||||
// reads and writes, and the parent lets go of its copies. Both directions
|
||||
// matter. While the parent holds the read end of a link, a command writing
|
||||
// into it never learns that the command meant to read it is gone, and keeps
|
||||
// running after the pipeline has been brought down. While the parent holds the
|
||||
// write end, the command reading it never reaches the end of its input.
|
||||
//
|
||||
// When a command fails to start, the ones already running are killed, since
|
||||
// without the rest of the pipeline to drain them they could block forever
|
||||
// writing to a full pipe. They still have to be reaped, so the count covers
|
||||
// them too.
|
||||
func startPipeline(cmds []*exec.Cmd, parentEnds []io.Closer) (int, error) {
|
||||
defer closeAll(parentEnds)
|
||||
|
||||
for i, cmd := range cmds {
|
||||
if err := cmd.Start(); err != nil {
|
||||
for _, started := range cmds[:i] {
|
||||
_ = started.Process.Kill()
|
||||
}
|
||||
|
||||
return i, err
|
||||
}
|
||||
}
|
||||
|
||||
return len(cmds), nil
|
||||
}
|
||||
|
||||
func closeAll(closers []io.Closer) {
|
||||
for _, closer := range closers {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package oscommands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// The pipeline tests need programs to run, and the test binary is the one
|
||||
// program every platform we test on is sure to have. pipelineMember builds a
|
||||
// command that re-runs this binary in the role a member of the pipeline is to
|
||||
// play; the roles are in TestPipelineMember.
|
||||
const pipelineRoleEnvVar = "LAZYGIT_TEST_PIPELINE_ROLE"
|
||||
|
||||
func pipelineMember(role string) *CmdObj {
|
||||
return NewDummyOSCommand().Cmd.
|
||||
New([]string{os.Args[0], "-test.run=^TestPipelineMember$"}).
|
||||
AddEnvVars(pipelineRoleEnvVar + "=" + role)
|
||||
}
|
||||
|
||||
// TestPipelineMember is the program the pipeline tests run, not a test of its
|
||||
// own. It exits before the testing package reports anything, so that its output
|
||||
// is what the role wrote and nothing else.
|
||||
//
|
||||
// For the same reason it exits with syscall.Exit, which skips the exit hooks
|
||||
// that os.Exit runs. In a binary built with -cover, one of these hooks writes
|
||||
// coverage data to $GOCOVERDIR and prints an error to stderr if that fails. On
|
||||
// Windows it fails now and then if two members of a pipeline exit at the same
|
||||
// time, because both of them replace the same file in that directory.
|
||||
func TestPipelineMember(t *testing.T) {
|
||||
switch os.Getenv(pipelineRoleEnvVar) {
|
||||
case "":
|
||||
t.Skip("not a test; the pipeline tests run this binary in a role")
|
||||
case "count":
|
||||
for i := 1; i <= 3; i++ {
|
||||
fmt.Printf("line %d\n", i)
|
||||
}
|
||||
case "upcase":
|
||||
input, _ := io.ReadAll(os.Stdin)
|
||||
fmt.Print(strings.ToUpper(string(input)))
|
||||
case "copy":
|
||||
_, _ = io.Copy(os.Stdout, os.Stdin)
|
||||
case "complain":
|
||||
fmt.Fprintln(os.Stderr, "something went wrong")
|
||||
syscall.Exit(3)
|
||||
case "flood":
|
||||
// A failed write means the reader is gone, and there is no point
|
||||
// writing to nobody. On platforms that raise a signal for it instead,
|
||||
// this process is already dead by the time the write returns.
|
||||
for i := 1; ; i++ {
|
||||
if _, err := fmt.Printf("line %d\n", i); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syscall.Exit(0)
|
||||
}
|
||||
|
||||
func TestStartPipelineStreamsTheOutputOfTheLastCommand(t *testing.T) {
|
||||
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
|
||||
pipelineMember("count"),
|
||||
pipelineMember("upcase"),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "LINE 1\nLINE 2\nLINE 3\n", string(output))
|
||||
|
||||
assert.NoError(t, pipeline.Wait())
|
||||
assert.NoError(t, reader.Close())
|
||||
}
|
||||
|
||||
func TestStartPipelineReadsWhatTheCommandsComplainAbout(t *testing.T) {
|
||||
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
|
||||
pipelineMember("count"),
|
||||
pipelineMember("complain"),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "something went wrong\n", string(output))
|
||||
|
||||
// The failure of the command nearest the output is the one reported, even
|
||||
// though the one feeding it was left writing into a pipe nobody reads.
|
||||
assert.ErrorContains(t, pipeline.Wait(), "exit status 3")
|
||||
|
||||
assert.NoError(t, reader.Close())
|
||||
}
|
||||
|
||||
func TestClosingAPipelinesOutputBringsItDown(t *testing.T) {
|
||||
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
|
||||
pipelineMember("flood"),
|
||||
pipelineMember("copy"),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Read some output first, so that both commands are past their startup and
|
||||
// really running when the reader goes.
|
||||
buf := make([]byte, len("line 1\n"))
|
||||
_, err = io.ReadFull(reader, buf)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "line 1\n", string(buf))
|
||||
|
||||
assert.NoError(t, reader.Close())
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- pipeline.Wait() }()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the pipeline was still running long after its output was closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPipelineReportsACommandItCannotStart(t *testing.T) {
|
||||
osCommand := NewDummyOSCommand()
|
||||
|
||||
_, _, err := osCommand.StartPipeline(
|
||||
pipelineMember("count"),
|
||||
osCommand.Cmd.New([]string{"lazygit-no-such-command"}),
|
||||
)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPipeCommandsReturnsWhenALaterCommandDiesEarly(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- NewDummyOSCommand().PipeCommands(
|
||||
pipelineMember("flood"),
|
||||
pipelineMember("complain"),
|
||||
)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
assert.ErrorContains(t, err, "something went wrong")
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("PipeCommands was still waiting for a command whose output nothing reads")
|
||||
}
|
||||
}
|
||||
@@ -144,9 +144,7 @@ func TerminateLivePtys() {
|
||||
// graceful signal worth waiting on — git and the common diff tools leave it
|
||||
// to the default handler, which calls ExitProcess at whatever instruction
|
||||
// the process happens to execute — so clients that got the event are
|
||||
// already dying. Killing at an arbitrary point cannot leak a stale
|
||||
// index.lock, because pty-rendered commands don't take that lock (see
|
||||
// withPtyGitConfig in pkg/gui/pty.go).
|
||||
// already dying.
|
||||
//
|
||||
// The pseudoconsole close gets its own goroutine because the kill must not
|
||||
// wait for it: on builds where ClosePseudoConsole blocks until the console
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -309,6 +310,13 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
|
||||
}
|
||||
}
|
||||
|
||||
// This creates gui.branchColorPatterns, so it must run before the move of
|
||||
// that key into gui.theme below.
|
||||
err = migrateBranchColors(&rootNode, changes)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
|
||||
}
|
||||
|
||||
pathsToMove := []struct {
|
||||
oldPath []string
|
||||
newPath []string
|
||||
@@ -317,6 +325,14 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
|
||||
[]string{"keybinding", "worktrees", "viewWorktreeOptions"},
|
||||
[]string{"keybinding", "universal", "newWorktree"},
|
||||
},
|
||||
{
|
||||
[]string{"gui", "authorColors"},
|
||||
[]string{"gui", "theme", "authorColors"},
|
||||
},
|
||||
{
|
||||
[]string{"gui", "branchColorPatterns"},
|
||||
[]string{"gui", "theme", "branchColorPatterns"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, pathToMove := range pathsToMove {
|
||||
@@ -630,6 +646,43 @@ func migratePagersToDiffRenderers(rootNode *yaml.Node, changes *ChangesSet) erro
|
||||
})
|
||||
}
|
||||
|
||||
// The deprecated gui.branchColors matched its keys against the part of a branch
|
||||
// name before the first slash. Turn each key into a pattern that matches the
|
||||
// same branches. If the file has a non-empty gui.branchColorPatterns,
|
||||
// gui.branchColors was ignored, so remove it.
|
||||
func migrateBranchColors(rootNode *yaml.Node, changes *ChangesSet) error {
|
||||
return yaml_utils.TransformNode(rootNode, []string{"gui"}, func(guiNode *yaml.Node) error {
|
||||
branchColorsKeyNode, branchColorsValueNode := yaml_utils.LookupKey(guiNode, "branchColors")
|
||||
if branchColorsKeyNode == nil || branchColorsValueNode.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
|
||||
patternsKeyNode, patternsValueNode := yaml_utils.LookupKey(guiNode, "branchColorPatterns")
|
||||
if patternsKeyNode != nil {
|
||||
switch {
|
||||
case patternsValueNode.Kind == yaml.MappingNode && len(patternsValueNode.Content) > 0:
|
||||
yaml_utils.RemoveKey(guiNode, "branchColors")
|
||||
changes.Add("Removed 'gui.branchColors'; it had no effect because 'gui.branchColorPatterns' is set")
|
||||
return nil
|
||||
case patternsValueNode.Kind == yaml.MappingNode || patternsValueNode.Tag == "!!null":
|
||||
yaml_utils.RemoveKey(guiNode, "branchColorPatterns")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
branchColorsKeyNode.Value = "branchColorPatterns"
|
||||
for i := 0; i < len(branchColorsValueNode.Content)-1; i += 2 {
|
||||
keyNode := branchColorsValueNode.Content[i]
|
||||
keyNode.Value = "^" + regexp.QuoteMeta(keyNode.Value) + "(/|$)"
|
||||
keyNode.Tag = "!!str"
|
||||
}
|
||||
changes.Add("Converted 'gui.branchColors' to 'gui.branchColorPatterns'")
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func hasNonNullScalarValue(node *yaml.Node) bool {
|
||||
return node != nil && node.Kind == yaml.ScalarNode && node.Tag != "!!null" && node.Value != ""
|
||||
}
|
||||
|
||||
@@ -175,6 +175,32 @@ func TestMigrationOfMovedKeys(t *testing.T) {
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Moved 'keybinding.worktrees.viewWorktreeOptions' to 'keybinding.universal.newWorktree'"},
|
||||
},
|
||||
{
|
||||
name: "Move author and branch colors into the theme",
|
||||
input: `gui:
|
||||
authorColors:
|
||||
John Smith: red
|
||||
theme:
|
||||
activeBorderColor:
|
||||
- green
|
||||
branchColorPatterns:
|
||||
^docs/: blue
|
||||
`,
|
||||
expected: `gui:
|
||||
theme:
|
||||
activeBorderColor:
|
||||
- green
|
||||
authorColors:
|
||||
John Smith: red
|
||||
branchColorPatterns:
|
||||
^docs/: blue
|
||||
`,
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{
|
||||
"Moved 'gui.authorColors' to 'gui.theme.authorColors'",
|
||||
"Moved 'gui.branchColorPatterns' to 'gui.theme.branchColorPatterns'",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -821,3 +847,119 @@ func TestPagerMigration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchColorsMigration(t *testing.T) {
|
||||
moved := "Moved 'gui.branchColorPatterns' to 'gui.theme.branchColorPatterns'"
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
expectedDidChange bool
|
||||
expectedChanges []string
|
||||
}{
|
||||
{
|
||||
name: "No branchColors",
|
||||
input: "gui:\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" '^docs/': blue\n",
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "branchColors is not an object",
|
||||
input: "gui:\n" +
|
||||
" branchColors: 5\n",
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "branchColors is converted to patterns",
|
||||
input: "gui:\n" +
|
||||
" scrollHeight: 2\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n" +
|
||||
" v1.x: '#ff0000'\n" +
|
||||
" 123: red\n" +
|
||||
" mouseEvents: false\n",
|
||||
expected: "gui:\n" +
|
||||
" scrollHeight: 2\n" +
|
||||
" mouseEvents: false\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" ^feature(/|$): green\n" +
|
||||
" ^v1\\.x(/|$): '#ff0000'\n" +
|
||||
" ^123(/|$): red\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
|
||||
},
|
||||
{
|
||||
name: "branchColors is removed if branchColorPatterns is set",
|
||||
input: "gui:\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" '^docs/': blue\n",
|
||||
expected: "gui:\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" '^docs/': blue\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Removed 'gui.branchColors'; it had no effect because 'gui.branchColorPatterns' is set", moved},
|
||||
},
|
||||
{
|
||||
name: "branchColors replaces an empty branchColorPatterns",
|
||||
input: "gui:\n" +
|
||||
" branchColorPatterns: {}\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n",
|
||||
expected: "gui:\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" ^feature(/|$): green\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
|
||||
},
|
||||
{
|
||||
name: "branchColors replaces a null branchColorPatterns",
|
||||
input: "gui:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n",
|
||||
expected: "gui:\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns:\n" +
|
||||
" ^feature(/|$): green\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
|
||||
},
|
||||
{
|
||||
name: "branchColors is kept if branchColorPatterns is not an object",
|
||||
input: "gui:\n" +
|
||||
" branchColorPatterns: 5\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n",
|
||||
expected: "gui:\n" +
|
||||
" branchColors:\n" +
|
||||
" feature: green\n" +
|
||||
" theme:\n" +
|
||||
" branchColorPatterns: 5\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{moved},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
changes := NewChangesSet()
|
||||
actual, didChange, err := computeMigratedConfig("path doesn't matter", []byte(s.input), changes)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expectedDidChange, didChange)
|
||||
if didChange {
|
||||
assert.Equal(t, s.expected, string(actual))
|
||||
}
|
||||
assert.Equal(t, s.expectedChanges, changes.ToSliceFromOldest())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/karimkhaleel/jsonschema"
|
||||
"github.com/samber/lo"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ColorPatterns assigns colors to the names that match regular expressions.
|
||||
// It's written in YAML as a mapping from pattern to color, and keeps the
|
||||
// patterns in the order in which they are written, so that the first pattern
|
||||
// that matches a name can decide its color.
|
||||
type ColorPatterns []ColorPattern
|
||||
|
||||
type ColorPattern struct {
|
||||
Pattern string
|
||||
Color string
|
||||
}
|
||||
|
||||
// UnmarshalYAML puts the patterns it reads in front of the ones that are there
|
||||
// already, which come from config files that were loaded earlier.
|
||||
func (p *ColorPatterns) UnmarshalYAML(node *yaml.Node) error {
|
||||
// Decoding into a map reports malformed input the same way as for the
|
||||
// other maps in the config.
|
||||
var colors map[string]string
|
||||
if err := node.Decode(&colors); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
patterns := make(ColorPatterns, 0, len(colors))
|
||||
for i := 0; i < len(node.Content)-1; i += 2 {
|
||||
var pattern string
|
||||
if err := node.Content[i].Decode(&pattern); err != nil {
|
||||
return err
|
||||
}
|
||||
patterns = append(patterns, ColorPattern{Pattern: pattern, Color: colors[pattern]})
|
||||
}
|
||||
|
||||
*p = patterns.over(*p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p ColorPatterns) MarshalYAML() (any, error) {
|
||||
node := &yaml.Node{Kind: yaml.MappingNode}
|
||||
for _, pattern := range p {
|
||||
node.Content = append(node.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Pattern},
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Color},
|
||||
)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// JSONSchema describes the patterns as the mapping they are written as.
|
||||
func (ColorPatterns) JSONSchema() *jsonschema.Schema {
|
||||
return &jsonschema.Schema{
|
||||
Type: "object",
|
||||
AdditionalProperties: &jsonschema.Schema{Type: "string"},
|
||||
}
|
||||
}
|
||||
|
||||
// over returns p followed by the patterns of lower that p doesn't have.
|
||||
func (p ColorPatterns) over(lower ColorPatterns) ColorPatterns {
|
||||
return slices.Concat(p, lo.Reject(lower, func(l ColorPattern, _ int) bool {
|
||||
return slices.ContainsFunc(p, func(c ColorPattern) bool { return c.Pattern == l.Pattern })
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type colorPatternsConfig struct {
|
||||
Patterns ColorPatterns `yaml:"patterns"`
|
||||
}
|
||||
|
||||
func TestColorPatternsKeepTheirOrder(t *testing.T) {
|
||||
var config colorPatternsConfig
|
||||
err := yaml.Unmarshal([]byte("patterns:\n"+
|
||||
" '^b': red\n"+
|
||||
" '^a': '#00ff00'\n"+
|
||||
" '^c': blue\n"), &config)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ColorPatterns{
|
||||
{Pattern: "^b", Color: "red"},
|
||||
{Pattern: "^a", Color: "#00ff00"},
|
||||
{Pattern: "^c", Color: "blue"},
|
||||
}, config.Patterns)
|
||||
}
|
||||
|
||||
func TestColorPatternsOfALaterFileComeFirst(t *testing.T) {
|
||||
var config colorPatternsConfig
|
||||
err := yaml.Unmarshal([]byte("patterns:\n"+
|
||||
" '^a': red\n"+
|
||||
" '^b': green\n"), &config)
|
||||
assert.NoError(t, err)
|
||||
err = yaml.Unmarshal([]byte("patterns:\n"+
|
||||
" '^c': blue\n"+
|
||||
" '^b': yellow\n"), &config)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ColorPatterns{
|
||||
{Pattern: "^c", Color: "blue"},
|
||||
{Pattern: "^b", Color: "yellow"},
|
||||
{Pattern: "^a", Color: "red"},
|
||||
}, config.Patterns)
|
||||
}
|
||||
|
||||
func TestColorPatternsMustBeAMapping(t *testing.T) {
|
||||
var config colorPatternsConfig
|
||||
err := yaml.Unmarshal([]byte("patterns: 5\n"), &config)
|
||||
assert.ErrorContains(t, err, "cannot unmarshal !!int `5` into map[string]string")
|
||||
}
|
||||
|
||||
func TestColorPatternsSurviveMarshalling(t *testing.T) {
|
||||
config := colorPatternsConfig{Patterns: ColorPatterns{
|
||||
{Pattern: "^b", Color: "red"},
|
||||
{Pattern: "^a", Color: "#00ff00"},
|
||||
{Pattern: "true", Color: "blue"},
|
||||
}}
|
||||
content, err := yaml.Marshal(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var roundTripped colorPatternsConfig
|
||||
err = yaml.Unmarshal(content, &roundTripped)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, config, roundTripped)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -60,18 +61,23 @@ func (self *DiffRendererConfigManager) GetDiffRendererType() DiffRendererType {
|
||||
return currentDiffRendererConfig.getType()
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetStdinFilterCommand(width int) string {
|
||||
// DiffRendererValues are what the command of a diff renderer can refer to.
|
||||
type DiffRendererValues struct {
|
||||
// The width of the view that the diff is rendered into
|
||||
Width int
|
||||
// The number of lines of context around each hunk
|
||||
DiffContext uint64
|
||||
// Whether the terminal has a light background
|
||||
LightBackground bool
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetStdinFilterCommand(values DiffRendererValues) (string, error) {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter {
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
|
||||
templateValues := map[string]string{
|
||||
"columnWidth": strconv.Itoa(width/2 - 6),
|
||||
}
|
||||
|
||||
commandTemplate := string(currentDiffRendererConfig.Command)
|
||||
return utils.ResolvePlaceholderString(commandTemplate, templateValues)
|
||||
return currentDiffRendererConfig.resolveCommand(values)
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetColorArg() string {
|
||||
@@ -87,17 +93,46 @@ func (self *DiffRendererConfigManager) GetColorArg() string {
|
||||
return colorArg
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
|
||||
func (self *DiffRendererConfigManager) GetExternalDiffCommand(values DiffRendererValues) (string, error) {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
|
||||
templateValues := map[string]string{
|
||||
"diffContext": strconv.Itoa(int(diffContext)),
|
||||
return currentDiffRendererConfig.resolveCommand(values)
|
||||
}
|
||||
|
||||
// resolveCommand resolves the renderer's command, which is a Go template with
|
||||
// the values it can refer to as its variables. A variable can be written with
|
||||
// or without the leading dot, as in {{.width}} or {{width}}.
|
||||
func (self *DiffRendererConfig) resolveCommand(values DiffRendererValues) (string, error) {
|
||||
colorScheme := "dark"
|
||||
if values.LightBackground {
|
||||
colorScheme = "light"
|
||||
}
|
||||
variables := map[string]any{
|
||||
"width": values.Width,
|
||||
"colorScheme": colorScheme,
|
||||
}
|
||||
switch self.getType() {
|
||||
case DiffRendererType_StdinFilter:
|
||||
variables["columnWidth"] = values.Width/2 - 6
|
||||
case DiffRendererType_ExtDiff:
|
||||
variables["diffContext"] = values.DiffContext
|
||||
case DiffRendererType_RawGit:
|
||||
// has no command
|
||||
}
|
||||
|
||||
return utils.ResolvePlaceholderString(string(currentDiffRendererConfig.Command), templateValues)
|
||||
funcs := template.FuncMap{}
|
||||
for name, value := range variables {
|
||||
funcs[name] = func() any { return value }
|
||||
}
|
||||
|
||||
command, err := utils.ResolveTemplate(string(self.Command), variables, funcs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git.diffRenderers: can't use the command '%s': %w", self.Command, err)
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetRawGitArgs() []string {
|
||||
|
||||
@@ -63,6 +63,158 @@ func TestCurrentDiffRendererName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStdinFilterCommand(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
diffRendererConfig DiffRendererConfig
|
||||
width int
|
||||
lightBackground bool
|
||||
expected string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "a command without template variables is passed through",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --paging=never"},
|
||||
width: 120,
|
||||
expected: "delta --paging=never",
|
||||
},
|
||||
{
|
||||
name: "the width the diff is rendered at",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{width}}"},
|
||||
width: 120,
|
||||
expected: "delta --width=120",
|
||||
},
|
||||
{
|
||||
name: "the width of one side of a side-by-side rendering",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "ydiff -p cat -w {{columnWidth}}"},
|
||||
width: 120,
|
||||
expected: "ydiff -p cat -w 54",
|
||||
},
|
||||
{
|
||||
name: "a template variable can also be written with a leading dot",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{.width}}"},
|
||||
width: 120,
|
||||
expected: "delta --width=120",
|
||||
},
|
||||
{
|
||||
name: "the color scheme on a dark background",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --{{colorScheme}}"},
|
||||
width: 120,
|
||||
expected: "delta --dark",
|
||||
},
|
||||
{
|
||||
name: "the color scheme on a light background",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --{{colorScheme}}"},
|
||||
width: 120,
|
||||
lightBackground: true,
|
||||
expected: "delta --light",
|
||||
},
|
||||
{
|
||||
name: "the command can choose between options by the color scheme",
|
||||
diffRendererConfig: DiffRendererConfig{Command: `delta --syntax-theme={{if eq .colorScheme "light"}}GitHub{{else}}Dracula{{end}}`},
|
||||
width: 120,
|
||||
lightBackground: true,
|
||||
expected: "delta --syntax-theme=GitHub",
|
||||
},
|
||||
{
|
||||
name: "the command can use template expressions",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta{{if gt .width 100}} --side-by-side{{end}}"},
|
||||
width: 120,
|
||||
expected: "delta --side-by-side",
|
||||
},
|
||||
{
|
||||
name: "an unknown template variable is an error",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{.widht}}"},
|
||||
width: 120,
|
||||
expectedError: "can't use the command 'delta --width={{.widht}}'",
|
||||
},
|
||||
{
|
||||
name: "an unknown template variable without a leading dot is an error too",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{widht}}"},
|
||||
width: 120,
|
||||
expectedError: "can't use the command 'delta --width={{widht}}'",
|
||||
},
|
||||
{
|
||||
name: "nothing is returned for a renderer of another type",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}}"},
|
||||
width: 120,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig}
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
|
||||
|
||||
command, err := config.GetStdinFilterCommand(DiffRendererValues{Width: s.width, LightBackground: s.lightBackground})
|
||||
if s.expectedError != "" {
|
||||
assert.ErrorContains(t, err, s.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expected, command)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExternalDiffCommand(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
diffRendererConfig DiffRendererConfig
|
||||
expected string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "a command without template variables is passed through",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
|
||||
expected: "difft --color=always",
|
||||
},
|
||||
{
|
||||
name: "the width the diff is rendered at",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}}"},
|
||||
expected: "difft --width=120",
|
||||
},
|
||||
{
|
||||
name: "the color scheme",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --background={{colorScheme}}"},
|
||||
expected: "difft --background=dark",
|
||||
},
|
||||
{
|
||||
name: "the width alongside the diff context size",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}} --context={{diffContext}}"},
|
||||
expected: "difft --width=120 --context=3",
|
||||
},
|
||||
{
|
||||
name: "a variable of stdin filters is an error",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"},
|
||||
expectedError: "can't use the command 'difft --width={{columnWidth}}'",
|
||||
},
|
||||
{
|
||||
name: "nothing is returned for a renderer of another type",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{width}}"},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig}
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
|
||||
|
||||
command, err := config.GetExternalDiffCommand(DiffRendererValues{Width: 120, DiffContext: 3})
|
||||
if s.expectedError != "" {
|
||||
assert.ErrorContains(t, err, s.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, s.expected, command)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// ThemeForBackground returns gui.theme with the overrides for a dark or a light
|
||||
// background applied, and with the defaults for that background in the fields
|
||||
// that neither of them sets. backgroundColor is the terminal's background color
|
||||
// as #rrggbb, or empty if we don't know it.
|
||||
func (c *GuiConfig) ThemeForBackground(lightBackground bool, backgroundColor string) ThemeConfig {
|
||||
override := lo.Ternary(lightBackground, c.LightTheme, c.DarkTheme)
|
||||
return mergeThemes(override, c.Theme, themeDefaults(lightBackground, backgroundColor))
|
||||
}
|
||||
|
||||
// themeDefaults returns the defaults of the theme fields whose default depends
|
||||
// on the terminal's background. GetDefaultConfig leaves these fields empty in
|
||||
// gui.theme, so that a value there comes from the user, and wins over these.
|
||||
//
|
||||
// Most of them are derived from the background color, so that they keep the
|
||||
// same distance from it however dark or light it is. If we don't know the
|
||||
// background color, we assume black or white.
|
||||
func themeDefaults(lightBackground bool, backgroundColor string) ThemeConfig {
|
||||
if len(backgroundColor) != 7 || !utils.IsValidHexValue(backgroundColor) {
|
||||
backgroundColor = lo.Ternary(lightBackground, "#ffffff", "#000000")
|
||||
}
|
||||
|
||||
if lightBackground {
|
||||
return ThemeConfig{
|
||||
// The colors of a light palette are dark enough to read as text on
|
||||
// the background, so none of them works as a background for text.
|
||||
// Some blue mixed into the background keeps the colored text on the
|
||||
// selected line as readable as elsewhere.
|
||||
SelectedLineBgColor: []string{mixHexColors(backgroundColor, "#0064ff", 0.25)},
|
||||
InactiveViewSelectedLineBgColor: []string{mixHexColors(backgroundColor, "#000000", 0.15)},
|
||||
}
|
||||
}
|
||||
|
||||
return ThemeConfig{
|
||||
// Dark palettes make their blue dark enough to work as a background for
|
||||
// text. Unlike a color derived from a dark background, it doesn't turn
|
||||
// into black on terminals with only 8 colors.
|
||||
SelectedLineBgColor: []string{"blue"},
|
||||
InactiveViewSelectedLineBgColor: []string{mixHexColors(backgroundColor, "#ffffff", 0.3)},
|
||||
}
|
||||
}
|
||||
|
||||
// mixHexColors mixes two colors given as #rrggbb. amount is how much of b to
|
||||
// take: 0 gives a, 1 gives b.
|
||||
func mixHexColors(a, b string, amount float64) string {
|
||||
channel := func(color string, i int) float64 {
|
||||
value, _ := strconv.ParseUint(color[1+2*i:3+2*i], 16, 8)
|
||||
return float64(value)
|
||||
}
|
||||
|
||||
result := "#"
|
||||
for i := range 3 {
|
||||
mixed := channel(a, i) + (channel(b, i)-channel(a, i))*amount
|
||||
result += fmt.Sprintf("%02x", int(math.Round(mixed)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeThemes takes each field from the first of the themes that sets it. For
|
||||
// maps and color patterns it merges the entries instead, and an entry of an
|
||||
// earlier theme wins over one with the same key in a later theme.
|
||||
func mergeThemes(themes ...ThemeConfig) ThemeConfig {
|
||||
var result ThemeConfig
|
||||
resultValue := reflect.ValueOf(&result).Elem()
|
||||
for i := range resultValue.NumField() {
|
||||
values := lo.Map(themes, func(theme ThemeConfig, _ int) any {
|
||||
return reflect.ValueOf(theme).Field(i).Interface()
|
||||
})
|
||||
resultValue.Field(i).Set(reflect.ValueOf(mergeThemeField(values)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mergeThemeField(values []any) any {
|
||||
switch values[0].(type) {
|
||||
case []string:
|
||||
return lo.FindOrElse(lo.Map(values, func(v any, _ int) []string { return v.([]string) }), nil,
|
||||
func(v []string) bool { return len(v) > 0 })
|
||||
case map[string]string:
|
||||
merged := map[string]string{}
|
||||
for _, v := range slices.Backward(values) {
|
||||
maps.Copy(merged, v.(map[string]string))
|
||||
}
|
||||
return merged
|
||||
case ColorPatterns:
|
||||
return lo.Reduce(values, func(merged ColorPatterns, v any, _ int) ColorPatterns {
|
||||
return merged.over(v.(ColorPatterns))
|
||||
}, nil)
|
||||
default:
|
||||
panic(fmt.Sprintf("don't know how to merge a theme field of type %T", values[0]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestThemeForBackground(t *testing.T) {
|
||||
gui := GuiConfig{
|
||||
Theme: ThemeConfig{
|
||||
ActiveBorderColor: []string{"green"},
|
||||
InactiveBorderColor: []string{"default"},
|
||||
AuthorColors: map[string]string{"Alice": "red", "Bob": "blue"},
|
||||
BranchColorPatterns: ColorPatterns{
|
||||
{Pattern: "^docs/", Color: "cyan"},
|
||||
{Pattern: "^feature/", Color: "green"},
|
||||
},
|
||||
},
|
||||
DarkTheme: ThemeConfig{
|
||||
ActiveBorderColor: []string{"yellow"},
|
||||
},
|
||||
LightTheme: ThemeConfig{
|
||||
InactiveBorderColor: []string{"#777777"},
|
||||
AuthorColors: map[string]string{"Bob": "#000080"},
|
||||
BranchColorPatterns: ColorPatterns{
|
||||
{Pattern: "ISSUE", Color: "red"},
|
||||
{Pattern: "^docs/", Color: "#008080"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dark := gui.ThemeForBackground(false, "")
|
||||
assert.Equal(t, []string{"yellow"}, dark.ActiveBorderColor)
|
||||
assert.Equal(t, []string{"default"}, dark.InactiveBorderColor)
|
||||
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "blue"}, dark.AuthorColors)
|
||||
assert.Equal(t, gui.Theme.BranchColorPatterns, dark.BranchColorPatterns)
|
||||
|
||||
light := gui.ThemeForBackground(true, "")
|
||||
assert.Equal(t, []string{"green"}, light.ActiveBorderColor)
|
||||
assert.Equal(t, []string{"#777777"}, light.InactiveBorderColor)
|
||||
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "#000080"}, light.AuthorColors)
|
||||
assert.Equal(t, ColorPatterns{
|
||||
{Pattern: "ISSUE", Color: "red"},
|
||||
{Pattern: "^docs/", Color: "#008080"},
|
||||
{Pattern: "^feature/", Color: "green"},
|
||||
}, light.BranchColorPatterns)
|
||||
|
||||
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "blue"}, gui.Theme.AuthorColors,
|
||||
"merging must leave the themes it merges alone")
|
||||
}
|
||||
|
||||
func TestEveryThemeFieldCanBeOverridden(t *testing.T) {
|
||||
var override ThemeConfig
|
||||
overrideValue := reflect.ValueOf(&override).Elem()
|
||||
for i := range overrideValue.NumField() {
|
||||
field := overrideValue.Field(i)
|
||||
switch field.Interface().(type) {
|
||||
case []string:
|
||||
field.Set(reflect.ValueOf([]string{"#123456"}))
|
||||
case map[string]string:
|
||||
field.Set(reflect.ValueOf(map[string]string{"key": "#123456"}))
|
||||
case ColorPatterns:
|
||||
field.Set(reflect.ValueOf(ColorPatterns{{Pattern: "key", Color: "#123456"}}))
|
||||
default:
|
||||
t.Fatalf("no test value for theme field %s", overrideValue.Type().Field(i).Name)
|
||||
}
|
||||
}
|
||||
|
||||
gui := GetDefaultConfig().Gui
|
||||
gui.DarkTheme = override
|
||||
assert.Equal(t, override, gui.ThemeForBackground(false, ""))
|
||||
}
|
||||
|
||||
func TestThemeForBackgroundFallsBackToTheDefaultsForTheBackground(t *testing.T) {
|
||||
gui := GetDefaultConfig().Gui
|
||||
assert.Equal(t, []string{"#4d4d4d"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
|
||||
assert.Equal(t, []string{"#d9d9d9"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
|
||||
|
||||
gui.Theme.InactiveViewSelectedLineBgColor = []string{"bold"}
|
||||
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
|
||||
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
|
||||
|
||||
gui.LightTheme.InactiveViewSelectedLineBgColor = []string{"white"}
|
||||
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
|
||||
assert.Equal(t, []string{"white"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
|
||||
}
|
||||
|
||||
func TestBackgroundDefaultsAreDerivedFromTheBackgroundColor(t *testing.T) {
|
||||
gui := GetDefaultConfig().Gui
|
||||
assert.Equal(t, []string{"#626262"}, gui.ThemeForBackground(false, "#1e1e1e").InactiveViewSelectedLineBgColor)
|
||||
assert.Equal(t, []string{"#d7d1c1"}, gui.ThemeForBackground(true, "#fdf6e3").InactiveViewSelectedLineBgColor)
|
||||
|
||||
assert.Equal(t, []string{"blue"}, gui.ThemeForBackground(false, "#1e1e1e").SelectedLineBgColor)
|
||||
assert.Equal(t, []string{"#bed2ea"}, gui.ThemeForBackground(true, "#fdf6e3").SelectedLineBgColor)
|
||||
assert.Equal(t, []string{"#bfd8ff"}, gui.ThemeForBackground(true, "").SelectedLineBgColor)
|
||||
}
|
||||
|
||||
// If gui.theme had a default for a field that also has a default for the
|
||||
// terminal's background, the former would always win.
|
||||
func TestFieldsWithBackgroundDefaultsHaveNoDefaultInGuiTheme(t *testing.T) {
|
||||
genericDefaults := setThemeFields(GetDefaultConfig().Gui.Theme)
|
||||
for _, field := range setThemeFields(themeDefaults(false, "")) {
|
||||
assert.NotContains(t, genericDefaults, field)
|
||||
}
|
||||
for _, field := range setThemeFields(themeDefaults(true, "")) {
|
||||
assert.NotContains(t, genericDefaults, field)
|
||||
}
|
||||
}
|
||||
|
||||
// If only one of the backgrounds had a default for a field, the field would
|
||||
// have no value at all with the other.
|
||||
func TestDarkAndLightDefaultsSetTheSameFields(t *testing.T) {
|
||||
assert.Equal(t, setThemeFields(themeDefaults(false, "")), setThemeFields(themeDefaults(true, "")))
|
||||
}
|
||||
|
||||
func setThemeFields(theme ThemeConfig) []string {
|
||||
var fields []string
|
||||
themeValue := reflect.ValueOf(theme)
|
||||
for i := range themeValue.NumField() {
|
||||
if themeValue.Field(i).Len() > 0 {
|
||||
fields = append(fields, themeValue.Type().Field(i).Name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
+32
-20
@@ -68,13 +68,6 @@ func (c *RefresherConfig) ExternalChangeCheckIntervalDuration() time.Duration {
|
||||
}
|
||||
|
||||
type GuiConfig struct {
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
|
||||
AuthorColors map[string]string `yaml:"authorColors"`
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
// Deprecated: use branchColorPatterns instead
|
||||
BranchColors map[string]string `yaml:"branchColors" jsonschema:"deprecated"`
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
BranchColorPatterns map[string]string `yaml:"branchColorPatterns"`
|
||||
// Custom icons for filenames and file extensions
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color
|
||||
CustomIcons CustomIconsConfig `yaml:"customIcons"`
|
||||
@@ -141,9 +134,19 @@ type GuiConfig struct {
|
||||
// Format used when displaying time if the time is less than 24 hours ago.
|
||||
// Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
|
||||
ShortTimeFormat string `yaml:"shortTimeFormat"`
|
||||
// Whether the terminal has a dark or a light background. This decides whether 'darkTheme' or 'lightTheme' applies, and the colors of authors are picked to stand out against it.
|
||||
// One of: 'auto' (default) | 'dark' | 'light'
|
||||
// With 'auto', lazygit asks the terminal, and assumes a dark background if the terminal doesn't tell.
|
||||
ColorScheme string `yaml:"colorScheme" jsonschema:"enum=auto,enum=dark,enum=light"`
|
||||
// Config relating to colors and styles.
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
|
||||
Theme ThemeConfig `yaml:"theme"`
|
||||
// Colors and styles that override those in 'theme' when the terminal has a dark background. It has the same fields as 'theme'.
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
|
||||
DarkTheme ThemeConfig `yaml:"darkTheme"`
|
||||
// Colors and styles that override those in 'theme' when the terminal has a light background. It has the same fields as 'theme'.
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
|
||||
LightTheme ThemeConfig `yaml:"lightTheme"`
|
||||
// Config relating to the commit length indicator
|
||||
CommitLength CommitLengthConfig `yaml:"commitLength"`
|
||||
// If true, show the '5 of 20' footer at the bottom of list views
|
||||
@@ -237,10 +240,15 @@ type ThemeConfig struct {
|
||||
SearchingActiveBorderColor []string `yaml:"searchingActiveBorderColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Color of keybindings help text in the bottom line
|
||||
OptionsTextColor []string `yaml:"optionsTextColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Color and attributes of the text of the selected line. The attributes are added to those of the text, and a color replaces the colors of the text.
|
||||
// Set it to 'default' to leave the text as it is, e.g. if you don't want the selected line in bold.
|
||||
SelectedLineFgColor []string `yaml:"selectedLineFgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Background color of selected line.
|
||||
// Default: 'blue' if the terminal has a dark background, or a suitable RGB blue computed from the background color if it is light.
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
|
||||
SelectedLineBgColor []string `yaml:"selectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Background color of selected line when view doesn't have focus.
|
||||
// Default: a suitable RGB grey computed from the terminal's background color.
|
||||
InactiveViewSelectedLineBgColor []string `yaml:"inactiveViewSelectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Foreground color of copied commit
|
||||
CherryPickedCommitFgColor []string `yaml:"cherryPickedCommitFgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
@@ -254,6 +262,10 @@ type ThemeConfig struct {
|
||||
UnstagedChangesColor []string `yaml:"unstagedChangesColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// Default text color
|
||||
DefaultFgColor []string `yaml:"defaultFgColor" jsonschema:"minItems=1,uniqueItems=true"`
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
|
||||
AuthorColors map[string]string `yaml:"authorColors"`
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
|
||||
BranchColorPatterns ColorPatterns `yaml:"branchColorPatterns"`
|
||||
}
|
||||
|
||||
type CommitLengthConfig struct {
|
||||
@@ -316,7 +328,7 @@ type GitConfig struct {
|
||||
AutoRefresh bool `yaml:"autoRefresh"`
|
||||
// If true, poll the repo periodically for external ref changes (commits, branch updates, checkouts made outside lazygit) and refresh when one is detected. Independent of autoRefresh, which only governs the files panel.
|
||||
AutoDetectExternalChanges bool `yaml:"autoDetectExternalChanges"`
|
||||
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).
|
||||
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). A branch that is checked out in another worktree is fast-forwarded there, unless that worktree has changes to tracked files or is in the middle of a rebase or bisect.
|
||||
// Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
|
||||
AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"`
|
||||
// If true, pass the --all arg to git fetch
|
||||
@@ -881,19 +893,19 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
Language: "auto",
|
||||
TimeFormat: "02 Jan 06",
|
||||
ShortTimeFormat: time.Kitchen,
|
||||
ColorScheme: "auto",
|
||||
Theme: ThemeConfig{
|
||||
ActiveBorderColor: []string{"green", "bold"},
|
||||
SearchingActiveBorderColor: []string{"cyan", "bold"},
|
||||
InactiveBorderColor: []string{"default"},
|
||||
OptionsTextColor: []string{"blue"},
|
||||
SelectedLineBgColor: []string{"blue"},
|
||||
InactiveViewSelectedLineBgColor: []string{"bold"},
|
||||
CherryPickedCommitBgColor: []string{"cyan"},
|
||||
CherryPickedCommitFgColor: []string{"blue"},
|
||||
MarkedBaseCommitBgColor: []string{"yellow"},
|
||||
MarkedBaseCommitFgColor: []string{"blue"},
|
||||
UnstagedChangesColor: []string{"red"},
|
||||
DefaultFgColor: []string{"default"},
|
||||
ActiveBorderColor: []string{"green", "bold"},
|
||||
SearchingActiveBorderColor: []string{"cyan", "bold"},
|
||||
InactiveBorderColor: []string{"dim"},
|
||||
OptionsTextColor: []string{"blue"},
|
||||
SelectedLineFgColor: []string{"bold"},
|
||||
CherryPickedCommitBgColor: []string{"cyan"},
|
||||
CherryPickedCommitFgColor: []string{"blue"},
|
||||
MarkedBaseCommitBgColor: []string{"yellow"},
|
||||
MarkedBaseCommitFgColor: []string{"blue"},
|
||||
UnstagedChangesColor: []string{"red"},
|
||||
DefaultFgColor: []string{"default"},
|
||||
},
|
||||
CommitLength: CommitLengthConfig{Show: true},
|
||||
SkipNoStagedFilesWarning: false,
|
||||
|
||||
@@ -18,6 +18,10 @@ func (config *UserConfig) Validate() error {
|
||||
[]string{"dashboard", "allBranchesLog"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("gui.colorScheme", config.Gui.ColorScheme,
|
||||
[]string{"auto", "dark", "light"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("gui.showDivergenceFromBaseBranch", config.Gui.ShowDivergenceFromBaseBranch,
|
||||
[]string{"none", "onlyArrow", "arrowAndNumber"}); err != nil {
|
||||
return err
|
||||
@@ -120,10 +124,16 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
|
||||
if len(diffRenderer.Args) > 0 {
|
||||
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'stdinFilter'.")
|
||||
}
|
||||
if err := validateDiffRendererCommand(diffRenderer); err != nil {
|
||||
return err
|
||||
}
|
||||
case "extDiff":
|
||||
if len(diffRenderer.Args) > 0 {
|
||||
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'extDiff'.")
|
||||
}
|
||||
if err := validateDiffRendererCommand(diffRenderer); err != nil {
|
||||
return err
|
||||
}
|
||||
case "rawGit":
|
||||
if diffRenderer.Command != "" {
|
||||
return errors.New("git.diffRenderers: 'command' cannot be used with diff renderer type 'rawGit'.")
|
||||
@@ -135,6 +145,14 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDiffRendererCommand resolves the command with made-up values, so that
|
||||
// a mistake in it shows up when the config is loaded rather than when a diff
|
||||
// is rendered.
|
||||
func validateDiffRendererCommand(diffRenderer DiffRendererConfig) error {
|
||||
_, err := diffRenderer.resolveCommand(DiffRendererValues{Width: 80, DiffContext: 3})
|
||||
return err
|
||||
}
|
||||
|
||||
func validateEnum(name string, value string, allowedValues []string) error {
|
||||
if slices.Contains(allowedValues, value) {
|
||||
return nil
|
||||
|
||||
@@ -409,9 +409,14 @@ func TestUserConfigValidate_diffRenderers(t *testing.T) {
|
||||
{name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter"}, valid: false},
|
||||
{name: "stdinFilter with type default without command", diffRenderer: DiffRendererConfig{}, valid: false},
|
||||
{name: "stdinFilter with args", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta", Args: []string{"-x"}}, valid: false},
|
||||
{name: "stdinFilter with a template", diffRenderer: DiffRendererConfig{Command: "delta --width={{width}}{{if gt .width 100}} --side-by-side{{end}}"}, valid: true},
|
||||
{name: "stdinFilter with an unknown template variable", diffRenderer: DiffRendererConfig{Command: "delta --width={{.widht}}"}, valid: false},
|
||||
{name: "stdinFilter with a broken template", diffRenderer: DiffRendererConfig{Command: "delta {{if .width}}"}, valid: false},
|
||||
{name: "external diff", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft"}, valid: true},
|
||||
{name: "external diff without command", diffRenderer: DiffRendererConfig{Type: "extDiff"}, valid: true},
|
||||
{name: "external diff with args", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft", Args: []string{"-x"}}, valid: false},
|
||||
{name: "external diff with a template", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --context={{diffContext}}"}, valid: true},
|
||||
{name: "external diff with a variable of stdin filters", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"}, valid: false},
|
||||
{name: "raw git", diffRenderer: DiffRendererConfig{Type: "rawGit"}, valid: true},
|
||||
{name: "raw git with args", diffRenderer: DiffRendererConfig{Type: "rawGit", Args: []string{"-x"}}, valid: true},
|
||||
{name: "raw git with command", diffRenderer: DiffRendererConfig{Type: "rawGit", Command: "delta"}, valid: false},
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
// ColorScheme says whether the terminal shows light text on a dark background,
|
||||
// or dark text on a light one.
|
||||
type ColorScheme int
|
||||
|
||||
const (
|
||||
ColorSchemeUnknown ColorScheme = iota
|
||||
ColorSchemeDark
|
||||
ColorSchemeLight
|
||||
)
|
||||
|
||||
func (self ColorScheme) String() string {
|
||||
switch self {
|
||||
case ColorSchemeDark:
|
||||
return "dark"
|
||||
case ColorSchemeLight:
|
||||
return "light"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DetectedColorScheme is what the terminal has told us about its colors.
|
||||
type DetectedColorScheme struct {
|
||||
ColorScheme ColorScheme
|
||||
// The background color that ColorScheme was derived from, as #rrggbb. It is
|
||||
// empty if the terminal didn't tell us its background color; ColorScheme is
|
||||
// then whatever the terminal said about itself, if anything.
|
||||
Background string
|
||||
}
|
||||
|
||||
func (self DetectedColorScheme) String() string {
|
||||
if self.Background != "" {
|
||||
return fmt.Sprintf("%s (background %s)", self.ColorScheme, self.Background)
|
||||
}
|
||||
if self.ColorScheme != ColorSchemeUnknown {
|
||||
return fmt.Sprintf("%s (reported by the terminal)", self.ColorScheme)
|
||||
}
|
||||
return self.ColorScheme.String()
|
||||
}
|
||||
|
||||
const (
|
||||
// Makes the terminal send a color scheme report whenever its colors change
|
||||
enableColorSchemeReports = "\x1b[?2031h"
|
||||
disableColorSchemeReports = "\x1b[?2031l"
|
||||
// Asks for a color scheme report: CSI ? 997 ; 1 n for dark, 2 for light
|
||||
requestColorScheme = "\x1b[?996n"
|
||||
// Asks for the background color: OSC 11 ; rgb:RRRR/GGGG/BBBB, ended by BEL
|
||||
// or ST. Ending the request with BEL makes more terminals reply.
|
||||
requestBackgroundColor = "\x1b]11;?\a"
|
||||
)
|
||||
|
||||
// colorSchemeTty is a tcell.Tty that finds out the terminal's color scheme.
|
||||
//
|
||||
// Some terminals say whether they are dark or light, and can report it again
|
||||
// whenever that changes. What they base this on varies, though: some go by the
|
||||
// terminal's own colors, others by the dark or light mode of the operating
|
||||
// system, whether or not the terminal follows it. So we also ask for the
|
||||
// background color, and when the terminal tells us that, it decides; a color
|
||||
// scheme report is then only a sign that the background may have changed.
|
||||
//
|
||||
// The queries go out when tcell starts the tty, which is before tcell sends its
|
||||
// own queries during Screen.Init and waits for their answer. Terminals answer in
|
||||
// order, so the answers to ours have arrived by the time Init returns, and we
|
||||
// know the color scheme before drawing anything without waiting for it.
|
||||
//
|
||||
// tcell doesn't understand the answers and drops them, so all we need to do is
|
||||
// watch for them in the input as it goes by.
|
||||
type colorSchemeTty struct {
|
||||
tcell.Tty
|
||||
|
||||
// What we send when the tty starts
|
||||
queries string
|
||||
|
||||
// Guards all of the fields below, and serializes our writes to the terminal
|
||||
// with tcell's
|
||||
mutex sync.Mutex
|
||||
|
||||
started bool
|
||||
|
||||
background colorful.Color
|
||||
// Whether the terminal has told us its background color
|
||||
haveBackground bool
|
||||
// Whether we have asked for the background color and are waiting for the
|
||||
// answer
|
||||
backgroundRequested bool
|
||||
// The color scheme that the terminal last reported for itself
|
||||
reported ColorScheme
|
||||
// Whether we have asked for a color scheme report and are waiting for it
|
||||
colorSchemeRequested bool
|
||||
// Closed once no answer that waitForReplies waits for is outstanding
|
||||
repliesArrived chan struct{}
|
||||
|
||||
notified DetectedColorScheme
|
||||
onChange func(DetectedColorScheme)
|
||||
|
||||
// Only used by Read, which tcell never calls concurrently with itself
|
||||
scanner terminalReplyScanner
|
||||
}
|
||||
|
||||
var _ tcell.Tty = &colorSchemeTty{}
|
||||
|
||||
func newColorSchemeTty(tty tcell.Tty) *colorSchemeTty {
|
||||
return &colorSchemeTty{
|
||||
Tty: tty,
|
||||
queries: colorSchemeQueries(
|
||||
os.Getenv("TERM"),
|
||||
os.Getenv("TERM_PROGRAM"),
|
||||
os.Getenv("TCELL_NEGOTIATE"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// colorSchemeQueries returns what to ask the terminal. It leaves out the
|
||||
// terminals that tcell doesn't send its own queries to (see
|
||||
// applyKnownTerminalProfile and the legacy terminals in tScreen.Init), except
|
||||
// for those that are known to answer a request for the background color.
|
||||
func colorSchemeQueries(term string, termProgram string, tcellNegotiate string) string {
|
||||
if tcellNegotiate == "disable" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if term == "st" || strings.HasPrefix(term, "st-") ||
|
||||
strings.HasPrefix(term, "vt") || strings.Contains(term, "ansi") ||
|
||||
term == "linux" || term == "sun" || term == "sun-color" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if termProgram == "Apple_Terminal" || termProgram == "WezTerm" {
|
||||
return requestBackgroundColor
|
||||
}
|
||||
|
||||
return enableColorSchemeReports + requestColorScheme + requestBackgroundColor
|
||||
}
|
||||
|
||||
// subscribe sets a function to call whenever the detected color scheme changes,
|
||||
// and returns the one detected so far. The function is called on the goroutine
|
||||
// that reads from the tty, so it must not block.
|
||||
func (self *colorSchemeTty) subscribe(onChange func(DetectedColorScheme)) DetectedColorScheme {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.onChange = onChange
|
||||
self.notified = self.detected()
|
||||
return self.notified
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Start() error {
|
||||
if err := self.Tty.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.started = true
|
||||
self.writeLocked(self.queries)
|
||||
if strings.Contains(self.queries, requestBackgroundColor) {
|
||||
self.backgroundRequested = true
|
||||
}
|
||||
if strings.Contains(self.queries, requestColorScheme) {
|
||||
self.colorSchemeRequested = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForReplies waits until the terminal has answered what we asked it, but
|
||||
// no longer than the timeout. Call it before handing the terminal to another
|
||||
// program, or the answers would reach that program as if they were typed. It
|
||||
// only waits for the answers that the terminal has given before, so a terminal
|
||||
// that doesn't answer at all costs no time.
|
||||
func (self *colorSchemeTty) waitForReplies(timeout time.Duration) {
|
||||
self.mutex.Lock()
|
||||
if !self.awaitingRepliesLocked() {
|
||||
self.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
if self.repliesArrived == nil {
|
||||
self.repliesArrived = make(chan struct{})
|
||||
}
|
||||
repliesArrived := self.repliesArrived
|
||||
self.mutex.Unlock()
|
||||
|
||||
select {
|
||||
case <-repliesArrived:
|
||||
case <-time.After(timeout):
|
||||
}
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) awaitingRepliesLocked() bool {
|
||||
return (self.backgroundRequested && self.haveBackground) ||
|
||||
(self.colorSchemeRequested && self.reported != ColorSchemeUnknown)
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Stop() error {
|
||||
self.mutex.Lock()
|
||||
// Otherwise, the program we hand the terminal to would receive the reports
|
||||
// as if they were typed
|
||||
if strings.Contains(self.queries, enableColorSchemeReports) {
|
||||
self.writeLocked(disableColorSchemeReports)
|
||||
}
|
||||
self.started = false
|
||||
self.mutex.Unlock()
|
||||
|
||||
return self.Tty.Stop()
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Write(p []byte) (int, error) {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
return self.Tty.Write(p)
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) Read(p []byte) (int, error) {
|
||||
n, err := self.Tty.Read(p)
|
||||
|
||||
for _, reply := range self.scanner.scan(p[:n]) {
|
||||
self.handleReply(reply)
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// onFocusGained asks for the background color again, for the terminals that
|
||||
// tell us their background color but not when it changes. When the operating
|
||||
// system switches between dark and light mode, the user is usually busy
|
||||
// elsewhere, so coming back to the terminal is a good time to check.
|
||||
func (self *colorSchemeTty) onFocusGained() {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.requestBackgroundColorLocked()
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) handleReply(reply terminalReply) {
|
||||
self.mutex.Lock()
|
||||
|
||||
if reply.background != nil {
|
||||
self.background = *reply.background
|
||||
self.haveBackground = true
|
||||
self.backgroundRequested = false
|
||||
} else {
|
||||
self.reported = reply.colorScheme
|
||||
self.colorSchemeRequested = false
|
||||
self.requestBackgroundColorLocked()
|
||||
}
|
||||
|
||||
if self.repliesArrived != nil && !self.awaitingRepliesLocked() {
|
||||
close(self.repliesArrived)
|
||||
self.repliesArrived = nil
|
||||
}
|
||||
|
||||
detected := self.detected()
|
||||
onChange := self.onChange
|
||||
changed := detected != self.notified
|
||||
if changed {
|
||||
self.notified = detected
|
||||
}
|
||||
|
||||
self.mutex.Unlock()
|
||||
|
||||
if changed && onChange != nil {
|
||||
onChange(detected)
|
||||
}
|
||||
}
|
||||
|
||||
// requestBackgroundColorLocked asks for the background color, if the terminal
|
||||
// has told us its background color before and isn't about to do it anyway.
|
||||
func (self *colorSchemeTty) requestBackgroundColorLocked() {
|
||||
if !self.started || !self.haveBackground || self.backgroundRequested {
|
||||
return
|
||||
}
|
||||
|
||||
self.writeLocked(requestBackgroundColor)
|
||||
self.backgroundRequested = true
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) detected() DetectedColorScheme {
|
||||
if !self.haveBackground {
|
||||
return DetectedColorScheme{ColorScheme: self.reported}
|
||||
}
|
||||
|
||||
return DetectedColorScheme{
|
||||
ColorScheme: colorSchemeOfBackground(self.background),
|
||||
Background: self.background.Hex(),
|
||||
}
|
||||
}
|
||||
|
||||
func (self *colorSchemeTty) writeLocked(s string) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// If the terminal is gone, tcell finds out when it next writes or reads
|
||||
_, _ = self.Tty.Write([]byte(s))
|
||||
}
|
||||
|
||||
func colorSchemeOfBackground(background colorful.Color) ColorScheme {
|
||||
lightness, _, _ := background.Lab()
|
||||
if lightness < 0.5 {
|
||||
return ColorSchemeDark
|
||||
}
|
||||
return ColorSchemeLight
|
||||
}
|
||||
|
||||
// terminalReply is one of the answers that colorSchemeTty asks the terminal
|
||||
// for. Exactly one of its fields is set.
|
||||
type terminalReply struct {
|
||||
colorScheme ColorScheme
|
||||
background *colorful.Color
|
||||
}
|
||||
|
||||
type terminalReplyScannerState int
|
||||
|
||||
const (
|
||||
scanningText terminalReplyScannerState = iota
|
||||
scanningEscape
|
||||
scanningCsi
|
||||
scanningOsc
|
||||
scanningOscEscape
|
||||
)
|
||||
|
||||
// No reply we look for is longer than this; we don't look at longer sequences
|
||||
const maxTerminalReplyLength = 64
|
||||
|
||||
// terminalReplyScanner finds the terminal's answers to our color scheme queries
|
||||
// in its input. The input arrives in chunks, which may split an answer anywhere,
|
||||
// so the scanner keeps its state from one chunk to the next.
|
||||
type terminalReplyScanner struct {
|
||||
state terminalReplyScannerState
|
||||
sequence []byte
|
||||
oversized bool
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) scan(input []byte) []terminalReply {
|
||||
var replies []terminalReply
|
||||
|
||||
for _, b := range input {
|
||||
switch self.state {
|
||||
case scanningText:
|
||||
if b == '\x1b' {
|
||||
self.state = scanningEscape
|
||||
}
|
||||
|
||||
case scanningEscape:
|
||||
self.scanEscape(b)
|
||||
|
||||
case scanningCsi:
|
||||
switch {
|
||||
case b == '\x1b':
|
||||
self.state = scanningEscape
|
||||
case b >= 0x20 && b <= 0x3f: // parameter and intermediate bytes
|
||||
self.appendToSequence(b)
|
||||
case b >= 0x40 && b <= 0x7e: // final byte
|
||||
self.state = scanningText
|
||||
if b == 'n' && !self.oversized {
|
||||
if colorScheme, ok := parseColorSchemeReport(string(self.sequence)); ok {
|
||||
replies = append(replies, terminalReply{colorScheme: colorScheme})
|
||||
}
|
||||
}
|
||||
default:
|
||||
self.state = scanningText
|
||||
}
|
||||
|
||||
case scanningOsc:
|
||||
switch b {
|
||||
case '\a':
|
||||
self.state = scanningText
|
||||
replies = self.appendOscReply(replies)
|
||||
case '\x1b':
|
||||
self.state = scanningOscEscape
|
||||
default:
|
||||
self.appendToSequence(b)
|
||||
}
|
||||
|
||||
case scanningOscEscape:
|
||||
if b == '\\' {
|
||||
self.state = scanningText
|
||||
replies = self.appendOscReply(replies)
|
||||
} else {
|
||||
// Not a string terminator, so a new escape sequence has begun and
|
||||
// the one before it was cut short
|
||||
self.scanEscape(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return replies
|
||||
}
|
||||
|
||||
// scanEscape handles the byte after an ESC.
|
||||
func (self *terminalReplyScanner) scanEscape(b byte) {
|
||||
switch b {
|
||||
case '[':
|
||||
self.startSequence(scanningCsi)
|
||||
case ']':
|
||||
self.startSequence(scanningOsc)
|
||||
case '\x1b':
|
||||
self.state = scanningEscape
|
||||
default:
|
||||
self.state = scanningText
|
||||
}
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) startSequence(state terminalReplyScannerState) {
|
||||
self.state = state
|
||||
self.sequence = self.sequence[:0]
|
||||
self.oversized = false
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) appendToSequence(b byte) {
|
||||
if len(self.sequence) >= maxTerminalReplyLength {
|
||||
self.oversized = true
|
||||
return
|
||||
}
|
||||
self.sequence = append(self.sequence, b)
|
||||
}
|
||||
|
||||
func (self *terminalReplyScanner) appendOscReply(replies []terminalReply) []terminalReply {
|
||||
if self.oversized {
|
||||
return replies
|
||||
}
|
||||
|
||||
if background, ok := parseBackgroundColorReport(string(self.sequence)); ok {
|
||||
replies = append(replies, terminalReply{background: &background})
|
||||
}
|
||||
|
||||
return replies
|
||||
}
|
||||
|
||||
// parseColorSchemeReport parses the parameters of CSI ? 997 ; <n> n
|
||||
func parseColorSchemeReport(params string) (ColorScheme, bool) {
|
||||
switch params {
|
||||
case "?997;1":
|
||||
return ColorSchemeDark, true
|
||||
case "?997;2":
|
||||
return ColorSchemeLight, true
|
||||
default:
|
||||
return ColorSchemeUnknown, false
|
||||
}
|
||||
}
|
||||
|
||||
// parseBackgroundColorReport parses the content of OSC 11 ; rgb:R/G/B, where
|
||||
// each of R, G and B has from one to four hex digits. Some terminals send
|
||||
// rgba:R/G/B/A instead.
|
||||
func parseBackgroundColorReport(content string) (colorful.Color, bool) {
|
||||
spec, ok := strings.CutPrefix(content, "11;")
|
||||
if !ok {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
|
||||
var components []string
|
||||
if rgb, ok := strings.CutPrefix(spec, "rgb:"); ok {
|
||||
components = strings.Split(rgb, "/")
|
||||
if len(components) != 3 {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
} else if rgba, ok := strings.CutPrefix(spec, "rgba:"); ok {
|
||||
components = strings.Split(rgba, "/")
|
||||
if len(components) != 4 {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
} else {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
|
||||
values := [3]float64{}
|
||||
for i := range values {
|
||||
component := components[i]
|
||||
if len(component) < 1 || len(component) > 4 {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
value, err := strconv.ParseUint(component, 16, 16)
|
||||
if err != nil {
|
||||
return colorful.Color{}, false
|
||||
}
|
||||
maxValue := uint64(1)<<(4*len(component)) - 1
|
||||
values[i] = float64(value) / float64(maxValue)
|
||||
}
|
||||
|
||||
return colorful.Color{R: values[0], G: values[1], B: values[2]}, true
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func describeReplies(replies []terminalReply) []string {
|
||||
result := []string{}
|
||||
for _, reply := range replies {
|
||||
if reply.background != nil {
|
||||
result = append(result, reply.background.Hex())
|
||||
} else {
|
||||
result = append(result, reply.colorScheme.String())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestTerminalReplyScanner(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "dark report",
|
||||
input: "\x1b[?997;1n",
|
||||
expected: []string{"dark"},
|
||||
},
|
||||
{
|
||||
name: "light report",
|
||||
input: "\x1b[?997;2n",
|
||||
expected: []string{"light"},
|
||||
},
|
||||
{
|
||||
name: "background ended by ST",
|
||||
input: "\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\",
|
||||
expected: []string{"#1e1e1e"},
|
||||
},
|
||||
{
|
||||
name: "background ended by BEL",
|
||||
input: "\x1b]11;rgb:ffff/ffff/ffff\a",
|
||||
expected: []string{"#ffffff"},
|
||||
},
|
||||
{
|
||||
name: "background with two hex digits per component",
|
||||
input: "\x1b]11;rgb:fd/f6/e3\a",
|
||||
expected: []string{"#fdf6e3"},
|
||||
},
|
||||
{
|
||||
name: "background with one hex digit per component",
|
||||
input: "\x1b]11;rgb:f/0/f\a",
|
||||
expected: []string{"#ff00ff"},
|
||||
},
|
||||
{
|
||||
name: "background with alpha",
|
||||
input: "\x1b]11;rgba:0000/2b2b/3636/ffff\a",
|
||||
expected: []string{"#002b36"},
|
||||
},
|
||||
{
|
||||
name: "replies among other input",
|
||||
input: "j\x1b[?997;1n\x1b[A\x1b[<0;10;5M\x1b]11;rgb:0000/0000/0000\x1b\\\x1b[?62;22c",
|
||||
expected: []string{"dark", "#000000"},
|
||||
},
|
||||
{
|
||||
name: "other reports",
|
||||
input: "\x1b[?997;3n\x1b[0n\x1b[?996n\x1b]10;rgb:ffff/ffff/ffff\a\x1b]4;1;rgb:ffff/0000/0000\a",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "malformed backgrounds",
|
||||
input: "\x1b]11;rgb:zz/00/00\a\x1b]11;rgb:10/20\a\x1b]11;rgb:12345/0/0\a\x1b]11;rgb://\a\x1b]11;#ffffff\a",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "reply after a sequence that was cut short",
|
||||
input: "\x1b]11;rgb:\x1b[?997;2n\x1b[?99\x1b]11;rgb:00/00/00\a",
|
||||
expected: []string{"light", "#000000"},
|
||||
},
|
||||
{
|
||||
name: "reply after an oversized sequence",
|
||||
input: "\x1b]11;" + strings.Repeat("x", 100) + "\a\x1b[?" + strings.Repeat("9", 100) + "n\x1b[?997;1n",
|
||||
expected: []string{"dark"},
|
||||
},
|
||||
{
|
||||
name: "oversized sequence that starts like a reply",
|
||||
input: "\x1b]11;rgb:00/00/00" + strings.Repeat("x", 100) + "\a",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
scanner := terminalReplyScanner{}
|
||||
assert.Equal(t, s.expected, describeReplies(scanner.scan([]byte(s.input))))
|
||||
})
|
||||
|
||||
t.Run(s.name+", split in two", func(t *testing.T) {
|
||||
for i := range len(s.input) {
|
||||
scanner := terminalReplyScanner{}
|
||||
replies := scanner.scan([]byte(s.input[:i]))
|
||||
replies = append(replies, scanner.scan([]byte(s.input[i:]))...)
|
||||
assert.Equal(t, s.expected, describeReplies(replies), "split at %d", i)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(s.name+", byte by byte", func(t *testing.T) {
|
||||
scanner := terminalReplyScanner{}
|
||||
replies := []terminalReply{}
|
||||
for i := range len(s.input) {
|
||||
replies = append(replies, scanner.scan([]byte{s.input[i]})...)
|
||||
}
|
||||
assert.Equal(t, s.expected, describeReplies(replies))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorSchemeQueries(t *testing.T) {
|
||||
allQueries := enableColorSchemeReports + requestColorScheme + requestBackgroundColor
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
term string
|
||||
termProgram string
|
||||
tcellNegotiate string
|
||||
expected string
|
||||
}{
|
||||
{name: "xterm", term: "xterm-256color", expected: allQueries},
|
||||
{name: "tmux", term: "tmux-256color", termProgram: "tmux", expected: allQueries},
|
||||
{name: "Terminal.app", term: "xterm-256color", termProgram: "Apple_Terminal", expected: requestBackgroundColor},
|
||||
{name: "WezTerm", term: "xterm-256color", termProgram: "WezTerm", expected: requestBackgroundColor},
|
||||
{name: "st", term: "st-256color", expected: ""},
|
||||
{name: "Linux console", term: "linux", expected: ""},
|
||||
{name: "VT100", term: "vt100", expected: ""},
|
||||
{name: "negotiation disabled", term: "xterm-256color", tcellNegotiate: "disable", expected: ""},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, colorSchemeQueries(s.term, s.termProgram, s.tcellNegotiate))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTty struct {
|
||||
input string
|
||||
written strings.Builder
|
||||
}
|
||||
|
||||
var _ tcell.Tty = &fakeTty{}
|
||||
|
||||
func (self *fakeTty) Start() error { return nil }
|
||||
func (self *fakeTty) Stop() error { return nil }
|
||||
func (self *fakeTty) Drain() error { return nil }
|
||||
func (self *fakeTty) NotifyResize(chan<- bool) {}
|
||||
func (self *fakeTty) WindowSize() (tcell.WindowSize, error) { return tcell.WindowSize{}, nil }
|
||||
func (self *fakeTty) Close() error { return nil }
|
||||
func (self *fakeTty) Write(p []byte) (int, error) { return self.written.Write(p) }
|
||||
func (self *fakeTty) Read(p []byte) (int, error) {
|
||||
n := copy(p, self.input)
|
||||
self.input = self.input[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (self *fakeTty) takeWritten() string {
|
||||
written := self.written.String()
|
||||
self.written.Reset()
|
||||
return written
|
||||
}
|
||||
|
||||
type colorSchemeTtyTest struct {
|
||||
fake *fakeTty
|
||||
tty *colorSchemeTty
|
||||
notifications []string
|
||||
}
|
||||
|
||||
func newColorSchemeTtyTest(termProgram string) *colorSchemeTtyTest {
|
||||
test := &colorSchemeTtyTest{fake: &fakeTty{}}
|
||||
test.tty = &colorSchemeTty{
|
||||
Tty: test.fake,
|
||||
queries: colorSchemeQueries("xterm-256color", termProgram, ""),
|
||||
}
|
||||
test.tty.subscribe(func(colorScheme DetectedColorScheme) {
|
||||
test.notifications = append(test.notifications, colorScheme.String())
|
||||
})
|
||||
return test
|
||||
}
|
||||
|
||||
// feed has the terminal send input, and has tcell read it
|
||||
func (self *colorSchemeTtyTest) feed(input string) {
|
||||
self.fake.input = input
|
||||
_, _ = self.tty.Read(make([]byte, 128))
|
||||
}
|
||||
|
||||
func (self *colorSchemeTtyTest) takeNotifications() []string {
|
||||
notifications := self.notifications
|
||||
self.notifications = nil
|
||||
return notifications
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyPrefersTheBackground(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assert.Equal(t, enableColorSchemeReports+requestColorScheme+requestBackgroundColor, test.fake.takeWritten())
|
||||
|
||||
// The terminal says it's light, but its background is dark
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, []string{"light (reported by the terminal)"}, test.takeNotifications())
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\\x1b[?62;22c")
|
||||
assert.Equal(t, []string{"dark (background #1e1e1e)"}, test.takeNotifications())
|
||||
assert.Equal(t, "", test.fake.takeWritten())
|
||||
|
||||
// A later report only makes us ask for the background again
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Nil(t, test.takeNotifications())
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:ffff/ffff/ffff\x1b\\")
|
||||
assert.Equal(t, []string{"light (background #ffffff)"}, test.takeNotifications())
|
||||
|
||||
// The same background again is no change
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
test.feed("\x1b]11;rgb:ffff/ffff/ffff\x1b\\")
|
||||
assert.Nil(t, test.takeNotifications())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyWithoutBackground(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.fake.takeWritten()
|
||||
|
||||
test.feed("\x1b[?997;1n\x1b[?62;22c")
|
||||
assert.Equal(t, []string{"dark (reported by the terminal)"}, test.takeNotifications())
|
||||
|
||||
test.feed("\x1b[?997;2n")
|
||||
assert.Equal(t, []string{"light (reported by the terminal)"}, test.takeNotifications())
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "a terminal that didn't answer before isn't asked again")
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyAsksAgainOnFocus(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("Apple_Terminal")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:0000/0000/0000\a")
|
||||
assert.Equal(t, []string{"dark (background #000000)"}, test.takeNotifications())
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, requestBackgroundColor, test.fake.takeWritten())
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "the background was asked for already")
|
||||
|
||||
test.feed("\x1b]11;rgb:ffff/ffff/ffff\a")
|
||||
assert.Equal(t, []string{"light (background #ffffff)"}, test.takeNotifications())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyStopAndStart(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.fake.takeWritten()
|
||||
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
|
||||
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.Equal(t, disableColorSchemeReports, test.fake.takeWritten())
|
||||
|
||||
test.tty.onFocusGained()
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "nothing is written to a stopped tty")
|
||||
|
||||
// Anything may have changed while we were stopped, so ask again
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assert.Equal(t, enableColorSchemeReports+requestColorScheme+requestBackgroundColor, test.fake.takeWritten())
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyStopWithoutReports(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("WezTerm")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.fake.takeWritten()
|
||||
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.Equal(t, "", test.fake.takeWritten(), "reports were never turned on")
|
||||
}
|
||||
|
||||
func TestColorSchemeOfBackground(t *testing.T) {
|
||||
for background, expected := range map[string]ColorScheme{
|
||||
"rgb:0000/0000/0000": ColorSchemeDark,
|
||||
"rgb:1e/1e/1e": ColorSchemeDark,
|
||||
"rgb:00/2b/36": ColorSchemeDark,
|
||||
"rgb:70/70/70": ColorSchemeDark,
|
||||
"rgb:80/80/80": ColorSchemeLight,
|
||||
"rgb:fd/f6/e3": ColorSchemeLight,
|
||||
"rgb:ff/ff/ff": ColorSchemeLight,
|
||||
} {
|
||||
color, ok := parseBackgroundColorReport("11;" + background)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, expected, colorSchemeOfBackground(color), background)
|
||||
}
|
||||
}
|
||||
|
||||
// startWaiting starts waiting for replies, with a timeout too long to matter
|
||||
func (self *colorSchemeTtyTest) startWaiting() <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
self.tty.waitForReplies(time.Minute)
|
||||
close(done)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func isClosed(ch <-chan struct{}) bool {
|
||||
select {
|
||||
case <-ch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func assertDoneWaiting(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
assert.Eventually(t, func() bool { return isClosed(done) }, time.Second, time.Millisecond)
|
||||
}
|
||||
|
||||
func assertStillWaiting(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
assert.Never(t, func() bool { return isClosed(done) }, 50*time.Millisecond, time.Millisecond)
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyWaitsForReplies(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
|
||||
assertDoneWaiting(t, test.startWaiting())
|
||||
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.NoError(t, test.tty.Start())
|
||||
|
||||
done := test.startWaiting()
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b[?997;1n")
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b]11;rgb:0000/0000/0000\a")
|
||||
assertDoneWaiting(t, done)
|
||||
|
||||
test.tty.onFocusGained()
|
||||
done = test.startWaiting()
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b]11;rgb:0000/0000/0000\a")
|
||||
assertDoneWaiting(t, done)
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyGivesUpWaiting(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
assert.NoError(t, test.tty.Start())
|
||||
test.feed("\x1b[?997;1n\x1b]11;rgb:0000/0000/0000\a")
|
||||
test.tty.onFocusGained()
|
||||
|
||||
start := time.Now()
|
||||
test.tty.waitForReplies(20 * time.Millisecond)
|
||||
assert.GreaterOrEqual(t, time.Since(start), 20*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestColorSchemeTtyDoesntWaitForRepliesThatNeverCame(t *testing.T) {
|
||||
test := newColorSchemeTtyTest("")
|
||||
|
||||
// The terminal answers nothing
|
||||
assert.NoError(t, test.tty.Start())
|
||||
assertDoneWaiting(t, test.startWaiting())
|
||||
|
||||
// The terminal reports its color scheme, but not its background
|
||||
test.feed("\x1b[?997;1n\x1b[?62;22c")
|
||||
assert.NoError(t, test.tty.Stop())
|
||||
assert.NoError(t, test.tty.Start())
|
||||
|
||||
done := test.startWaiting()
|
||||
assertStillWaiting(t, done)
|
||||
test.feed("\x1b[?997;1n")
|
||||
assertDoneWaiting(t, done)
|
||||
}
|
||||
@@ -227,6 +227,12 @@ type Gui struct {
|
||||
// readable from anywhere, so it's atomic.
|
||||
focused atomic.Bool
|
||||
|
||||
// colorSchemeTty is nil when running headless. colorScheme and
|
||||
// colorSchemeHandler are only touched on the UI thread.
|
||||
colorSchemeTty *colorSchemeTty
|
||||
colorScheme DetectedColorScheme
|
||||
colorSchemeHandler func(DetectedColorScheme) error
|
||||
|
||||
// blockInputCount, when greater than zero, withholds keyboard input from
|
||||
// the handlers: key events are buffered into bufferedKeyEvents and replayed
|
||||
// once the count drops back to zero, while mouse clicks and hover are
|
||||
@@ -317,6 +323,18 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
|
||||
// never happened.
|
||||
g.focused.Store(true)
|
||||
|
||||
if g.colorSchemeTty != nil {
|
||||
g.colorScheme = g.colorSchemeTty.subscribe(func(colorScheme DetectedColorScheme) {
|
||||
g.UpdateBackground(func(g *Gui) error {
|
||||
g.colorScheme = colorScheme
|
||||
if g.colorSchemeHandler != nil {
|
||||
return g.colorSchemeHandler(colorScheme)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
@@ -374,6 +392,7 @@ func (g *Gui) WaitUntilIdle() {
|
||||
// initialization and when gocui is not needed anymore.
|
||||
func (g *Gui) Close() {
|
||||
close(g.stop)
|
||||
g.waitForColorSchemeReplies()
|
||||
Screen.Fini()
|
||||
}
|
||||
|
||||
@@ -708,6 +727,29 @@ func (g *Gui) SetFocusHandler(handler func(bool) error) {
|
||||
g.focusHandler = handler
|
||||
}
|
||||
|
||||
// DetectedColorScheme returns what the terminal has told us about its colors.
|
||||
// It is known before the first layout, for the terminals that tell us at all.
|
||||
func (g *Gui) DetectedColorScheme() DetectedColorScheme {
|
||||
return g.colorScheme
|
||||
}
|
||||
|
||||
// SetColorSchemeChangeHandler sets a function to call on the UI thread whenever
|
||||
// the terminal's colors change after startup.
|
||||
func (g *Gui) SetColorSchemeChangeHandler(handler func(DetectedColorScheme) error) {
|
||||
g.colorSchemeHandler = handler
|
||||
}
|
||||
|
||||
// Long enough for the round trip of a slow ssh connection
|
||||
const colorSchemeReplyTimeout = 500 * time.Millisecond
|
||||
|
||||
// waitForColorSchemeReplies is for before we give up the terminal. tcell is
|
||||
// still reading the input at that point, so the answers are consumed as usual.
|
||||
func (g *Gui) waitForColorSchemeReplies() {
|
||||
if g.colorSchemeTty != nil {
|
||||
g.colorSchemeTty.waitForReplies(colorSchemeReplyTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gui) SetOpenHyperlinkFunc(openHyperlinkFunc func(string, string) error) {
|
||||
g.openHyperlink = openHyperlinkFunc
|
||||
}
|
||||
@@ -2043,6 +2085,10 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
}
|
||||
g.focused.Store(ev.Focused)
|
||||
|
||||
if ev.Focused && g.colorSchemeTty != nil {
|
||||
g.colorSchemeTty.onFocusGained()
|
||||
}
|
||||
|
||||
if g.focusHandler != nil {
|
||||
return g.focusHandler(ev.Focused)
|
||||
}
|
||||
@@ -2059,6 +2105,8 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
// after re-engaging.
|
||||
|
||||
func (g *Gui) Suspend() error {
|
||||
g.waitForColorSchemeReplies()
|
||||
|
||||
g.suspendedMutex.Lock()
|
||||
defer g.suspendedMutex.Unlock()
|
||||
|
||||
|
||||
@@ -56,7 +56,13 @@ var runeReplacements = map[rune]string{
|
||||
func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
|
||||
tcell.SetEncodingFallback(tcell.EncodingFallbackASCII)
|
||||
|
||||
s, e := tcell.NewScreen()
|
||||
tty, e := tcell.NewDevTty()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
colorSchemeTty := newColorSchemeTty(tty)
|
||||
|
||||
s, e := tcell.NewTerminfoScreenFromTty(colorSchemeTty)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -68,6 +74,7 @@ func (g *Gui) tcellInit(runeReplacements map[rune]string) error {
|
||||
registerRuneFallbacks(s, runeReplacements)
|
||||
|
||||
g.screen = s
|
||||
g.colorSchemeTty = colorSchemeTty
|
||||
Screen = s
|
||||
return nil
|
||||
}
|
||||
|
||||
+17
-7
@@ -138,6 +138,12 @@ type View struct {
|
||||
// focus.
|
||||
InactiveViewSelBgColor Attribute
|
||||
|
||||
// SelTextColor is applied to the text of the selected line when it is
|
||||
// highlighted, whether the view has the focus or not. Its attributes are
|
||||
// added to those of the text, and if it has a color, that replaces the
|
||||
// color of the text.
|
||||
SelTextColor Attribute
|
||||
|
||||
// If Editable is true, keystrokes will be added to the view's internal
|
||||
// buffer at the cursor position.
|
||||
Editable bool
|
||||
@@ -631,7 +637,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
|
||||
|
||||
v.FgColor, v.BgColor = ColorDefault, ColorDefault
|
||||
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
|
||||
v.InactiveViewSelBgColor = ColorDefault
|
||||
v.InactiveViewSelBgColor, v.SelTextColor = ColorDefault, ColorDefault
|
||||
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
|
||||
v.buf.ei.screenColMax = v.InnerWidth()
|
||||
return v
|
||||
@@ -722,12 +728,7 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
|
||||
}
|
||||
|
||||
if y >= rangeSelectStart && y <= rangeSelectEnd {
|
||||
// this ensures we use the bright variant of a colour upon highlight
|
||||
fgColorComponent := fgColor & ^AttrAll
|
||||
if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 {
|
||||
fgColor += 8
|
||||
}
|
||||
fgColor = fgColor | AttrBold
|
||||
fgColor = applySelTextColor(fgColor, v.SelTextColor)
|
||||
if v.HighlightInactive || !isWindowFocused {
|
||||
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
|
||||
} else {
|
||||
@@ -1843,6 +1844,15 @@ func indexFunc(r rune) bool {
|
||||
return r == ' ' || r == 0
|
||||
}
|
||||
|
||||
// applySelTextColor adds the attributes of selTextColor to fgColor, and
|
||||
// replaces the color of fgColor with that of selTextColor if it has one.
|
||||
func applySelTextColor(fgColor, selTextColor Attribute) Attribute {
|
||||
if selTextColor&AttrColorBits != ColorDefault {
|
||||
fgColor = fgColor&AttrStyleBits | selTextColor&AttrColorBits
|
||||
}
|
||||
return fgColor | selTextColor&AttrStyleBits
|
||||
}
|
||||
|
||||
// SetHighlight toggles highlighting of separate lines, for custom lists
|
||||
// or multiple selection in views.
|
||||
func (v *View) SetHighlight(y int, on bool) {
|
||||
|
||||
@@ -780,3 +780,43 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
|
||||
"trailing cell at (%d, 2) should have green bg", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySelTextColor(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
fgColor Attribute
|
||||
selTextColor Attribute
|
||||
expected Attribute
|
||||
}{
|
||||
{
|
||||
name: "adds attributes",
|
||||
fgColor: ColorRed | AttrUnderline,
|
||||
selTextColor: AttrBold,
|
||||
expected: ColorRed | AttrUnderline | AttrBold,
|
||||
},
|
||||
{
|
||||
name: "replaces the color",
|
||||
fgColor: ColorRed | AttrUnderline,
|
||||
selTextColor: ColorWhite | AttrBold,
|
||||
expected: ColorWhite | AttrUnderline | AttrBold,
|
||||
},
|
||||
{
|
||||
name: "replaces an RGB color",
|
||||
fgColor: NewRGBColor(0x12, 0x34, 0x56),
|
||||
selTextColor: ColorBlue,
|
||||
expected: ColorBlue,
|
||||
},
|
||||
{
|
||||
name: "leaves the text alone if default",
|
||||
fgColor: ColorRed | AttrUnderline,
|
||||
selTextColor: ColorDefault,
|
||||
expected: ColorRed | AttrUnderline,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, applySelTextColor(s.fgColor, s.selTextColor))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
@@ -144,8 +143,8 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Branches.FastForward),
|
||||
Handler: self.withItem(self.fastForward),
|
||||
GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)),
|
||||
Handler: self.withItems(self.fastForward),
|
||||
GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)),
|
||||
Description: self.c.Tr.FastForward,
|
||||
Tooltip: self.c.Tr.FastForwardTooltip,
|
||||
},
|
||||
@@ -206,13 +205,13 @@ func (self *BranchesController) GetOnRenderToMain() func() {
|
||||
} else {
|
||||
cmdObj := self.c.Git().Branch.GetGraphCmdObj(branch.FullRefName())
|
||||
|
||||
ptyTask := types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
task = ptyTask
|
||||
rendererTask := types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
task = rendererTask
|
||||
|
||||
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
|
||||
if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) {
|
||||
ptyTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr)
|
||||
ptyTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
|
||||
rendererTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr)
|
||||
rendererTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,54 +654,23 @@ func (self *BranchesController) rebase(branch *models.Branch) error {
|
||||
return self.c.Helpers().MergeAndRebase.RebaseOntoRef(branch.Name)
|
||||
}
|
||||
|
||||
func (self *BranchesController) fastForward(branch *models.Branch) error {
|
||||
if !branch.IsTrackingRemote() {
|
||||
func (self *BranchesController) fastForward(branches []*models.Branch) error {
|
||||
if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.IsTrackingRemote() }) {
|
||||
return errors.New(self.c.Tr.FwdNoUpstream)
|
||||
}
|
||||
if !branch.RemoteBranchStoredLocally() {
|
||||
if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.RemoteBranchStoredLocally() }) {
|
||||
return errors.New(self.c.Tr.FwdNoLocalUpstream)
|
||||
}
|
||||
if branch.IsAheadForPull() {
|
||||
// A branch that is only ahead has nothing to fast-forward to. One that is
|
||||
// both ahead and behind may still be reset to its upstream, so let the
|
||||
// helper look into it.
|
||||
if lo.SomeBy(branches, func(branch *models.Branch) bool {
|
||||
return branch.IsAheadForPull() && !branch.IsBehindForPull()
|
||||
}) {
|
||||
return errors.New(self.c.Tr.FwdCommitsToPush)
|
||||
}
|
||||
|
||||
action := self.c.Tr.Actions.FastForwardBranch
|
||||
worktree, ok := self.worktreeForBranch(branch)
|
||||
|
||||
return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
if ok {
|
||||
self.c.LogAction(action)
|
||||
|
||||
worktreeGitDir := ""
|
||||
worktreePath := ""
|
||||
// if it is the current worktree path, no need to specify the path
|
||||
if !worktree.IsCurrent {
|
||||
worktreeGitDir = worktree.GitDir
|
||||
worktreePath = worktree.Path
|
||||
}
|
||||
|
||||
err := self.c.Git().Sync.Pull(
|
||||
task,
|
||||
git_commands.PullOptions{
|
||||
RemoteName: branch.UpstreamRemote,
|
||||
BranchName: branch.UpstreamBranch,
|
||||
FastForwardOnly: true,
|
||||
WorktreeGitDir: worktreeGitDir,
|
||||
WorktreePath: worktreePath,
|
||||
},
|
||||
)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.LogAction(action)
|
||||
|
||||
err := self.c.Git().Sync.FastForward(
|
||||
task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch,
|
||||
)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
return err
|
||||
})
|
||||
return self.c.Helpers().BranchesHelper.FastForwardBranches(branches)
|
||||
}
|
||||
|
||||
func (self *BranchesController) createTag(branch *models.Branch) error {
|
||||
|
||||
@@ -177,7 +177,7 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
|
||||
|
||||
paths := self.pathsForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
|
||||
task := types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
task := types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
|
||||
@@ -356,7 +356,7 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
|
||||
prefix += self.c.Tr.MergeConflictCurrentDiff
|
||||
}
|
||||
prefix += "\n\n"
|
||||
self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix))
|
||||
self.renderToMainWithTask(types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
refreshOpts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: title,
|
||||
},
|
||||
@@ -395,7 +395,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
||||
Title: title,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package controllers
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
@@ -185,13 +184,7 @@ func (self *GlobalController) cycleDiffRenderersBackward() error {
|
||||
// onDiffRenderersChanged re-renders the main view so the newly selected diff renderer
|
||||
// takes effect, and shows a toast naming it.
|
||||
func (self *GlobalController) onDiffRenderersChanged() {
|
||||
currentSide := self.c.Context().CurrentSide()
|
||||
currentKey := self.c.Context().Current().GetKey()
|
||||
if currentSide.GetKey() == currentKey ||
|
||||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
|
||||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
|
||||
currentSide.HandleRenderToMain()
|
||||
}
|
||||
self.c.Helpers().Diff.RenderToMainAgain()
|
||||
|
||||
diffRendererConfigManager := self.c.State().GetDiffRendererConfigManager()
|
||||
current, total := diffRendererConfigManager.CurrentDiffRendererIndex()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Returns the branches stacked below the given branch: those whose tip is one
|
||||
// of the given commits of the branch that hasn't been merged into a main
|
||||
// branch yet. The branch itself and the main branches are left out. The
|
||||
// result is in the order in which the tips appear in commits, so the branch
|
||||
// closest to the given one comes first.
|
||||
//
|
||||
// Only the commits passed in are looked at, so a branch whose tip is further
|
||||
// down the history than the loaded commits is not found.
|
||||
func BranchesBelowInStack(
|
||||
commits []*models.Commit,
|
||||
branches []*models.Branch,
|
||||
branch *models.Branch,
|
||||
mainBranches []string,
|
||||
) []*models.Branch {
|
||||
branchesByTip := map[string][]*models.Branch{}
|
||||
for _, other := range branches {
|
||||
if other.Name == branch.Name || lo.Contains(mainBranches, other.Name) {
|
||||
continue
|
||||
}
|
||||
branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other)
|
||||
}
|
||||
|
||||
result := []*models.Branch{}
|
||||
for _, commit := range commits {
|
||||
if commit.IsTODO() || commit.Status == models.StatusMerged {
|
||||
continue
|
||||
}
|
||||
result = append(result, branchesByTip[commit.Hash()]...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stefanhaller/git-todo-parser/todo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBranchesBelowInStack(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
commit := func(hash string, status models.CommitStatus) *models.Commit {
|
||||
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Status: status})
|
||||
}
|
||||
todoCommit := func(hash string) *models.Commit {
|
||||
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Action: todo.Pick, Status: models.StatusRebasing})
|
||||
}
|
||||
branch := func(name string, tip string) *models.Branch {
|
||||
return &models.Branch{Name: name, CommitHash: tip}
|
||||
}
|
||||
|
||||
current := branch("current", "c3")
|
||||
mainBranches := []string{"master", "main"}
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
commits []*models.Commit
|
||||
branches []*models.Branch
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
testName: "stack of branches, closest to the current one first",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("c2", models.StatusPushed),
|
||||
commit("c1", models.StatusUnpushed),
|
||||
commit("m1", models.StatusMerged),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("first", "c1"), branch("second", "c2"), branch("master", "m1")},
|
||||
expected: []string{"second", "first"},
|
||||
},
|
||||
{
|
||||
testName: "branch pointing at the same commit as the current one",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("m1", models.StatusMerged),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("twin", "c3")},
|
||||
expected: []string{"twin"},
|
||||
},
|
||||
{
|
||||
testName: "several branches pointing at one commit keep their order",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("c1", models.StatusUnpushed),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("one", "c1"), branch("two", "c1")},
|
||||
expected: []string{"one", "two"},
|
||||
},
|
||||
{
|
||||
testName: "main branches are left out even if their tip isn't merged",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("c1", models.StatusUnpushed),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("main", "c1")},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
testName: "branches whose tip is merged are left out",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
commit("m1", models.StatusMerged),
|
||||
commit("m0", models.StatusMerged),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("old", "m0"), branch("master", "m1")},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
testName: "branches whose tip is not among the commits are left out",
|
||||
commits: []*models.Commit{
|
||||
commit("c3", models.StatusUnpushed),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("sibling", "s1")},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
testName: "todo commits of a rebase don't count",
|
||||
commits: []*models.Commit{
|
||||
todoCommit("t1"),
|
||||
commit("c3", models.StatusUnpushed),
|
||||
},
|
||||
branches: []*models.Branch{current, branch("other", "t1")},
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
result := BranchesBelowInStack(s.commits, s.branches, current, mainBranches)
|
||||
names := lo.Map(result, func(b *models.Branch, _ int) string { return b.Name })
|
||||
assert.Equal(t, s.expected, names)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
@@ -420,52 +422,287 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe
|
||||
if self.c.State().GetRepoGeneration() != fetchGeneration {
|
||||
return nil
|
||||
}
|
||||
err := self.AutoForwardBranches(background)
|
||||
if background && err != nil {
|
||||
// The background poller discards this return value, so surface
|
||||
// the error in the log rather than as a popup for background work.
|
||||
self.c.Log.Error(err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
self.AutoForwardBranches(background)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return fetchErr
|
||||
}
|
||||
|
||||
func (self *BranchesHelper) AutoForwardBranches(background bool) error {
|
||||
if self.c.UserConfig().Git.AutoForwardBranches == "none" {
|
||||
// One of the branches that a fast-forward is about to bring to its upstream
|
||||
type branchToForward struct {
|
||||
branch *models.Branch
|
||||
// the worktree that the branch is checked out in, nil if there is none
|
||||
worktree *models.Worktree
|
||||
// whether the branch has to be reset to its upstream because it has
|
||||
// diverged from it, as opposed to being moved forward
|
||||
reset bool
|
||||
}
|
||||
|
||||
// Updates the given branches to their upstream branches, fetching those first.
|
||||
// A branch that is behind its upstream is moved forward to it; one that has
|
||||
// diverged from it is reset to it, as long as it has no commits of its own. If
|
||||
// any of the branches can't be updated, none of them is.
|
||||
func (self *BranchesHelper) FastForwardBranches(branches []*models.Branch) error {
|
||||
fastForward, err := self.PrepareFastForward(branches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return self.WithInlineStatusOnBranches(branches, types.ItemOperationFastForwarding, fastForward)
|
||||
}
|
||||
|
||||
// Does the part of FastForwardBranches that looks at the model, and so has to
|
||||
// run on the UI thread. Returns the rest, which is to be run on a worker; this
|
||||
// lets other operations include the fast-forward in their own task.
|
||||
func (self *BranchesHelper) PrepareFastForward(branches []*models.Branch) (func(gocui.Task) error, error) {
|
||||
toForward := lo.Map(branches, func(branch *models.Branch, _ int) *branchToForward {
|
||||
worktree, _ := self.worktreeForBranch(branch)
|
||||
return &branchToForward{branch: branch, worktree: worktree}
|
||||
})
|
||||
anyCheckedOut := lo.SomeBy(toForward, func(f *branchToForward) bool { return f.worktree != nil })
|
||||
|
||||
// Updating that worktree would move its detached HEAD, which belongs to the
|
||||
// rebase or bisect, and leave the branch alone
|
||||
for _, f := range toForward {
|
||||
if f.worktree != nil && f.worktree.IsRebasingOrBisecting {
|
||||
return nil, errors.New(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.FwdBranchRebasingOrBisecting,
|
||||
map[string]string{"branchName": f.branch.Name, "worktreeName": f.worktree.Name},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return func(task gocui.Task) error {
|
||||
defer func() {
|
||||
if anyCheckedOut {
|
||||
// The files of those worktrees have changed as well
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
} else {
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
}
|
||||
}()
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.FastForwardBranch)
|
||||
|
||||
if err := self.fetchUpstreamBranches(task, branches); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Look at all the branches before moving any of them, so that one we
|
||||
// have to refuse leaves the others alone too
|
||||
for _, f := range toForward {
|
||||
if err := self.planForwardingBranch(f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return self.forwardBranches(self.c.Git(), toForward)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Runs f on a worker with all the given branches shown as being in the given
|
||||
// operation while it runs
|
||||
func (self *BranchesHelper) WithInlineStatusOnBranches(branches []*models.Branch, operation types.ItemOperation, f func(gocui.Task) error) error {
|
||||
return WithInlineStatusOnItems(self.c, branches, operation, context.LOCAL_BRANCHES_CONTEXT_KEY, f)
|
||||
}
|
||||
|
||||
func (self *BranchesHelper) fetchUpstreamBranches(task gocui.Task, branches []*models.Branch) error {
|
||||
remotes := lo.Uniq(lo.Map(branches, func(branch *models.Branch, _ int) string {
|
||||
return branch.UpstreamRemote
|
||||
}))
|
||||
|
||||
for _, remote := range remotes {
|
||||
remoteBranches := []string{}
|
||||
for _, branch := range branches {
|
||||
if branch.UpstreamRemote == remote {
|
||||
remoteBranches = append(remoteBranches, branch.UpstreamBranch)
|
||||
}
|
||||
}
|
||||
|
||||
if err := self.c.Git().Sync.FetchRemoteBranches(task, remote, remoteBranches); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Works out whether the branch has to be reset to its upstream, and returns an
|
||||
// error if it can't be brought there at all.
|
||||
func (self *BranchesHelper) planForwardingBranch(f *branchToForward) error {
|
||||
f.reset = !self.c.Git().Branch.IsAncestor(
|
||||
f.branch.FullRefName(), f.branch.FullUpstreamRefName())
|
||||
if !f.reset {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Moving the branch to its upstream means giving up the commits it is
|
||||
// ahead by, so make sure that none of them is ours
|
||||
hasLocalOnlyCommits, err := self.c.Git().Branch.HasLocalOnlyCommits(f.branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasLocalOnlyCommits {
|
||||
return errors.New(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.FwdLocalOnlyCommits,
|
||||
map[string]string{"branchName": f.branch.Name},
|
||||
))
|
||||
}
|
||||
|
||||
if f.worktree != nil {
|
||||
// Resetting the branch changes the files of the worktree under the
|
||||
// user's feet, so only do it while they have no changes of their own
|
||||
// there
|
||||
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
|
||||
hasChanges, err := self.c.Git().WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasChanges {
|
||||
return errors.New(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.FwdUncommittedChanges,
|
||||
map[string]string{"branchName": f.branch.Name},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keeps going when a branch fails to update, so that it doesn't hold up the
|
||||
// others, and returns the errors of all the failed ones.
|
||||
func (self *BranchesHelper) forwardBranches(git *commands.GitCommand, toForward []*branchToForward) error {
|
||||
var errs []error
|
||||
|
||||
// The branches that aren't checked out anywhere are nothing but refs to
|
||||
// update, so they can all be done in one go
|
||||
updateCommands := ""
|
||||
for _, f := range toForward {
|
||||
if f.worktree == nil {
|
||||
updateCommands += fmt.Sprintf("update %s %s %s\n",
|
||||
f.branch.FullRefName(), f.branch.FullUpstreamRefName(), f.branch.CommitHash)
|
||||
}
|
||||
}
|
||||
|
||||
if updateCommands != "" {
|
||||
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
|
||||
if err := git.Branch.UpdateBranchRefs(updateCommands, "lazygit: update to upstream branch"); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A branch that is checked out somewhere needs the files of that worktree
|
||||
// to be updated along with it
|
||||
for _, f := range toForward {
|
||||
if f.worktree == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
|
||||
|
||||
var err error
|
||||
if f.reset {
|
||||
err = git.WorkingTree.ResetKeep(
|
||||
f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath)
|
||||
} else {
|
||||
err = git.Branch.FastForwardMerge(
|
||||
f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath)
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Returns the git dir and the path to pass for the given worktree; both are
|
||||
// empty for the current one, which git commands use by default anyway.
|
||||
func (self *BranchesHelper) worktreeArgs(worktree *models.Worktree) (string, string) {
|
||||
if worktree.IsCurrent {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return worktree.GitDir, worktree.Path
|
||||
}
|
||||
|
||||
// Reads the branches from the model, so it must be called on the UI thread; the
|
||||
// git work happens on a worker.
|
||||
func (self *BranchesHelper) AutoForwardBranches(background bool) {
|
||||
if self.c.UserConfig().Git.AutoForwardBranches == "none" {
|
||||
return
|
||||
}
|
||||
|
||||
branches := self.c.Model().Branches
|
||||
if len(branches) == 0 {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
allBranches := self.c.UserConfig().Git.AutoForwardBranches == "allBranches"
|
||||
updateCommands := ""
|
||||
toForward := []*branchToForward{}
|
||||
// The first branch is the currently checked out branch; skip it
|
||||
for _, branch := range branches[1:] {
|
||||
if branch.RemoteBranchStoredLocally() &&
|
||||
!self.checkedOutByOtherWorktree(branch) &&
|
||||
(allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) {
|
||||
isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull()
|
||||
if isStrictlyBehind {
|
||||
updateCommands += fmt.Sprintf("update %s %s %s\n", branch.FullRefName(), branch.FullUpstreamRefName(), branch.CommitHash)
|
||||
}
|
||||
if !branch.RemoteBranchStoredLocally() ||
|
||||
!(allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull()
|
||||
if !isStrictlyBehind {
|
||||
continue
|
||||
}
|
||||
|
||||
// Changing the files of the current worktree without being asked to
|
||||
// would be surprising. A worktree that is mid-rebase or mid-bisect has
|
||||
// its HEAD detached from the branch, so the branch can't be moved
|
||||
// there, and neither can it in a worktree whose directory is missing.
|
||||
worktree, _ := self.worktreeForBranch(branch)
|
||||
if worktree != nil && (worktree.IsCurrent || worktree.IsRebasingOrBisecting || worktree.IsPathMissing) {
|
||||
continue
|
||||
}
|
||||
|
||||
toForward = append(toForward, &branchToForward{branch: branch, worktree: worktree})
|
||||
}
|
||||
|
||||
if updateCommands == "" {
|
||||
return nil
|
||||
if len(toForward) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches)
|
||||
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
|
||||
err := self.c.Git().Branch.UpdateBranchRefs(updateCommands)
|
||||
// A background worker doesn't block switching repos, so it has to stick
|
||||
// to the git commands of the repo that the branches came from
|
||||
git := self.c.Git()
|
||||
onWorker := lo.Ternary(background, self.c.OnWorkerBackground, self.c.OnWorker)
|
||||
onWorker(func(gocui.Task) error {
|
||||
// Only change the files of another worktree while the user has no
|
||||
// changes of their own there
|
||||
toForward := lo.Filter(toForward, func(f *branchToForward, _ int) bool {
|
||||
if f.worktree == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
|
||||
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
|
||||
hasChanges, err := git.WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath)
|
||||
if err != nil {
|
||||
self.c.Log.Errorf("Failed to check worktree %s for changes: %v", f.worktree.Name, err)
|
||||
}
|
||||
return err == nil && !hasChanges
|
||||
})
|
||||
if len(toForward) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches)
|
||||
err := self.forwardBranches(git, toForward)
|
||||
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
|
||||
|
||||
if background && err != nil {
|
||||
// Surface the error in the log rather than as a popup for background
|
||||
// work
|
||||
self.c.Log.Error(err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,11 +74,11 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm
|
||||
}
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
|
||||
prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName())
|
||||
return types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
return types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
}
|
||||
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit))
|
||||
return types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
return types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
}
|
||||
|
||||
func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
|
||||
@@ -98,6 +98,19 @@ func (self *DiffHelper) ExitDiffMode() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderToMainAgain renders the current side panel into the main view again, if
|
||||
// that is what the main view shows. This is for when something that the
|
||||
// rendering depends on has changed, such as the diff renderer.
|
||||
func (self *DiffHelper) RenderToMainAgain() {
|
||||
currentSide := self.c.Context().CurrentSide()
|
||||
currentKey := self.c.Context().Current().GetKey()
|
||||
if currentSide.GetKey() == currentKey ||
|
||||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
|
||||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
|
||||
currentSide.HandleRenderToMain()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *DiffHelper) RenderDiff() {
|
||||
args := self.DiffArgs()
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
|
||||
@@ -106,7 +119,7 @@ func (self *DiffHelper) RenderDiff() {
|
||||
self.c.Tr.ShowingGitDiff,
|
||||
"git diff "+strings.Join(args, " "),
|
||||
)
|
||||
task := types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
task := types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
|
||||
@@ -94,6 +94,23 @@ func (self *InlineStatusHelper) WithInlineStatus(opts InlineStatusOpts, f func(g
|
||||
}
|
||||
}
|
||||
|
||||
// Runs f on a worker with all the given items shown as being in the given
|
||||
// operation while it runs
|
||||
func WithInlineStatusOnItems[T types.HasUrn](c *HelperCommon, items []T, operation types.ItemOperation, contextKey types.ContextKey, f func(gocui.Task) error) error {
|
||||
return c.WithInlineStatus(items[0], operation, contextKey, func(task gocui.Task) error {
|
||||
for _, item := range items[1:] {
|
||||
c.State().SetItemOperation(item, operation)
|
||||
}
|
||||
defer func() {
|
||||
for _, item := range items[1:] {
|
||||
c.State().ClearItemOperation(item)
|
||||
}
|
||||
}()
|
||||
|
||||
return f(task)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *InlineStatusHelper) start(opts InlineStatusOpts) {
|
||||
self.c.State().SetItemOperation(opts.Item, opts.Operation)
|
||||
|
||||
|
||||
@@ -525,7 +525,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e
|
||||
}
|
||||
|
||||
wantFastForward, wantNonFastForward := self.fastForwardMergeUserPreference()
|
||||
canFastForward := self.c.Git().Branch.CanDoFastForwardMerge(refName)
|
||||
canFastForward := self.c.Git().Branch.IsAncestor("HEAD", refName)
|
||||
|
||||
var firstRegularMergeItem *types.MenuItem
|
||||
var secondRegularMergeItem *types.MenuItem
|
||||
|
||||
@@ -1150,14 +1150,14 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode
|
||||
|
||||
// self.refreshStatus is called at the end of this because that's when we can
|
||||
// be sure there is a State.Model.Branches array to pick the current branch from
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadExtraInfo bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
loadSeq := self.branchLoadSeq.Add(1)
|
||||
|
||||
branches, err := env.git.Loaders.BranchLoader.Load(
|
||||
reflogCommits,
|
||||
captured.mainBranches,
|
||||
captured.oldBranches,
|
||||
loadBehindCounts,
|
||||
loadExtraInfo,
|
||||
func(f func() error) {
|
||||
self.onWorker(env.background, func(_ gocui.Task) error {
|
||||
err := f()
|
||||
|
||||
@@ -47,7 +47,7 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() {
|
||||
} else {
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit))
|
||||
|
||||
task = types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
task = types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
}
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
|
||||
@@ -93,7 +93,7 @@ func (self *StashController) GetOnRenderToMain() func() {
|
||||
task = types.NewRenderStringTask(self.c.Tr.NoStashEntries)
|
||||
} else {
|
||||
prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description())
|
||||
task = types.NewRunPtyTaskWithPrefix(
|
||||
task = types.NewRunDiffRendererTaskWithPrefix(
|
||||
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd(),
|
||||
prefix,
|
||||
)
|
||||
|
||||
@@ -146,7 +146,7 @@ func (self *StatusController) editConfig() error {
|
||||
|
||||
func (self *StatusController) showAllBranchLogs() {
|
||||
cmdObj := self.c.Git().Branch.AllBranchesLogCmdObj()
|
||||
task := types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
task := types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
|
||||
title := self.c.Tr.LogTitle
|
||||
if i, n := self.c.Git().Branch.GetAllBranchesLogIdxAndCount(); n > 1 {
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"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 SyncController struct {
|
||||
@@ -87,18 +89,140 @@ func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func(
|
||||
}
|
||||
|
||||
func (self *SyncController) push(currentBranch *models.Branch) error {
|
||||
// if we are behind our upstream branch we'll ask if the user wants to force push
|
||||
if currentBranch.IsTrackingRemote() {
|
||||
opts := pushOpts{remoteBranchStoredLocally: currentBranch.RemoteBranchStoredLocally()}
|
||||
branchesBelow := self.unpushedBranchesBelow(currentBranch)
|
||||
if len(branchesBelow) == 0 {
|
||||
return self.pushCurrentBranch(currentBranch)
|
||||
}
|
||||
|
||||
branchName := map[string]string{"branchName": currentBranch.Name}
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.Push,
|
||||
Prompt: self.branchesBelowPrompt(currentBranch, branchesBelow),
|
||||
Items: []*types.MenuItem{
|
||||
{
|
||||
Label: utils.ResolvePlaceholderString(self.c.Tr.PushBranchAndBranchesBelow, branchName),
|
||||
OnPress: func() error {
|
||||
return self.pushWithBranchesBelow(currentBranch, branchesBelow)
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: utils.ResolvePlaceholderString(self.c.Tr.PushOnlyCurrentBranch, branchName),
|
||||
OnPress: func() error {
|
||||
return self.pushCurrentBranch(currentBranch)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *SyncController) pushCurrentBranch(currentBranch *models.Branch) error {
|
||||
return self.resolvePushOfCurrentBranch(currentBranch, func(opts pushOpts) error {
|
||||
// if we are behind our upstream branch we'll ask if the user wants to force push
|
||||
if currentBranch.IsBehindForPush() {
|
||||
return self.requestToForcePush(currentBranch, opts)
|
||||
}
|
||||
|
||||
return self.pushAux(currentBranch, opts)
|
||||
})
|
||||
}
|
||||
|
||||
// The branches stacked below the current one that have commits to push
|
||||
func (self *SyncController) unpushedBranchesBelow(currentBranch *models.Branch) []*models.Branch {
|
||||
branchesBelow := helpers.BranchesBelowInStack(
|
||||
self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches)
|
||||
|
||||
return lo.Filter(branchesBelow, func(branch *models.Branch, _ int) bool {
|
||||
// Pushing a branch whose remote branch was deleted would recreate it.
|
||||
// A branch that is only behind its remote branch has nothing to push,
|
||||
// and force-pushing it would move the remote branch back to an older
|
||||
// commit.
|
||||
return branch.PushRemote != "" && !branch.UpstreamGone && branch.IsAheadForPush()
|
||||
})
|
||||
}
|
||||
|
||||
func (self *SyncController) branchesBelowPrompt(currentBranch *models.Branch, branchesBelow []*models.Branch) string {
|
||||
intro := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.BranchesBelowHaveCommitsToPush,
|
||||
map[string]string{"branchName": currentBranch.Name},
|
||||
)
|
||||
lines := lo.Map(branchesBelow, func(branch *models.Branch, _ int) string {
|
||||
divergence := "↑" + branch.AheadForPush
|
||||
if branch.IsBehindForPush() {
|
||||
divergence = "↓" + branch.BehindForPush + divergence
|
||||
}
|
||||
return fmt.Sprintf("%s %s", branch.Name, style.FgYellow.Sprint(divergence))
|
||||
})
|
||||
|
||||
return intro + "\n\n " + strings.Join(lines, "\n ")
|
||||
}
|
||||
|
||||
// Pushes the current branch and the branches stacked below it, after asking
|
||||
// for confirmation if any of them has to be force-pushed
|
||||
func (self *SyncController) pushWithBranchesBelow(currentBranch *models.Branch, branchesBelow []*models.Branch) error {
|
||||
if currentBranch.RemoteBranchStoredLocally() && currentBranch.PushRemote != "" {
|
||||
// We know where the current branch goes and whether it needs to be
|
||||
// forced, so it is pushed like the branches below it, in the same
|
||||
// command as those that go to the same remote
|
||||
branches := append([]*models.Branch{currentBranch}, branchesBelow...)
|
||||
return self.confirmForcePushIfNeeded(branches, func(forceWithLease bool) error {
|
||||
return self.pushBranchesAux(currentBranch, branchesBelow, forceWithLease)
|
||||
})
|
||||
}
|
||||
|
||||
// The current branch has no upstream yet, or its remote branch isn't
|
||||
// stored locally. Push it the way it is pushed on its own, and the branches
|
||||
// below it after it.
|
||||
return self.resolvePushOfCurrentBranch(currentBranch, func(opts pushOpts) error {
|
||||
return self.confirmForcePushIfNeeded(branchesBelow, func(forceWithLease bool) error {
|
||||
opts.branchesBelow = branchesBelow
|
||||
opts.forceWithLeaseBelow = forceWithLease
|
||||
return self.pushAux(currentBranch, opts)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Calls push right away if none of the branches has diverged from its remote
|
||||
// branch, and after the user confirmed force-pushing otherwise
|
||||
func (self *SyncController) confirmForcePushIfNeeded(branches []*models.Branch, push func(forceWithLease bool) error) error {
|
||||
diverged := lo.Filter(branches, func(branch *models.Branch, _ int) bool {
|
||||
return branch.IsBehindForPush()
|
||||
})
|
||||
if len(diverged) == 0 {
|
||||
return push(false)
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DisableForcePushing {
|
||||
return errors.New(self.c.Tr.ForcePushBranchesDisabled)
|
||||
}
|
||||
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.ForcePush,
|
||||
Prompt: utils.ResolvePlaceholderString(
|
||||
self.c.Tr.ForcePushBranchesPrompt,
|
||||
map[string]string{
|
||||
"branches": " " + strings.Join(lo.Map(diverged, func(branch *models.Branch, _ int) string { return branch.Name }), "\n "),
|
||||
"cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(),
|
||||
"confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(),
|
||||
},
|
||||
),
|
||||
HandleConfirm: func() error {
|
||||
return push(true)
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Works out where the current branch is pushed to: to its upstream, to a
|
||||
// branch of the same name if push.default is "current", or to an upstream the
|
||||
// user enters in a prompt. Calls onResolved with the options for that push.
|
||||
func (self *SyncController) resolvePushOfCurrentBranch(currentBranch *models.Branch, onResolved func(pushOpts) error) error {
|
||||
if currentBranch.IsTrackingRemote() {
|
||||
return onResolved(pushOpts{remoteBranchStoredLocally: currentBranch.RemoteBranchStoredLocally()})
|
||||
}
|
||||
|
||||
if self.c.Git().Config.GetPushToCurrent() {
|
||||
return self.pushAux(currentBranch, pushOpts{setUpstream: true})
|
||||
return onResolved(pushOpts{setUpstream: true})
|
||||
}
|
||||
|
||||
return self.c.Helpers().Upstream.PromptForUpstreamWithInitialContent(currentBranch, func(upstream string) error {
|
||||
@@ -107,7 +231,7 @@ func (self *SyncController) push(currentBranch *models.Branch) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return self.pushAux(currentBranch, pushOpts{
|
||||
return onResolved(pushOpts{
|
||||
setUpstream: true,
|
||||
upstreamRemote: upstreamRemote,
|
||||
upstreamBranch: upstreamBranch,
|
||||
@@ -116,7 +240,36 @@ func (self *SyncController) push(currentBranch *models.Branch) error {
|
||||
}
|
||||
|
||||
func (self *SyncController) pull(currentBranch *models.Branch) error {
|
||||
action := self.c.Tr.Actions.Pull
|
||||
branchesBelow := self.updatableBranchesBelow(currentBranch)
|
||||
if len(branchesBelow) == 0 {
|
||||
return self.pullCurrentBranch(currentBranch, nil)
|
||||
}
|
||||
|
||||
branchName := map[string]string{"branchName": currentBranch.Name}
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.Pull,
|
||||
Prompt: self.branchesBelowToUpdatePrompt(currentBranch, branchesBelow),
|
||||
Items: []*types.MenuItem{
|
||||
{
|
||||
Label: utils.ResolvePlaceholderString(self.c.Tr.PullBranchAndBranchesBelow, branchName),
|
||||
OnPress: func() error {
|
||||
return self.pullCurrentBranch(currentBranch, branchesBelow)
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: utils.ResolvePlaceholderString(self.c.Tr.PullOnlyCurrentBranch, branchName),
|
||||
OnPress: func() error {
|
||||
return self.pullCurrentBranch(currentBranch, nil)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Pulls the current branch, after updating the given branches stacked below it
|
||||
// to their upstream branches
|
||||
func (self *SyncController) pullCurrentBranch(currentBranch *models.Branch, branchesBelow []*models.Branch) error {
|
||||
opts := PullFilesOptions{Action: self.c.Tr.Actions.Pull, BranchesBelow: branchesBelow}
|
||||
|
||||
// if we have no upstream branch we need to set that first
|
||||
if !currentBranch.IsTrackingRemote() {
|
||||
@@ -125,11 +278,46 @@ func (self *SyncController) pull(currentBranch *models.Branch) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return self.PullAux(currentBranch, PullFilesOptions{Action: action})
|
||||
return self.PullAux(currentBranch, opts)
|
||||
})
|
||||
}
|
||||
|
||||
return self.PullAux(currentBranch, PullFilesOptions{Action: action})
|
||||
return self.PullAux(currentBranch, opts)
|
||||
}
|
||||
|
||||
// The branches stacked below the current one that can be updated to their
|
||||
// upstream branches without losing anything: those that are behind them, and
|
||||
// those that diverged from them only because they were rewritten
|
||||
func (self *SyncController) updatableBranchesBelow(currentBranch *models.Branch) []*models.Branch {
|
||||
branchesBelow := helpers.BranchesBelowInStack(
|
||||
self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches)
|
||||
|
||||
return lo.Filter(branchesBelow, func(branch *models.Branch, _ int) bool {
|
||||
// Updating a branch that is checked out in another worktree changes
|
||||
// the files there, which is more than pulling the current branch
|
||||
// should do
|
||||
if git_commands.CheckedOutByOtherWorktree(branch, self.c.Model().Worktrees) {
|
||||
return false
|
||||
}
|
||||
|
||||
return branch.IsBehindForPull() && (!branch.IsAheadForPull() || branch.UpstreamRewritten.Load())
|
||||
})
|
||||
}
|
||||
|
||||
func (self *SyncController) branchesBelowToUpdatePrompt(currentBranch *models.Branch, branchesBelow []*models.Branch) string {
|
||||
intro := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.BranchesBelowHaveChangedOnRemote,
|
||||
map[string]string{"branchName": currentBranch.Name},
|
||||
)
|
||||
lines := lo.Map(branchesBelow, func(branch *models.Branch, _ int) string {
|
||||
divergence := "↓" + branch.BehindForPull
|
||||
if branch.IsAheadForPull() {
|
||||
divergence += "↑" + branch.AheadForPull
|
||||
}
|
||||
return fmt.Sprintf("%s %s", branch.Name, style.FgYellow.Sprint(divergence))
|
||||
})
|
||||
|
||||
return intro + "\n\n " + strings.Join(lines, "\n ")
|
||||
}
|
||||
|
||||
func (self *SyncController) setCurrentBranchUpstream(upstream string) error {
|
||||
@@ -155,10 +343,35 @@ type PullFilesOptions struct {
|
||||
UpstreamBranch string
|
||||
FastForwardOnly bool
|
||||
Action string
|
||||
|
||||
// Branches stacked below the current one, updated to their upstream
|
||||
// branches before the current one is pulled
|
||||
BranchesBelow []*models.Branch
|
||||
}
|
||||
|
||||
func (self *SyncController) PullAux(currentBranch *models.Branch, opts PullFilesOptions) error {
|
||||
return self.c.WithInlineStatus(currentBranch, types.ItemOperationPulling, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
var updateBranchesBelow func(gocui.Task) error
|
||||
if len(opts.BranchesBelow) > 0 {
|
||||
var err error
|
||||
updateBranchesBelow, err = self.c.Helpers().BranchesHelper.PrepareFastForward(opts.BranchesBelow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
branches := append([]*models.Branch{currentBranch}, opts.BranchesBelow...)
|
||||
return self.c.Helpers().BranchesHelper.WithInlineStatusOnBranches(branches, types.ItemOperationPulling, func(task gocui.Task) error {
|
||||
// Update the branches below first. If one of them pointed into the
|
||||
// commits that a rebasing pull rebases, the pull would move it when
|
||||
// rebase.updateRefs is set, and updating it afterwards would fail. If
|
||||
// updating them fails, the current branch isn't pulled either; the
|
||||
// user can still choose to pull only the current branch.
|
||||
if updateBranchesBelow != nil {
|
||||
if err := updateBranchesBelow(task); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return self.pullWithLock(task, opts)
|
||||
})
|
||||
}
|
||||
@@ -190,20 +403,28 @@ type pushOpts struct {
|
||||
// the server rejected. If this is true, we don't offer to force-push if the
|
||||
// server rejected, but rather ask the user to fetch.
|
||||
remoteBranchStoredLocally bool
|
||||
|
||||
// Branches stacked below the current one, pushed after it with one command
|
||||
// per remote, with --force-with-lease if forceWithLeaseBelow is set. The
|
||||
// force options above apply to the current branch's own push only.
|
||||
branchesBelow []*models.Branch
|
||||
forceWithLeaseBelow bool
|
||||
}
|
||||
|
||||
func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) error {
|
||||
return self.c.WithInlineStatus(currentBranch, types.ItemOperationPushing, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.Push)
|
||||
return self.withPushingStatus(currentBranch, opts.branchesBelow, func(task gocui.Task) error {
|
||||
refspecs := []string{}
|
||||
if opts.upstreamBranch != "" {
|
||||
refspecs = append(refspecs, fmt.Sprintf("refs/heads/%s:%s", currentBranch.Name, opts.upstreamBranch))
|
||||
}
|
||||
err := self.c.Git().Sync.Push(
|
||||
task,
|
||||
git_commands.PushOpts{
|
||||
Force: opts.force,
|
||||
ForceWithLease: opts.forceWithLease,
|
||||
CurrentBranch: currentBranch.Name,
|
||||
UpstreamRemote: opts.upstreamRemote,
|
||||
UpstreamBranch: opts.upstreamBranch,
|
||||
SetUpstream: opts.setUpstream,
|
||||
Remote: opts.upstreamRemote,
|
||||
Refspecs: refspecs,
|
||||
})
|
||||
if err != nil {
|
||||
if !opts.force && !opts.forceWithLease && strings.Contains(err.Error(), "Updates were rejected") {
|
||||
@@ -229,11 +450,54 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
err = self.pushBranches(task, opts.branchesBelow, opts.forceWithLeaseBelow)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return nil
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// Pushes the current branch along with the branches stacked below it, all of
|
||||
// them with explicit refspecs
|
||||
func (self *SyncController) pushBranchesAux(currentBranch *models.Branch, branchesBelow []*models.Branch, forceWithLease bool) error {
|
||||
return self.withPushingStatus(currentBranch, branchesBelow, func(task gocui.Task) error {
|
||||
branches := append([]*models.Branch{currentBranch}, branchesBelow...)
|
||||
err := self.pushBranches(task, branches, forceWithLease)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// Runs f as a push of the current branch, showing it and the other branches
|
||||
// as being pushed while it runs
|
||||
func (self *SyncController) withPushingStatus(currentBranch *models.Branch, otherBranches []*models.Branch, f func(gocui.Task) error) error {
|
||||
branches := append([]*models.Branch{currentBranch}, otherBranches...)
|
||||
return self.c.Helpers().BranchesHelper.WithInlineStatusOnBranches(branches, types.ItemOperationPushing, func(task gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.Push)
|
||||
return f(task)
|
||||
})
|
||||
}
|
||||
|
||||
// Pushes the branches to their push destinations, one command per remote
|
||||
func (self *SyncController) pushBranches(task gocui.Task, branches []*models.Branch, forceWithLease bool) error {
|
||||
remotes := lo.Uniq(lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.PushRemote }))
|
||||
for _, remote := range remotes {
|
||||
refspecs := lo.FilterMap(branches, func(branch *models.Branch, _ int) (string, bool) {
|
||||
return fmt.Sprintf("refs/heads/%s:refs/heads/%s", branch.Name, branch.PushBranch), branch.PushRemote == remote
|
||||
})
|
||||
err := self.c.Git().Sync.Push(task, git_commands.PushOpts{
|
||||
ForceWithLease: forceWithLease,
|
||||
Remote: remote,
|
||||
Refspecs: refspecs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *SyncController) requestToForcePush(currentBranch *models.Branch, opts pushOpts) error {
|
||||
forcePushDisabled := self.c.UserConfig().Git.DisableForcePushing
|
||||
if forcePushDisabled {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -61,17 +62,17 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types.
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
||||
Handler: self.withItem(self.delete),
|
||||
Handler: self.withItems(self.delete),
|
||||
Description: self.c.Tr.Delete,
|
||||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||||
GetDisabledReason: self.require(self.itemsSelected()),
|
||||
Tooltip: self.c.Tr.TagDeleteTooltip,
|
||||
OpensMenu: true,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Branches.PushTag),
|
||||
Handler: self.withItem(self.push),
|
||||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||||
Handler: self.withItems(self.push),
|
||||
GetDisabledReason: self.require(self.itemsSelected()),
|
||||
Description: self.c.Tr.PushTag,
|
||||
Tooltip: self.c.Tr.PushTagTooltip,
|
||||
DisplayOnScreen: true,
|
||||
@@ -164,54 +165,104 @@ func (self *TagsController) checkout(tag *models.Tag) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *TagsController) localDelete(tag *models.Tag) error {
|
||||
func (self *TagsController) localDelete(tags []*models.Tag) error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag)
|
||||
err := self.c.Git().Tag.LocalDelete(tag.Name)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
|
||||
err := self.c.Git().Tag.LocalDelete(tagNames(tags))
|
||||
self.refreshAfterLocalDelete()
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (self *TagsController) remoteDelete(tag *models.Tag) error {
|
||||
title := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.SelectRemoteTagUpstream,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
func (self *TagsController) remoteDelete(tags []*models.Tag) error {
|
||||
confirmPromptTemplate := lo.Ternary(len(tags) > 1, self.c.Tr.DeleteRemoteTagsPrompt, self.c.Tr.DeleteRemoteTagPrompt)
|
||||
return self.confirmRemoteDelete(tags, confirmPromptTemplate, func(task gocui.Task, upstream string) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
|
||||
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tagNames(tags)); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Toast(lo.Ternary(len(tags) > 1, self.c.Tr.RemoteTagsDeletedMessage, self.c.Tr.RemoteTagDeletedMessage))
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (self *TagsController) localAndRemoteDelete(tags []*models.Tag) error {
|
||||
confirmPromptTemplate := lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalAndRemoteTagsPrompt, self.c.Tr.DeleteLocalAndRemoteTagPrompt)
|
||||
return self.confirmRemoteDelete(tags, confirmPromptTemplate, func(task gocui.Task, upstream string) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
|
||||
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tagNames(tags)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag)
|
||||
if err := self.c.Git().Tag.LocalDelete(tagNames(tags)); err != nil {
|
||||
return err
|
||||
}
|
||||
self.refreshAfterLocalDelete()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Refreshes after deleting local tags. If the tags were a range selection,
|
||||
// this also collapses it to its first line; otherwise it would select the tags
|
||||
// that moved up into the place of the deleted ones. Collapsing it in the Then
|
||||
// of a batched refresh draws the shorter list and the new selection in the
|
||||
// same frame.
|
||||
func (self *TagsController) refreshAfterLocalDelete() {
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.COMMITS, types.TAGS},
|
||||
BatchUIUpdates: true,
|
||||
Then: func() error {
|
||||
if self.context().IsSelectingRange() {
|
||||
self.context().CollapseRangeSelectionToTop()
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Asks for the remote to delete the tags from and for a confirmation, and then
|
||||
// runs deleteTags on a worker with the tags shown as being deleted.
|
||||
// confirmPromptTemplate can use the placeholder upstream, and tagName if there
|
||||
// is only one tag.
|
||||
func (self *TagsController) confirmRemoteDelete(
|
||||
tags []*models.Tag,
|
||||
confirmPromptTemplate string,
|
||||
deleteTags func(task gocui.Task, upstream string) error,
|
||||
) error {
|
||||
var title string
|
||||
if len(tags) == 1 {
|
||||
title = utils.ResolvePlaceholderString(
|
||||
self.c.Tr.SelectRemoteTagUpstream,
|
||||
map[string]string{
|
||||
"tagName": tags[0].Name,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
title = self.c.Tr.SelectRemoteTagsUpstream
|
||||
}
|
||||
|
||||
self.c.Prompt(types.PromptOpts{
|
||||
Title: title,
|
||||
InitialContent: "origin",
|
||||
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(),
|
||||
HandleConfirm: func(upstream string) error {
|
||||
confirmTitle := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteTagTitle,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
},
|
||||
)
|
||||
confirmPrompt := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteRemoteTagPrompt,
|
||||
confirmPromptTemplate,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
"tagName": tags[0].Name,
|
||||
"upstream": upstream,
|
||||
},
|
||||
)
|
||||
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: confirmTitle,
|
||||
Title: self.deleteTagsTitle(tags),
|
||||
Prompt: confirmPrompt,
|
||||
HandleConfirm: func() error {
|
||||
return self.c.WithInlineStatus(tag, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
|
||||
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tag.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Toast(self.c.Tr.RemoteTagDeletedMessage)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
|
||||
return nil
|
||||
return helpers.WithInlineStatusOnItems(self.c.HelperCommon, tags, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
return deleteTags(task, upstream)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -223,116 +274,73 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error {
|
||||
title := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.SelectRemoteTagUpstream,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
},
|
||||
)
|
||||
|
||||
self.c.Prompt(types.PromptOpts{
|
||||
Title: title,
|
||||
InitialContent: "origin",
|
||||
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(),
|
||||
HandleConfirm: func(upstream string) error {
|
||||
confirmTitle := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteTagTitle,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
},
|
||||
)
|
||||
confirmPrompt := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteLocalAndRemoteTagPrompt,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
"upstream": upstream,
|
||||
},
|
||||
)
|
||||
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: confirmTitle,
|
||||
Prompt: confirmPrompt,
|
||||
HandleConfirm: func() error {
|
||||
return self.c.WithInlineStatus(tag, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
|
||||
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tag.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag)
|
||||
if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *TagsController) delete(tag *models.Tag) error {
|
||||
menuTitle := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteTagTitle,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
},
|
||||
)
|
||||
|
||||
func (self *TagsController) delete(tags []*models.Tag) error {
|
||||
menuItems := []*types.MenuItem{
|
||||
{
|
||||
Label: self.c.Tr.DeleteLocalTag,
|
||||
Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalTags, self.c.Tr.DeleteLocalTag),
|
||||
Keys: menuKey('c'),
|
||||
OnPress: func() error {
|
||||
return self.localDelete(tag)
|
||||
return self.localDelete(tags)
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: self.c.Tr.DeleteRemoteTag,
|
||||
Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteRemoteTags, self.c.Tr.DeleteRemoteTag),
|
||||
Keys: menuKey('r'),
|
||||
OpensMenu: true,
|
||||
OnPress: func() error {
|
||||
return self.remoteDelete(tag)
|
||||
return self.remoteDelete(tags)
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: self.c.Tr.DeleteLocalAndRemoteTag,
|
||||
Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalAndRemoteTags, self.c.Tr.DeleteLocalAndRemoteTag),
|
||||
Keys: menuKey('b'),
|
||||
OpensMenu: true,
|
||||
OnPress: func() error {
|
||||
return self.localAndRemoteDelete(tag)
|
||||
return self.localAndRemoteDelete(tags)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: menuTitle,
|
||||
Title: self.deleteTagsTitle(tags),
|
||||
Items: menuItems,
|
||||
})
|
||||
}
|
||||
|
||||
func (self *TagsController) push(tag *models.Tag) error {
|
||||
title := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.PushTagTitle,
|
||||
func (self *TagsController) deleteTagsTitle(tags []*models.Tag) string {
|
||||
if len(tags) > 1 {
|
||||
return self.c.Tr.DeleteTagsTitle
|
||||
}
|
||||
|
||||
return utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteTagTitle,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
"tagName": tags[0].Name,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *TagsController) push(tags []*models.Tag) error {
|
||||
var title string
|
||||
if len(tags) == 1 {
|
||||
title = utils.ResolvePlaceholderString(
|
||||
self.c.Tr.PushTagTitle,
|
||||
map[string]string{
|
||||
"tagName": tags[0].Name,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
title = self.c.Tr.PushTagsTitle
|
||||
}
|
||||
|
||||
self.c.Prompt(types.PromptOpts{
|
||||
Title: title,
|
||||
InitialContent: "origin",
|
||||
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(),
|
||||
HandleConfirm: func(response string) error {
|
||||
return self.c.WithInlineStatus(tag, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
return helpers.WithInlineStatusOnItems(self.c.HelperCommon, tags, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.PushTag)
|
||||
return self.c.Git().Tag.Push(task, response, tag.Name)
|
||||
return self.c.Git().Tag.Push(task, response, tagNames(tags))
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -354,3 +362,7 @@ func (self *TagsController) create() error {
|
||||
func (self *TagsController) context() *context.TagsContext {
|
||||
return self.c.Contexts().Tags
|
||||
}
|
||||
|
||||
func tagNames(tags []*models.Tag) []string {
|
||||
return lo.Map(tags, func(tag *models.Tag, _ int) string { return tag.Name })
|
||||
}
|
||||
|
||||
+53
-13
@@ -494,7 +494,7 @@ func (gui *Gui) onUserConfigLoaded() error {
|
||||
gui.previousLanguageConfig = userConfig.Gui.Language
|
||||
}
|
||||
|
||||
gui.setColorScheme()
|
||||
gui.applyTheme()
|
||||
gui.configureViewProperties()
|
||||
|
||||
gui.g.SearchEscapeKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.Return)
|
||||
@@ -517,7 +517,6 @@ func (gui *Gui) onUserConfigLoaded() error {
|
||||
// sake of backwards compatibility. We're making use of short circuiting here
|
||||
gui.ShowExtrasWindow = userConfig.Gui.ShowCommandLog && !gui.c.GetAppState().HideCommandLog
|
||||
|
||||
authors.SetCustomAuthors(userConfig.Gui.AuthorColors)
|
||||
if userConfig.Gui.NerdFontsVersion != "" {
|
||||
icons.SetNerdFontsVersion(userConfig.Gui.NerdFontsVersion)
|
||||
} else if userConfig.Gui.ShowIcons {
|
||||
@@ -526,13 +525,6 @@ func (gui *Gui) onUserConfigLoaded() error {
|
||||
icons.SetNerdFontsVersion("")
|
||||
}
|
||||
|
||||
if len(userConfig.Gui.BranchColorPatterns) > 0 {
|
||||
presentation.SetCustomBranches(userConfig.Gui.BranchColorPatterns, true)
|
||||
} else {
|
||||
// Fall back to the deprecated branchColors config
|
||||
presentation.SetCustomBranches(userConfig.Gui.BranchColors, false)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -942,6 +934,19 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
|
||||
|
||||
g.ErrorHandler = gui.PopupHandler.ErrorHandler
|
||||
|
||||
gui.c.Log.Infof("Terminal color scheme: %s", g.DetectedColorScheme())
|
||||
g.SetColorSchemeChangeHandler(func(colorScheme gocui.DetectedColorScheme) error {
|
||||
gui.c.Log.Infof("Terminal color scheme changed: %s", colorScheme)
|
||||
gui.applyTheme()
|
||||
gui.configureViewProperties()
|
||||
for _, context := range gui.c.Context().AllList() {
|
||||
context.HandleRender()
|
||||
}
|
||||
gui.helpers.Refresh.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STATUS}})
|
||||
gui.helpers.Diff.RenderToMainAgain()
|
||||
return nil
|
||||
})
|
||||
|
||||
gui.g.ShouldHandleMouseEvent = func(view *gocui.View, key gocui.KeyName) bool {
|
||||
if gui.helpers.Confirmation.IsPopupPanelFocused() && gui.currentViewName() != view.Name() &&
|
||||
!gocui.IsMouseScrollKey(key) {
|
||||
@@ -1244,15 +1249,50 @@ func (gui *Gui) showBreakingChangesMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
// setColorScheme sets the color scheme for the app based on the user config
|
||||
func (gui *Gui) setColorScheme() {
|
||||
userConfig := gui.UserConfig()
|
||||
theme.UpdateTheme(userConfig.Gui.Theme)
|
||||
// applyTheme sets the colors of the app from the theme in the user config,
|
||||
// with the overrides for the terminal's background applied
|
||||
func (gui *Gui) applyTheme() {
|
||||
themeConfig := gui.UserConfig().Gui.ThemeForBackground(gui.terminalHasLightBackground(), gui.terminalBackgroundColor())
|
||||
theme.UpdateTheme(themeConfig)
|
||||
authors.SetCustomAuthors(themeConfig.AuthorColors)
|
||||
presentation.SetCustomBranches(themeConfig.BranchColorPatterns)
|
||||
|
||||
gui.g.FgColor = theme.InactiveBorderColor
|
||||
gui.g.SelFgColor = theme.ActiveBorderColor
|
||||
gui.g.FrameColor = theme.InactiveBorderColor
|
||||
gui.g.SelFrameColor = theme.ActiveBorderColor
|
||||
|
||||
gui.applyTerminalBackground()
|
||||
}
|
||||
|
||||
// applyTerminalBackground tells the colors that depend on the terminal's
|
||||
// background whether it is light.
|
||||
func (gui *Gui) applyTerminalBackground() {
|
||||
authors.SetLightBackground(gui.terminalHasLightBackground())
|
||||
}
|
||||
|
||||
// terminalHasLightBackground goes by gui.colorScheme, or by what the terminal
|
||||
// tells us if that is 'auto'.
|
||||
func (gui *Gui) terminalHasLightBackground() bool {
|
||||
switch gui.UserConfig().Gui.ColorScheme {
|
||||
case "dark":
|
||||
return false
|
||||
case "light":
|
||||
return true
|
||||
default:
|
||||
return gui.g.DetectedColorScheme().ColorScheme == gocui.ColorSchemeLight
|
||||
}
|
||||
}
|
||||
|
||||
// terminalBackgroundColor returns the background color that the terminal told
|
||||
// us, as #rrggbb. It returns "" if the terminal didn't tell us, or if
|
||||
// gui.colorScheme disagrees with it about whether the background is light.
|
||||
func (gui *Gui) terminalBackgroundColor() string {
|
||||
detected := gui.g.DetectedColorScheme()
|
||||
if (detected.ColorScheme == gocui.ColorSchemeLight) != gui.terminalHasLightBackground() {
|
||||
return ""
|
||||
}
|
||||
return detected.Background
|
||||
}
|
||||
|
||||
func (gui *Gui) onUIThread(f func() error) {
|
||||
|
||||
@@ -20,8 +20,8 @@ func (gui *Gui) runTaskForView(view *gocui.View, task types.UpdateTask) error {
|
||||
case *types.RunCommandTask:
|
||||
return gui.newCmdTask(view, v.Cmd, v.Prefix)
|
||||
|
||||
case *types.RunPtyTask:
|
||||
return gui.newPtyTask(view, v.Cmd, v.Prefix)
|
||||
case *types.RunDiffRendererTask:
|
||||
return gui.newRenderTask(view, v.Cmd, v.Prefix)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// renderSpec describes a render of a command's output into a view: what a way
|
||||
// of running the command (see runRender) needs to know about it.
|
||||
type renderSpec struct {
|
||||
view *gocui.View
|
||||
cmd *exec.Cmd
|
||||
// The width the renderer lays its rendering out to, and the width the view
|
||||
// counts its own wrapping against. Read after the layout pass, which
|
||||
// settles it.
|
||||
width int
|
||||
// The configured stdin filter, empty unless one is configured. How git gets
|
||||
// to run it depends on the way the render runs. An external diff renderer
|
||||
// is named to git in the environment before the render is set up, whichever
|
||||
// way it runs, so it doesn't appear here; nothing else is a command of its
|
||||
// own.
|
||||
stdinFilter string
|
||||
}
|
||||
|
||||
// newRenderTask renders cmd's output into view, through the diff renderer the
|
||||
// user has configured. The renderer lays its rendering out to the width of the
|
||||
// view, which only the layout settles, so the task is created after it.
|
||||
func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
|
||||
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
|
||||
// If we're not using a custom diff renderer, then we don't need to use a pty
|
||||
return gui.newCmdTask(view, cmd, prefix)
|
||||
}
|
||||
|
||||
// Mark the view as loading synchronously now, before the layout pass: the
|
||||
// actual task is created in afterLayout (below), which runs after layout, so
|
||||
// without this the next layout pass would clamp the scroll position to the
|
||||
// not-yet-loaded content.
|
||||
gui.getManager(view).StartLoading()
|
||||
// Hold the scrollbar at its current height while the re-render loads, so the
|
||||
// thumb doesn't shrink and snap back when the first partial paint swaps in
|
||||
// (see the matching call in newCmdTask).
|
||||
view.FreezeScrollbarHeight()
|
||||
|
||||
// Run the render after layout so that it gets the correct size
|
||||
gui.afterLayout(func() error {
|
||||
// The layout may have changed the size of the view, so only now is the
|
||||
// width to render at known, and with it the renderer command.
|
||||
width := view.InnerWidth()
|
||||
diffRendererConfigManager := gui.stateAccessor.GetDiffRendererConfigManager()
|
||||
values := config.DiffRendererValues{
|
||||
Width: width,
|
||||
DiffContext: gui.c.UserConfig().Git.DiffContextSize,
|
||||
LightBackground: gui.terminalHasLightBackground(),
|
||||
}
|
||||
stdinFilter, stdinFilterErr := diffRendererConfigManager.GetStdinFilterCommand(values)
|
||||
externalDiff, externalDiffErr := diffRendererConfigManager.GetExternalDiffCommand(values)
|
||||
if err := errors.Join(stdinFilterErr, externalDiffErr); err != nil {
|
||||
// The commands are checked with made-up values when the config is
|
||||
// loaded, so only one that fails for some values and not for others
|
||||
// gets here. Git's own diff is shown instead.
|
||||
gui.c.ErrorToast(err.Error())
|
||||
}
|
||||
|
||||
cmdStr := strings.Join(cmd.Args, " ")
|
||||
|
||||
// This communicates to diff renderers that we're in a very simple
|
||||
// terminal that they should not expect to have much capabilities.
|
||||
// Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities.
|
||||
// Context: https://github.com/jesseduffield/lazygit/issues/3419
|
||||
cmd.Env = removeExistingTermEnvVars(cmd.Env)
|
||||
cmd.Env = append(cmd.Env, "TERM=dumb")
|
||||
|
||||
// An external diff command is named to git here, in the environment,
|
||||
// because the width it renders at is only known after the layout, and
|
||||
// the command's arguments were settled before it. An empty command
|
||||
// means the user wants git's own diff.external config to apply, so
|
||||
// leave the variable unset in that case; git takes it being set at all
|
||||
// as an instruction, however little it says.
|
||||
if externalDiff != "" {
|
||||
cmd.Env = append(cmd.Env, "GIT_EXTERNAL_DIFF="+externalDiff)
|
||||
}
|
||||
|
||||
spec := renderSpec{
|
||||
view: view,
|
||||
cmd: cmd,
|
||||
width: width,
|
||||
stdinFilter: stdinFilter,
|
||||
}
|
||||
run := gui.ptyRender
|
||||
if rendersThroughAPipe() {
|
||||
run = gui.pipedRender
|
||||
}
|
||||
return gui.newTaskForRender(spec, prefix, cmdStr, run)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// The start and onClose functions a render hands to its task: how to get the
|
||||
// command running and the output reader for it, and how to tear it down again
|
||||
// once the task is stopped.
|
||||
type (
|
||||
startRender func() (tasks.Cmd, io.Reader)
|
||||
onCloseRender func()
|
||||
)
|
||||
|
||||
// runRender is a way of running a render's command and getting at its output:
|
||||
// plainly, in a pty, or through a pipe with the stdin filter as a command of our
|
||||
// own. It returns the functions the task drives the command by.
|
||||
type runRender func(spec renderSpec) (startRender, onCloseRender)
|
||||
|
||||
// newTaskForRender creates the task that reads the render's output into its
|
||||
// view, running the command the given way. key names what is rendered, so that
|
||||
// a re-render of the same content can be told from a render of other content.
|
||||
func (gui *Gui) newTaskForRender(spec renderSpec, prefix string, key string, run runRender) error {
|
||||
setColumnsEnvVar(spec.cmd, spec.width)
|
||||
|
||||
start, onClose := run(spec)
|
||||
|
||||
manager := gui.getManager(spec.view)
|
||||
linesToRead := gui.linesToReadFromCmdTask(spec.view)
|
||||
return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), key)
|
||||
}
|
||||
|
||||
// renderWithoutPtyEnvVar makes a render take the piped path on a platform that
|
||||
// would otherwise use a pty, so that tests can exercise it anywhere.
|
||||
const renderWithoutPtyEnvVar = "LAZYGIT_RENDER_WITHOUT_PTY"
|
||||
|
||||
// rendersThroughAPipe reports whether a render feeds the diff renderer the
|
||||
// command's output through a pipe rather than running it in a pty.
|
||||
//
|
||||
// On Windows it has to. ConPTY doesn't pass a command's output through; it
|
||||
// parses it into a screen buffer and re-encodes that for the terminal side,
|
||||
// and it hands a sequence it can't represent there the moment it parses it,
|
||||
// separately from the text around it. So what a renderer writes is not what
|
||||
// lazygit reads. A pipe carries the bytes as the renderer wrote them.
|
||||
//
|
||||
// Everywhere else the pty is kept, since a renderer can read the width it
|
||||
// should lay out to off it, and a configuration that doesn't name a width would
|
||||
// otherwise render at whatever width the renderer falls back to.
|
||||
func rendersThroughAPipe() bool {
|
||||
return runtime.GOOS == "windows" || os.Getenv(renderWithoutPtyEnvVar) != ""
|
||||
}
|
||||
|
||||
// pipedRender feeds the diff renderer the command's output through a pipe.
|
||||
//
|
||||
// A stdin filter becomes a command of our own here, because git only invokes
|
||||
// the one named by GIT_PAGER when it thinks it is talking to a terminal, so
|
||||
// with a pipe the filter would never run. An external diff renderer is git's
|
||||
// own business, named in the environment and run by git per file, so with one
|
||||
// the command runs alone.
|
||||
func (gui *Gui) pipedRender(spec renderSpec) (startRender, onCloseRender) {
|
||||
if spec.stdinFilter == "" {
|
||||
return gui.plainRender(spec)
|
||||
}
|
||||
|
||||
view := spec.view
|
||||
cmd := spec.cmd
|
||||
|
||||
var pipe io.ReadCloser
|
||||
start := func() (tasks.Cmd, io.Reader) {
|
||||
// See the matching call in ptyRender for why this happens here.
|
||||
view.SetContentWidth(spec.width)
|
||||
|
||||
// The filter runs in a plain shell, without lazygit's shell functions
|
||||
// sourced, since that is the shell git would have run it in. It is
|
||||
// handed git's environment for the same reason: as git's child it
|
||||
// would have inherited exactly that.
|
||||
pipeline, reader, err := gui.os.StartPipeline(
|
||||
gui.os.Cmd.NewFromCmd(cmd).DontLog(),
|
||||
gui.os.Cmd.NewShell(spec.stdinFilter, "").SetEnviron(cmd.Env).DontLog(),
|
||||
)
|
||||
if err != nil {
|
||||
gui.c.Log.Error(err)
|
||||
// The command has been started and stopped again by now, so it
|
||||
// can't be run a second time without the renderer. Show what went
|
||||
// wrong where the diff would have been.
|
||||
return tasks.ExecCmd{Cmd: cmd}, strings.NewReader(err.Error())
|
||||
}
|
||||
pipe = reader
|
||||
return pipeline, reader
|
||||
}
|
||||
|
||||
onClose := func() {
|
||||
// Closing the reader brings the pipeline down. The renderer's next write
|
||||
// fails, so it exits, and git's write into the pipe the renderer was
|
||||
// reading fails in turn.
|
||||
if pipe != nil {
|
||||
pipe.Close()
|
||||
pipe = nil
|
||||
}
|
||||
}
|
||||
|
||||
return start, onClose
|
||||
}
|
||||
|
||||
// setColumnsEnvVar tells a command how wide the view its output goes into is.
|
||||
// git reads COLUMNS in preference to the size of the terminal it is talking to,
|
||||
// and lays the diffstat graph out to it; a diff renderer with no terminal to
|
||||
// ask may read it too (difftastic and diff-so-fancy do, delta does not). A
|
||||
// command told nothing renders for 80 columns.
|
||||
func setColumnsEnvVar(cmd *exec.Cmd, width int) {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("COLUMNS=%d", width))
|
||||
}
|
||||
|
||||
func removeExistingTermEnvVars(env []string) []string {
|
||||
return lo.Filter(env, func(envVar string, _ int) bool {
|
||||
return !isTermEnvVar(envVar)
|
||||
})
|
||||
}
|
||||
|
||||
// Terminals set a variety of different environment variables
|
||||
// to identify themselves to processes. This list should catch the most common among them.
|
||||
func isTermEnvVar(envVar string) bool {
|
||||
return strings.HasPrefix(envVar, "TERM=") ||
|
||||
strings.HasPrefix(envVar, "TERM_PROGRAM=") ||
|
||||
strings.HasPrefix(envVar, "TERM_PROGRAM_VERSION=") ||
|
||||
strings.HasPrefix(envVar, "TERMINAL_EMULATOR=") ||
|
||||
strings.HasPrefix(envVar, "TERMINAL_NAME=") ||
|
||||
strings.HasPrefix(envVar, "TERMINAL_VERSION_")
|
||||
}
|
||||
@@ -21,7 +21,17 @@ type authorNameCacheKey struct {
|
||||
var (
|
||||
authorInitialCache = make(map[string]string)
|
||||
authorNameCache = make(map[authorNameCacheKey]string)
|
||||
authorStyleCache = make(map[string]*style.TextStyle)
|
||||
|
||||
// The styles from gui.authorColors
|
||||
customAuthorStyles = make(map[string]*style.TextStyle)
|
||||
// The styles derived from the names of the other authors
|
||||
authorStyleCache = make(map[string]*style.TextStyle)
|
||||
|
||||
// Whether the terminal has a light background, for the derived styles to
|
||||
// stand out against
|
||||
lightBackground bool
|
||||
|
||||
colorsVersion int
|
||||
)
|
||||
|
||||
const authorNameWildcard = "*"
|
||||
@@ -74,12 +84,16 @@ func AuthorWithLength(authorName string, length int) string {
|
||||
}
|
||||
|
||||
func AuthorStyle(authorName string) *style.TextStyle {
|
||||
if value, ok := authorStyleCache[authorName]; ok {
|
||||
if value, ok := customAuthorStyles[authorName]; ok {
|
||||
return value
|
||||
}
|
||||
|
||||
// use the unified style whatever the author name is
|
||||
if value, ok := authorStyleCache[authorNameWildcard]; ok {
|
||||
if value, ok := customAuthorStyles[authorNameWildcard]; ok {
|
||||
return value
|
||||
}
|
||||
|
||||
if value, ok := authorStyleCache[authorName]; ok {
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -91,12 +105,42 @@ func AuthorStyle(authorName string) *style.TextStyle {
|
||||
}
|
||||
|
||||
func trueColorStyle(str string) style.TextStyle {
|
||||
hash := md5.Sum([]byte(str))
|
||||
c := colorful.Hsl(randFloat(hash[0:4])*360.0, 0.6+0.4*randFloat(hash[4:8]), 0.4+randFloat(hash[8:12])*0.2)
|
||||
c := colorAtPosition(ColorPosition(str))
|
||||
|
||||
return style.New().SetFg(style.NewRGBColor(color.RGB(uint8(c.R*255), uint8(c.G*255), uint8(c.B*255))))
|
||||
}
|
||||
|
||||
// To check the colors at the edges of the ranges below, run
|
||||
// `go run ./cmd/author_colors_repo <path>` and open the repository it creates.
|
||||
func colorAtPosition(hue, saturation, lightness float64) colorful.Color {
|
||||
// The lightness of an HSLuv color is how bright it looks, so every author
|
||||
// comes out about equally readable whichever hue their name lands on. Plain
|
||||
// HSL spreads them instead. At one and the same lightness, it gives a
|
||||
// glaring yellow and a blue that all but disappears.
|
||||
//
|
||||
// There is one lightness range for a dark background and one for a light
|
||||
// background. Each keeps every author above a contrast ratio of 4.5:1
|
||||
// against common backgrounds of its kind, such as #1e1e1e and #fdf6e3.
|
||||
//
|
||||
// Saturation in HSLuv is a fraction of the most colorful a hue can get at
|
||||
// that lightness, and pale colors are hard to tell apart, so keep it near
|
||||
// the top of its range.
|
||||
minLightness := 0.57
|
||||
if lightBackground {
|
||||
minLightness = 0.31
|
||||
}
|
||||
|
||||
return colorful.HSLuv(hue*360.0, 0.8+0.2*saturation, minLightness+0.15*lightness)
|
||||
}
|
||||
|
||||
// ColorPosition says where an author's color lies within the range of hues,
|
||||
// saturations and lightnesses that colorAtPosition picks from. Each is a
|
||||
// fraction from 0 up to 1, derived from a hash of the author's name.
|
||||
func ColorPosition(authorName string) (hue, saturation, lightness float64) {
|
||||
hash := md5.Sum([]byte(authorName))
|
||||
return randFloat(hash[0:4]), randFloat(hash[4:8]), randFloat(hash[8:12])
|
||||
}
|
||||
|
||||
func randFloat(hash []byte) float64 {
|
||||
return float64(randInt(hash, 100)) / 100
|
||||
}
|
||||
@@ -128,5 +172,32 @@ func getInitials(authorName string) string {
|
||||
}
|
||||
|
||||
func SetCustomAuthors(customAuthorColors map[string]string) {
|
||||
authorStyleCache = utils.SetCustomColors(customAuthorColors)
|
||||
customAuthorStyles = utils.SetCustomColors(customAuthorColors)
|
||||
colorsChanged()
|
||||
}
|
||||
|
||||
// SetLightBackground says whether the terminal has a light background, for the
|
||||
// colors of authors to stand out against.
|
||||
func SetLightBackground(light bool) {
|
||||
if light == lightBackground {
|
||||
return
|
||||
}
|
||||
|
||||
lightBackground = light
|
||||
authorStyleCache = make(map[string]*style.TextStyle)
|
||||
colorsChanged()
|
||||
}
|
||||
|
||||
// colorsChanged drops what was rendered with the previous colors of authors.
|
||||
func colorsChanged() {
|
||||
authorInitialCache = make(map[string]string)
|
||||
authorNameCache = make(map[authorNameCacheKey]string)
|
||||
colorsVersion++
|
||||
}
|
||||
|
||||
// ColorsVersion changes whenever the colors of authors change, so that
|
||||
// whatever keeps the styles of authors around can tell when they are out of
|
||||
// date.
|
||||
func ColorsVersion() int {
|
||||
return colorsVersion
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ package authors
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gookit/color"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/xo/terminfo"
|
||||
)
|
||||
|
||||
func TestGetInitials(t *testing.T) {
|
||||
@@ -41,3 +45,79 @@ func TestAuthorWithLength(t *testing.T) {
|
||||
assert.Equal(t, s.expectedOutput, utils.Decolorise(AuthorWithLength(s.authorName, s.length)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorColorsFollowTheConfig(t *testing.T) {
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
t.Cleanup(func() { SetCustomAuthors(nil) })
|
||||
|
||||
SetCustomAuthors(map[string]string{"Jane Doe": "red"})
|
||||
assert.Equal(t, style.FgRed.Sprint("JD"), ShortAuthor("Jane Doe"))
|
||||
assert.Equal(t, style.FgRed.Sprint("Jane Doe"), LongAuthor("Jane Doe", 8))
|
||||
|
||||
SetCustomAuthors(map[string]string{"Jane Doe": "blue"})
|
||||
assert.Equal(t, style.FgBlue.Sprint("JD"), ShortAuthor("Jane Doe"))
|
||||
assert.Equal(t, style.FgBlue.Sprint("Jane Doe"), LongAuthor("Jane Doe", 8))
|
||||
}
|
||||
|
||||
func TestSetLightBackground(t *testing.T) {
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
t.Cleanup(func() {
|
||||
SetLightBackground(false)
|
||||
SetCustomAuthors(nil)
|
||||
})
|
||||
|
||||
SetCustomAuthors(map[string]string{"Jane Doe": "red"})
|
||||
onDarkBackground := ShortAuthor("John Smith")
|
||||
|
||||
SetLightBackground(true)
|
||||
assert.NotEqual(t, onDarkBackground, ShortAuthor("John Smith"))
|
||||
assert.Equal(t, style.FgRed.Sprint("JD"), ShortAuthor("Jane Doe"))
|
||||
}
|
||||
|
||||
func TestAuthorColorsStandOutAgainstTheBackground(t *testing.T) {
|
||||
t.Cleanup(func() { SetLightBackground(false) })
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
lightBackground bool
|
||||
backgrounds []string
|
||||
}{
|
||||
{name: "dark", lightBackground: false, backgrounds: []string{"#000000", "#1e1e1e"}},
|
||||
{name: "light", lightBackground: true, backgrounds: []string{"#ffffff", "#fdf6e3"}},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
SetLightBackground(s.lightBackground)
|
||||
|
||||
for _, backgroundHex := range s.backgrounds {
|
||||
background, err := colorful.Hex(backgroundHex)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// The edges of the range, which is where the contrast is lowest
|
||||
for hue := range 100 {
|
||||
for _, saturation := range []float64{0, 0.99} {
|
||||
for _, lightness := range []float64{0, 0.99} {
|
||||
c := colorAtPosition(float64(hue)/100, saturation, lightness)
|
||||
assert.GreaterOrEqual(t, contrastRatio(c, background), 4.5,
|
||||
"%s on %s", c.Hex(), backgroundHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// contrastRatio is as defined by the Web Content Accessibility Guidelines
|
||||
func contrastRatio(a colorful.Color, b colorful.Color) float64 {
|
||||
luminance := func(c colorful.Color) float64 {
|
||||
r, g, b := c.LinearRgb()
|
||||
return 0.2126*r + 0.7152*g + 0.0722*b
|
||||
}
|
||||
lighter := max(luminance(a), luminance(b))
|
||||
darker := min(luminance(a), luminance(b))
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ import (
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type colorMatcher struct {
|
||||
patterns map[string]*style.TextStyle
|
||||
isRegex bool // NOTE: this value is needed only until the deprecated branchColors config is removed and only regex color patterns are used
|
||||
type branchColorPattern struct {
|
||||
pattern string
|
||||
style style.TextStyle
|
||||
}
|
||||
|
||||
var colorPatterns *colorMatcher
|
||||
var branchColorPatterns []branchColorPattern
|
||||
|
||||
var dimYellow = style.FgYellow.SetDim()
|
||||
|
||||
func GetBranchListDisplayStrings(
|
||||
branches []*models.Branch,
|
||||
@@ -193,31 +195,15 @@ func getBranchDisplayStrings(
|
||||
|
||||
// GetBranchTextStyle branch color
|
||||
func GetBranchTextStyle(name string) style.TextStyle {
|
||||
if style, ok := colorPatterns.match(name); ok {
|
||||
return *style
|
||||
for _, p := range branchColorPatterns {
|
||||
if matched, _ := regexp.MatchString(p.pattern, name); matched {
|
||||
return p.style
|
||||
}
|
||||
}
|
||||
|
||||
return theme.DefaultTextColor
|
||||
}
|
||||
|
||||
func (m *colorMatcher) match(name string) (*style.TextStyle, bool) {
|
||||
if m.isRegex {
|
||||
for pattern, style := range m.patterns {
|
||||
if matched, _ := regexp.MatchString(pattern, name); matched {
|
||||
return style, true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// old behavior using the deprecated branchColors behavior matching on branch type
|
||||
branchType := strings.Split(name, "/")[0]
|
||||
if value, ok := m.patterns[branchType]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func BranchStatus(
|
||||
branch *models.Branch,
|
||||
itemOperation types.ItemOperation,
|
||||
@@ -239,7 +225,12 @@ func BranchStatus(
|
||||
} else if branch.RemoteBranchNotStoredLocally() {
|
||||
result = style.FgMagenta.Sprint("?")
|
||||
} else if branch.IsBehindForPull() && branch.IsAheadForPull() {
|
||||
result = style.FgYellow.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull)
|
||||
// A branch that diverged only because its upstream was rewritten
|
||||
// has no commits of its own, and fast-forwarding it resolves the
|
||||
// divergence. Dim it to set it apart from a branch whose
|
||||
// divergence needs a decision.
|
||||
divergenceStyle := lo.Ternary(branch.UpstreamRewritten.Load(), dimYellow, style.FgYellow)
|
||||
result = divergenceStyle.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull)
|
||||
} else if branch.IsBehindForPull() {
|
||||
result = style.FgYellow.Sprintf("↓%s", branch.BehindForPull)
|
||||
} else if branch.IsAheadForPull() {
|
||||
@@ -271,11 +262,10 @@ func divergenceStr(
|
||||
return result
|
||||
}
|
||||
|
||||
func SetCustomBranches(customBranchColors map[string]string, isRegex bool) {
|
||||
colorPatterns = &colorMatcher{
|
||||
patterns: utils.SetCustomColors(customBranchColors),
|
||||
isRegex: isRegex,
|
||||
}
|
||||
func SetCustomBranches(patterns config.ColorPatterns) {
|
||||
branchColorPatterns = lo.Map(patterns, func(p config.ColorPattern, _ int) branchColorPattern {
|
||||
return branchColorPattern{pattern: p.Pattern, style: utils.CustomColorStyle(p.Color)}
|
||||
})
|
||||
}
|
||||
|
||||
func WithPrColor(state string, text string, isBg bool) string {
|
||||
|
||||
@@ -2,6 +2,7 @@ package presentation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -9,10 +10,12 @@ import (
|
||||
"github.com/gookit/color"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/theme"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/xo/terminfo"
|
||||
@@ -419,7 +422,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
|
||||
c := common.NewDummyCommon()
|
||||
SetCustomBranches(c.UserConfig().Gui.BranchColorPatterns, true)
|
||||
SetCustomBranches(c.UserConfig().Gui.Theme.BranchColorPatterns)
|
||||
|
||||
for i, s := range scenarios {
|
||||
icons.SetNerdFontsVersion(lo.Ternary(s.useIcons, "3", ""))
|
||||
@@ -436,3 +439,48 @@ func Test_getBranchDisplayStrings(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBranchTextStyle(t *testing.T) {
|
||||
defer SetCustomBranches(nil)
|
||||
|
||||
patterns := config.ColorPatterns{
|
||||
{Pattern: "^feature/", Color: "green"},
|
||||
{Pattern: `ISSUE-\d+`, Color: "red"},
|
||||
}
|
||||
|
||||
SetCustomBranches(patterns)
|
||||
assert.Equal(t, style.FgGreen, GetBranchTextStyle("feature/ISSUE-1"))
|
||||
assert.Equal(t, style.FgRed, GetBranchTextStyle("fix/ISSUE-1"))
|
||||
assert.Equal(t, theme.DefaultTextColor, GetBranchTextStyle("main"))
|
||||
|
||||
slices.Reverse(patterns)
|
||||
SetCustomBranches(patterns)
|
||||
assert.Equal(t, style.FgRed, GetBranchTextStyle("feature/ISSUE-1"))
|
||||
}
|
||||
|
||||
func TestBranchStatus(t *testing.T) {
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
|
||||
c := common.NewDummyCommon()
|
||||
|
||||
divergedBranch := func(upstreamRewritten bool) *models.Branch {
|
||||
branch := &models.Branch{
|
||||
Name: "branch",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "branch",
|
||||
AheadForPull: "3",
|
||||
BehindForPull: "5",
|
||||
}
|
||||
branch.UpstreamRewritten.Store(upstreamRewritten)
|
||||
return branch
|
||||
}
|
||||
|
||||
status := func(branch *models.Branch) string {
|
||||
return BranchStatus(branch, types.ItemOperationNone, c.Tr, time.Time{}, c.UserConfig())
|
||||
}
|
||||
|
||||
assert.Equal(t, "\x1b[33m↓5↑3\x1b[0m", status(divergedBranch(false)))
|
||||
assert.Equal(t, "\x1b[33;2m↓5↑3\x1b[0m", status(divergedBranch(true)),
|
||||
"a branch whose upstream was rewritten should be dimmed")
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ type pipeSetCacheKey struct {
|
||||
var (
|
||||
pipeSetCache = make(map[pipeSetCacheKey][][]graph.Pipe)
|
||||
mutex deadlock.Mutex
|
||||
|
||||
// The pipes have the colors of the authors of the commits they start at,
|
||||
// so they are only good for as long as these colors don't change
|
||||
pipeSetCacheAuthorColors = authors.ColorsVersion()
|
||||
)
|
||||
|
||||
type bisectBounds struct {
|
||||
@@ -302,6 +306,11 @@ func indexOfFirstNonTODOCommit(commits []*models.Commit) int {
|
||||
}
|
||||
|
||||
func loadPipesets(commits []*models.Commit) [][]graph.Pipe {
|
||||
if pipeSetCacheAuthorColors != authors.ColorsVersion() {
|
||||
pipeSetCache = make(map[pipeSetCacheKey][][]graph.Pipe)
|
||||
pipeSetCacheAuthorColors = authors.ColorsVersion()
|
||||
}
|
||||
|
||||
// given that our cache key is a commit hash and a commit count, it's very important that we don't actually try to render pipes
|
||||
// when dealing with things like filtered commits.
|
||||
cacheKey := pipeSetCacheKey{
|
||||
@@ -529,7 +538,7 @@ func displayCommit(
|
||||
func getBisectStatusColor(status BisectStatus) style.TextStyle {
|
||||
switch status {
|
||||
case BisectStatusNone:
|
||||
return style.FgBlack
|
||||
return theme.DefaultTextColor.SetDim()
|
||||
case BisectStatusNew:
|
||||
return style.FgRed
|
||||
case BisectStatusOld:
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stefanhaller/git-todo-parser/todo"
|
||||
@@ -620,3 +623,24 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphColorsFollowTheAuthorColors(t *testing.T) {
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
t.Cleanup(func() { authors.SetCustomAuthors(nil) })
|
||||
|
||||
hashPool := &utils.StringPool{}
|
||||
commits := []*models.Commit{
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors1", AuthorName: "Jane Doe", Parents: []string{"authorcolors2"}}),
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors2", AuthorName: "Jane Doe"}),
|
||||
}
|
||||
renderGraph := func() string {
|
||||
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil), "\n")
|
||||
}
|
||||
|
||||
authors.SetCustomAuthors(map[string]string{"Jane Doe": "red"})
|
||||
assert.Contains(t, renderGraph(), style.FgRed.Sprint("○"))
|
||||
|
||||
authors.SetCustomAuthors(map[string]string{"Jane Doe": "blue"})
|
||||
assert.Contains(t, renderGraph(), style.FgBlue.Sprint("○"))
|
||||
}
|
||||
|
||||
+54
-155
@@ -1,20 +1,14 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func (gui *Gui) desiredPtySize(view *gocui.View) (cols, rows uint16) {
|
||||
@@ -53,156 +47,61 @@ func (p ptyCmd) Wait() error { return p.wait() }
|
||||
func (p ptyCmd) String() string { return p.cmd.String() }
|
||||
func (p ptyCmd) Terminate() error { return oscommands.TerminateProcessGracefully(p.process) }
|
||||
|
||||
// Some commands need to output for a terminal to active certain behaviour.
|
||||
// For example, git won't invoke the GIT_PAGER env var unless it thinks it's
|
||||
// talking to a terminal. We typically write cmd outputs straight to a view,
|
||||
// which is just an io.Reader. the pty package lets us wrap a command in a
|
||||
// pseudo-terminal meaning we'll get the behaviour we want from the underlying
|
||||
// command.
|
||||
func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
|
||||
width := view.InnerWidth()
|
||||
|
||||
// Set LAZYGIT_COLUMNS for diff renderer scripts that can't query the terminal width directly.
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width))
|
||||
|
||||
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
|
||||
// If we're not using a custom diff renderer, then we don't need to use a pty
|
||||
return gui.newCmdTask(view, cmd, prefix)
|
||||
}
|
||||
|
||||
cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS)
|
||||
|
||||
// Mark the view as loading synchronously now, before the layout pass: the
|
||||
// actual task is created in afterLayout (below), which runs after layout, so
|
||||
// without this the next layout pass would clamp the scroll position to the
|
||||
// not-yet-loaded content.
|
||||
gui.getManager(view).StartLoading()
|
||||
// Hold the scrollbar at its current height while the re-render loads, so the
|
||||
// thumb doesn't shrink and snap back when the first partial paint swaps in
|
||||
// (see the matching call in newCmdTask).
|
||||
view.FreezeScrollbarHeight()
|
||||
|
||||
// Run the pty after layout so that it gets the correct size
|
||||
gui.afterLayout(func() error {
|
||||
// Need to get the width and the pager command again because the layout might have
|
||||
// changed the size of the view
|
||||
width = view.InnerWidth()
|
||||
pager := gui.stateAccessor.GetDiffRendererConfigManager().GetStdinFilterCommand(width)
|
||||
|
||||
cmdStr := strings.Join(cmd.Args, " ")
|
||||
|
||||
// This communicates to diff renderers that we're in a very simple
|
||||
// terminal that they should not expect to have much capabilities.
|
||||
// Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities.
|
||||
// Context: https://github.com/jesseduffield/lazygit/issues/3419
|
||||
cmd.Env = removeExistingTermEnvVars(cmd.Env)
|
||||
cmd.Env = append(cmd.Env, "TERM=dumb")
|
||||
|
||||
cmd.Env = append(cmd.Env, "GIT_PAGER="+pager)
|
||||
|
||||
manager := gui.getManager(view)
|
||||
|
||||
// Size the pty from the view's dimensions here, on the UI thread; the
|
||||
// start func below runs on the task's goroutine, which must not read the
|
||||
// view's live dimensions while the UI thread is laying it out.
|
||||
cols, rows := gui.desiredPtySize(view)
|
||||
|
||||
var p oscommands.Pty
|
||||
var fallbackPipe io.ReadCloser
|
||||
start := func() (tasks.Cmd, io.Reader) {
|
||||
// The pty (and diff renderer) wrap to this width; apply it here, on the
|
||||
// task's goroutine once the previous task has stopped, so it doesn't
|
||||
// race that task's writes (see View.SetContentWidth).
|
||||
view.SetContentWidth(width)
|
||||
|
||||
sp, err := oscommands.StartPty(cmd, cols, rows)
|
||||
if err != nil {
|
||||
gui.c.Log.Error(err)
|
||||
// Fall back to running the command without a pty: the diff renderer is
|
||||
// lost, but the command's output still renders.
|
||||
execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log)
|
||||
fallbackPipe = pipe
|
||||
return execCmd, pipe
|
||||
}
|
||||
p = sp.Pty
|
||||
|
||||
gui.Mutexes.PtyMutex.Lock()
|
||||
gui.viewPtmxMap[view.Name()] = p
|
||||
gui.Mutexes.PtyMutex.Unlock()
|
||||
|
||||
return ptyCmd{cmd: cmd, process: sp.Process, wait: sp.Wait}, p
|
||||
}
|
||||
|
||||
onClose := func() {
|
||||
gui.Mutexes.PtyMutex.Lock()
|
||||
if p != nil {
|
||||
p.Close()
|
||||
}
|
||||
if fallbackPipe != nil {
|
||||
fallbackPipe.Close()
|
||||
fallbackPipe = nil
|
||||
}
|
||||
delete(gui.viewPtmxMap, view.Name())
|
||||
gui.Mutexes.PtyMutex.Unlock()
|
||||
}
|
||||
|
||||
linesToRead := gui.linesToReadFromCmdTask(view)
|
||||
return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// withPtyGitConfig returns args with extra git configuration for commands
|
||||
// that render into a pty. On Windows, such a command is terminated at an
|
||||
// arbitrary point of its execution when its task stops: tearing down the
|
||||
// pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the default
|
||||
// handler, which just calls ExitProcess. git's automatic index refresh
|
||||
// (diff.autoRefreshIndex, on by default) takes index.lock at the end of a
|
||||
// diff against the worktree to write back refreshed stat information —
|
||||
// GIT_OPTIONAL_LOCKS does not cover this lock — and a termination landing
|
||||
// in that window leaves a stale index.lock behind that the next git command
|
||||
// chokes on. So don't let pty-rendered commands refresh the index;
|
||||
// lazygit's foreground `git status` refreshes, which never run in a pty,
|
||||
// keep the stat cache fresh instead.
|
||||
// ptyRender runs the command in a pseudo-terminal. git invokes the stdin filter
|
||||
// named by GIT_PAGER only when it talks to a terminal, and a renderer reads the
|
||||
// width it lays out to off it.
|
||||
//
|
||||
// On Unix a stopped pty child gets SIGTERM, and git's signal handlers remove
|
||||
// its lock files, so the refresh can stay enabled there and keep healing
|
||||
// stale stat info.
|
||||
func withPtyGitConfig(args []string, goos string) []string {
|
||||
if goos != "windows" {
|
||||
return args
|
||||
}
|
||||
// Most pty commands are direct git invocations, but the user-configured
|
||||
// ones can be arbitrary command lines (e.g. a branchLogCmd wrapping git
|
||||
// in `sh -c`), and injecting git flags into those would corrupt them.
|
||||
// Only direct git invocations get the config; that loses nothing, since
|
||||
// the wrapped commands are log commands, which never take the index
|
||||
// lock. (For direct invocations other than worktree diffs the config is
|
||||
// simply a no-op.)
|
||||
base := strings.TrimSuffix(strings.ToLower(filepath.Base(args[0])), ".exe")
|
||||
if base != "git" {
|
||||
return args
|
||||
}
|
||||
result := make([]string, 0, len(args)+2)
|
||||
result = append(result, args[0])
|
||||
result = append(result, "-c", "diff.autoRefreshIndex=false")
|
||||
return append(result, args[1:]...)
|
||||
}
|
||||
// Must be called on the UI thread: it reads the view's dimensions, which the
|
||||
// layout writes.
|
||||
func (gui *Gui) ptyRender(spec renderSpec) (startRender, onCloseRender) {
|
||||
view := spec.view
|
||||
cmd := spec.cmd
|
||||
|
||||
func removeExistingTermEnvVars(env []string) []string {
|
||||
return lo.Filter(env, func(envVar string, _ int) bool {
|
||||
return !isTermEnvVar(envVar)
|
||||
})
|
||||
}
|
||||
// git runs the stdin filter itself, as the pager it is told about here.
|
||||
// Named even when there is none, so that git doesn't reach for the user's
|
||||
// core.pager instead.
|
||||
cmd.Env = append(cmd.Env, "GIT_PAGER="+spec.stdinFilter)
|
||||
|
||||
// Terminals set a variety of different environment variables
|
||||
// to identify themselves to processes. This list should catch the most common among them.
|
||||
func isTermEnvVar(envVar string) bool {
|
||||
return strings.HasPrefix(envVar, "TERM=") ||
|
||||
strings.HasPrefix(envVar, "TERM_PROGRAM=") ||
|
||||
strings.HasPrefix(envVar, "TERM_PROGRAM_VERSION=") ||
|
||||
strings.HasPrefix(envVar, "TERMINAL_EMULATOR=") ||
|
||||
strings.HasPrefix(envVar, "TERMINAL_NAME=") ||
|
||||
strings.HasPrefix(envVar, "TERMINAL_VERSION_")
|
||||
cols, rows := gui.desiredPtySize(view)
|
||||
|
||||
var p oscommands.Pty
|
||||
var fallbackPipe io.ReadCloser
|
||||
start := func() (tasks.Cmd, io.Reader) {
|
||||
// The pty (and diff renderer) wrap to this width; apply it here, on the
|
||||
// task's goroutine once the previous task has stopped, so it doesn't
|
||||
// race that task's writes (see View.SetContentWidth).
|
||||
view.SetContentWidth(spec.width)
|
||||
|
||||
sp, err := oscommands.StartPty(cmd, cols, rows)
|
||||
if err != nil {
|
||||
gui.c.Log.Error(err)
|
||||
// Fall back to running the command without a pty: the diff renderer is
|
||||
// lost, but the command's output still renders.
|
||||
execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log)
|
||||
fallbackPipe = pipe
|
||||
return execCmd, pipe
|
||||
}
|
||||
p = sp.Pty
|
||||
|
||||
gui.Mutexes.PtyMutex.Lock()
|
||||
gui.viewPtmxMap[view.Name()] = p
|
||||
gui.Mutexes.PtyMutex.Unlock()
|
||||
|
||||
return ptyCmd{cmd: cmd, process: sp.Process, wait: sp.Wait}, p
|
||||
}
|
||||
|
||||
onClose := func() {
|
||||
gui.Mutexes.PtyMutex.Lock()
|
||||
if p != nil {
|
||||
p.Close()
|
||||
}
|
||||
if fallbackPipe != nil {
|
||||
fallbackPipe.Close()
|
||||
fallbackPipe = nil
|
||||
}
|
||||
delete(gui.viewPtmxMap, view.Name())
|
||||
gui.Mutexes.PtyMutex.Unlock()
|
||||
}
|
||||
|
||||
return start, onClose
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWithPtyGitConfig(t *testing.T) {
|
||||
args := []string{"git", "-C", "/repo", "diff", "--color=always"}
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"git", "-c", "diff.autoRefreshIndex=false", "-C", "/repo", "diff", "--color=always"},
|
||||
withPtyGitConfig(args, "windows"))
|
||||
|
||||
assert.Equal(t, args, withPtyGitConfig(args, "linux"))
|
||||
assert.Equal(t, args, withPtyGitConfig(args, "darwin"))
|
||||
|
||||
// A user-configured command that wraps git in a shell must not have git
|
||||
// flags injected into it.
|
||||
shellArgs := []string{"sh", "-c", "git log --graph {{branchName}} -- | sed -e s/x/y/"}
|
||||
assert.Equal(t, shellArgs, withPtyGitConfig(shellArgs, "windows"))
|
||||
|
||||
// The guard recognizes git regardless of case and extension.
|
||||
exeArgs := []string{"GIT.EXE", "diff"}
|
||||
assert.Equal(t,
|
||||
[]string{"GIT.EXE", "-c", "diff.autoRefreshIndex=false", "diff"},
|
||||
withPtyGitConfig(exeArgs, "windows"))
|
||||
}
|
||||
@@ -34,6 +34,7 @@ var (
|
||||
|
||||
AttrUnderline = New().SetUnderline()
|
||||
AttrBold = New().SetBold()
|
||||
AttrDim = New().SetDim()
|
||||
|
||||
ColorMap = map[string]struct {
|
||||
Foreground TextStyle
|
||||
|
||||
@@ -4,6 +4,7 @@ import "github.com/gookit/color"
|
||||
|
||||
type Decoration struct {
|
||||
bold bool
|
||||
dim bool
|
||||
underline bool
|
||||
reverse bool
|
||||
strikethrough bool
|
||||
@@ -13,6 +14,10 @@ func (d *Decoration) SetBold() {
|
||||
d.bold = true
|
||||
}
|
||||
|
||||
func (d *Decoration) SetDim() {
|
||||
d.dim = true
|
||||
}
|
||||
|
||||
func (d *Decoration) SetUnderline() {
|
||||
d.underline = true
|
||||
}
|
||||
@@ -26,12 +31,16 @@ func (d *Decoration) SetStrikethrough() {
|
||||
}
|
||||
|
||||
func (d Decoration) ToOpts() color.Opts {
|
||||
opts := make([]color.Color, 0, 3)
|
||||
opts := make([]color.Color, 0, 5)
|
||||
|
||||
if d.bold {
|
||||
opts = append(opts, color.OpBold)
|
||||
}
|
||||
|
||||
if d.dim {
|
||||
opts = append(opts, color.OpFuzzy)
|
||||
}
|
||||
|
||||
if d.underline {
|
||||
opts = append(opts, color.OpUnderscore)
|
||||
}
|
||||
@@ -52,6 +61,10 @@ func (d Decoration) Merge(other Decoration) Decoration {
|
||||
d.bold = true
|
||||
}
|
||||
|
||||
if other.dim {
|
||||
d.dim = true
|
||||
}
|
||||
|
||||
if other.underline {
|
||||
d.underline = true
|
||||
}
|
||||
|
||||
@@ -68,6 +68,25 @@ func TestMerge(t *testing.T) {
|
||||
},
|
||||
"\x1b[1mfoo\x1b[0m",
|
||||
},
|
||||
{
|
||||
"dim attribute",
|
||||
[]TextStyle{AttrDim},
|
||||
TextStyle{
|
||||
decoration: Decoration{dim: true},
|
||||
Style: color.Style{color.OpFuzzy},
|
||||
},
|
||||
"\x1b[2mfoo\x1b[0m",
|
||||
},
|
||||
{
|
||||
"dim attribute and color",
|
||||
[]TextStyle{FgRed.SetDim()},
|
||||
TextStyle{
|
||||
fg: &Color{basic: &fgRed},
|
||||
decoration: Decoration{dim: true},
|
||||
Style: color.Style{fgRed, color.OpFuzzy},
|
||||
},
|
||||
"\x1b[31;2mfoo\x1b[0m",
|
||||
},
|
||||
{
|
||||
"multiple attributes",
|
||||
[]TextStyle{AttrBold, AttrUnderline},
|
||||
|
||||
@@ -63,6 +63,12 @@ func (b TextStyle) SetBold() TextStyle {
|
||||
return b
|
||||
}
|
||||
|
||||
func (b TextStyle) SetDim() TextStyle {
|
||||
b.decoration.SetDim()
|
||||
b.Style = b.deriveStyle()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b TextStyle) SetUnderline() TextStyle {
|
||||
b.decoration.SetUnderline()
|
||||
b.Style = b.deriveStyle()
|
||||
|
||||
+15
-15
@@ -17,11 +17,10 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
cmdStr,
|
||||
).Debug("RunCommand")
|
||||
|
||||
manager := gui.getManager(view)
|
||||
// Mark the view as loading synchronously (before the task's goroutine runs
|
||||
// and before the next layout pass) so the layout doesn't clamp the scroll
|
||||
// position to the not-yet-loaded content.
|
||||
manager.StartLoading()
|
||||
gui.getManager(view).StartLoading()
|
||||
// Hold the scrollbar at the height the view has now (the previous render),
|
||||
// while it still shows that render: once the re-render swaps in its first
|
||||
// partial paint the displayed buffer is briefly short, and we don't want the
|
||||
@@ -29,17 +28,23 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
view.FreezeScrollbarHeight()
|
||||
|
||||
// Snapshot the view width here, on the UI thread, so the task goroutine
|
||||
// doesn't read the view's live dimensions while it streams output. It's
|
||||
// applied inside start() below rather than now, because start() runs once
|
||||
// the previous task has stopped -- applying it here would race that task's
|
||||
// still-running writes (see View.SetContentWidth).
|
||||
contentWidth := view.InnerWidth()
|
||||
// doesn't read the view's live dimensions while it streams output.
|
||||
spec := renderSpec{view: view, cmd: cmd, width: view.InnerWidth()}
|
||||
|
||||
return gui.newTaskForRender(spec, prefix, cmdStr, gui.plainRender)
|
||||
}
|
||||
|
||||
// plainRender runs the command as it is, with its output going straight into
|
||||
// a pipe.
|
||||
func (gui *Gui) plainRender(spec renderSpec) (startRender, onCloseRender) {
|
||||
var r io.ReadCloser
|
||||
start := func() (tasks.Cmd, io.Reader) {
|
||||
view.SetContentWidth(contentWidth)
|
||||
// The view wraps to this width; apply it here, on the task's goroutine
|
||||
// once the previous task has stopped, so it doesn't race that task's
|
||||
// still-running writes (see View.SetContentWidth).
|
||||
spec.view.SetContentWidth(spec.width)
|
||||
|
||||
execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log)
|
||||
execCmd, pipe := startCmdWithPipe(spec.cmd, gui.c.Log)
|
||||
r = pipe
|
||||
return execCmd, pipe
|
||||
}
|
||||
@@ -51,12 +56,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
}
|
||||
}
|
||||
|
||||
linesToRead := gui.linesToReadFromCmdTask(view)
|
||||
if err := manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr); err != nil {
|
||||
gui.c.Log.Error(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return start, onClose
|
||||
}
|
||||
|
||||
// startCmdWithPipe starts cmd with its stdout and stderr going to a single
|
||||
|
||||
@@ -84,17 +84,17 @@ func NewRunCommandTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunCommandTask {
|
||||
return &RunCommandTask{Cmd: cmd, Prefix: prefix}
|
||||
}
|
||||
|
||||
type RunPtyTask struct {
|
||||
type RunDiffRendererTask struct {
|
||||
Cmd *exec.Cmd
|
||||
Prefix string
|
||||
}
|
||||
|
||||
func (t *RunPtyTask) IsUpdateTask() {}
|
||||
func (t *RunDiffRendererTask) IsUpdateTask() {}
|
||||
|
||||
func NewRunPtyTask(cmd *exec.Cmd) *RunPtyTask {
|
||||
return &RunPtyTask{Cmd: cmd}
|
||||
func NewRunDiffRendererTask(cmd *exec.Cmd) *RunDiffRendererTask {
|
||||
return &RunDiffRendererTask{Cmd: cmd}
|
||||
}
|
||||
|
||||
func NewRunPtyTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunPtyTask {
|
||||
return &RunPtyTask{Cmd: cmd, Prefix: prefix}
|
||||
func NewRunDiffRendererTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunDiffRendererTask {
|
||||
return &RunDiffRendererTask{Cmd: cmd, Prefix: prefix}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ func (gui *Gui) configureViewProperties() {
|
||||
(*mapping.viewPtr).SelBgColor = theme.GocuiSelectedLineBgColor
|
||||
(*mapping.viewPtr).SelFgColor = gui.g.SelFgColor
|
||||
(*mapping.viewPtr).InactiveViewSelBgColor = theme.GocuiInactiveViewSelectedLineBgColor
|
||||
(*mapping.viewPtr).SelTextColor = theme.GocuiSelectedLineFgColor
|
||||
}
|
||||
|
||||
gui.Views.MenuFilterFrame.FrameRunes = frameRunesWithTopCorners(frameRunes, teeLeft, teeRight)
|
||||
|
||||
+43
-3
@@ -239,6 +239,14 @@ type TranslationSet struct {
|
||||
ForcePush string
|
||||
ForcePushPrompt string
|
||||
ForcePushDisabled string
|
||||
ForcePushBranchesPrompt string
|
||||
ForcePushBranchesDisabled string
|
||||
BranchesBelowHaveCommitsToPush string
|
||||
PushBranchAndBranchesBelow string
|
||||
PushOnlyCurrentBranch string
|
||||
BranchesBelowHaveChangedOnRemote string
|
||||
PullBranchAndBranchesBelow string
|
||||
PullOnlyCurrentBranch string
|
||||
UpdatesRejected string
|
||||
UpdatesRejectedAndForcePushDisabled string
|
||||
CheckForUpdate string
|
||||
@@ -369,6 +377,9 @@ type TranslationSet struct {
|
||||
FwdNoUpstream string
|
||||
FwdNoLocalUpstream string
|
||||
FwdCommitsToPush string
|
||||
FwdLocalOnlyCommits string
|
||||
FwdUncommittedChanges string
|
||||
FwdBranchRebasingOrBisecting string
|
||||
PullRequestNoUpstream string
|
||||
PullRequestChecksPassing string
|
||||
PullRequestChecksPending string
|
||||
@@ -598,14 +609,23 @@ type TranslationSet struct {
|
||||
LightweightTag string
|
||||
AnnotatedTag string
|
||||
DeleteTagTitle string
|
||||
DeleteTagsTitle string
|
||||
DeleteLocalTag string
|
||||
DeleteLocalTags string
|
||||
DeleteRemoteTag string
|
||||
DeleteRemoteTags string
|
||||
DeleteLocalAndRemoteTag string
|
||||
DeleteLocalAndRemoteTags string
|
||||
SelectRemoteTagUpstream string
|
||||
SelectRemoteTagsUpstream string
|
||||
DeleteRemoteTagPrompt string
|
||||
DeleteRemoteTagsPrompt string
|
||||
DeleteLocalAndRemoteTagPrompt string
|
||||
DeleteLocalAndRemoteTagsPrompt string
|
||||
RemoteTagDeletedMessage string
|
||||
RemoteTagsDeletedMessage string
|
||||
PushTagTitle string
|
||||
PushTagsTitle string
|
||||
PushTag string
|
||||
PushTagTooltip string
|
||||
NewTag string
|
||||
@@ -1222,9 +1242,9 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
Refresh: "Refresh",
|
||||
RefreshTooltip: "Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`.",
|
||||
Push: "Push",
|
||||
PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch.",
|
||||
PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too.",
|
||||
Pull: "Pull",
|
||||
PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch.",
|
||||
PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too.",
|
||||
MergeConflictsTitle: "Merge conflicts",
|
||||
MergeConflictDescription_DD: "Conflict: this file was moved or renamed both in the current and the incoming changes, but to different destinations. I don't know which ones, but they should both show up as conflicts too (marked 'AU' and 'UA', respectively). The most likely resolution is to delete this file, and pick one of the destinations and delete the other.",
|
||||
MergeConflictDescription_AU: "Conflict: this file is the destination of a move or rename in the current changes, but was moved or renamed to a different destination in the incoming changes. That other destination should also show up as a conflict (marked 'UA'), as well as the file that both were renamed from (marked 'DD').",
|
||||
@@ -1400,6 +1420,14 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
ForcePush: "Force push",
|
||||
ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.",
|
||||
ForcePushDisabled: "Your branch has diverged from the remote branch and you've disabled force pushing",
|
||||
ForcePushBranchesPrompt: "The following branches have diverged from their remote branches:\n\n{{.branches}}\n\nPress {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.",
|
||||
ForcePushBranchesDisabled: "Some of these branches have diverged from their remote branches and you've disabled force pushing",
|
||||
BranchesBelowHaveCommitsToPush: "The following branches stacked below '{{.branchName}}' also have commits to push:",
|
||||
PushBranchAndBranchesBelow: "Push all these branches in addition to the current one",
|
||||
PushOnlyCurrentBranch: "Push only '{{.branchName}}'",
|
||||
BranchesBelowHaveChangedOnRemote: "The following branches stacked below '{{.branchName}}' have also changed on the remote:",
|
||||
PullBranchAndBranchesBelow: "Pull all these branches in addition to the current one",
|
||||
PullOnlyCurrentBranch: "Pull only '{{.branchName}}'",
|
||||
UpdatesRejected: "Updates were rejected. Please fetch and examine the remote changes before pushing again.",
|
||||
UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing",
|
||||
CheckForUpdate: "Check for update",
|
||||
@@ -1478,7 +1506,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).",
|
||||
ReturnToFilesPanel: `Return to files panel`,
|
||||
FastForward: `Fast-forward`,
|
||||
FastForwardTooltip: "Fast-forward selected branch from its upstream.",
|
||||
FastForwardTooltip: "Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates).",
|
||||
FastForwarding: "Fast-forwarding",
|
||||
FoundConflictsTitle: "Conflicts!",
|
||||
ViewConflictsMenuItem: "View conflicts",
|
||||
@@ -1537,6 +1565,9 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
FwdNoUpstream: "Cannot fast-forward a branch with no upstream",
|
||||
FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally",
|
||||
FwdCommitsToPush: "Cannot fast-forward a branch with commits to push",
|
||||
FwdLocalOnlyCommits: "Cannot fast-forward '{{.branchName}}' because it has commits which were never on its remote branch",
|
||||
FwdUncommittedChanges: "Cannot fast-forward '{{.branchName}}' because the worktree it is checked out in has uncommitted changes",
|
||||
FwdBranchRebasingOrBisecting: "Cannot fast-forward '{{.branchName}}' because it is being rebased or bisected in worktree {{.worktreeName}}",
|
||||
PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream",
|
||||
PullRequestChecksPassing: "Passing",
|
||||
PullRequestChecksPending: "Pending",
|
||||
@@ -1767,14 +1798,23 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
AnnotatedTag: "Annotated tag",
|
||||
LightweightTag: "Lightweight tag",
|
||||
DeleteTagTitle: "Delete tag '{{.tagName}}'?",
|
||||
DeleteTagsTitle: "Delete selected tags?",
|
||||
DeleteLocalTag: "Delete local tag",
|
||||
DeleteLocalTags: "Delete local tags",
|
||||
DeleteRemoteTag: "Delete remote tag",
|
||||
DeleteRemoteTags: "Delete remote tags",
|
||||
DeleteLocalAndRemoteTag: "Delete local and remote tag",
|
||||
DeleteLocalAndRemoteTags: "Delete local and remote tags",
|
||||
RemoteTagDeletedMessage: "Remote tag deleted",
|
||||
RemoteTagsDeletedMessage: "Remote tags deleted",
|
||||
SelectRemoteTagUpstream: "Remote from which to remove tag '{{.tagName}}':",
|
||||
SelectRemoteTagsUpstream: "Remote from which to remove the selected tags:",
|
||||
DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?",
|
||||
DeleteRemoteTagsPrompt: "Are you sure you want to delete the selected tags from '{{.upstream}}'?",
|
||||
DeleteLocalAndRemoteTagPrompt: "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?",
|
||||
DeleteLocalAndRemoteTagsPrompt: "Are you sure you want to delete the selected tags from both your machine and from '{{.upstream}}'?",
|
||||
PushTagTitle: "Remote to push tag '{{.tagName}}' to:",
|
||||
PushTagsTitle: "Remote to push the selected tags to:",
|
||||
// Using 'push tag' rather than just 'push' to disambiguate from a global push
|
||||
PushTag: "Push tag",
|
||||
PushTagTooltip: "Push the selected tag to a remote. You'll be prompted to select a remote.",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type Git struct {
|
||||
@@ -27,6 +28,19 @@ func (self *Git) RemoteTagDeleted(ref string, tagName string) *Git {
|
||||
})
|
||||
}
|
||||
|
||||
// Asserts the names of all tags on the remote, in alphabetical order
|
||||
func (self *Git) RemoteTagNames(remote string, expectedNames []string) *Git {
|
||||
return self.expect([]string{"git", "ls-remote", "--tags", "--refs", remote}, func(output string) (bool, string) {
|
||||
names := lo.FilterMap(strings.Split(output, "\n"), func(line string, _ int) (string, bool) {
|
||||
_, ref, found := strings.Cut(line, "\t")
|
||||
return strings.TrimPrefix(ref, "refs/tags/"), found
|
||||
})
|
||||
actual := strings.Join(names, "\n")
|
||||
expected := strings.Join(expectedNames, "\n")
|
||||
return actual == expected, fmt.Sprintf("Expected the tags on %s to be\n%s\nbut got\n%s", remote, expected, actual)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *Git) assert(cmdArgs []string, expected string) *Git {
|
||||
self.expect(cmdArgs, func(output string) (bool, string) {
|
||||
return output == expected, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expected, output)
|
||||
|
||||
@@ -11,7 +11,7 @@ var Highlight = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Git.Log.ShowGraph = "always"
|
||||
config.GetUserConfig().Gui.AuthorColors = map[string]string{
|
||||
config.GetUserConfig().Gui.Theme.AuthorColors = map[string]string{
|
||||
"CI": "red",
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import "github.com/jesseduffield/lazygit/pkg/config"
|
||||
|
||||
// Gives us nicer colours when we generate a git repo history with `shell.CreateRepoHistory()`
|
||||
func setGeneratedAuthorColours(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.AuthorColors = map[string]string{
|
||||
config.GetUserConfig().Gui.Theme.AuthorColors = map[string]string{
|
||||
"Fredrica Greenhill": "#fb5aa3",
|
||||
"Oscar Reuenthal": "#86c82f",
|
||||
"Paul Oberstein": "#ffd500",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var RenderThroughAPipe = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "A stdin filter renders the diff when it is fed through a pipe rather than run in a pty",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
// This is how a render works on Windows, where a pty can't carry a
|
||||
// renderer's output faithfully. Ask for it here so that the path is
|
||||
// covered on the platforms the integration tests do run on.
|
||||
ExtraEnvVars: map[string]string{"LAZYGIT_RENDER_WITHOUT_PTY": "1"},
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// Reports the width it was given, then passes the diff through. git
|
||||
// only runs a filter of its own when it talks to a terminal, so the
|
||||
// filter running at all says the pipeline was built here.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Command: `echo "rendered at {{width}} columns"; cat`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\n")
|
||||
shell.Commit("one")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("one").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().
|
||||
// The width reaches the filter on its command line, since with no
|
||||
// terminal it has nowhere to read it from.
|
||||
Content(MatchesRegexp(`rendered at \d+ columns`)).
|
||||
ContainsLines(
|
||||
Equals("diff --git a/file1 b/file1"),
|
||||
Contains("new file mode"),
|
||||
Contains("index "),
|
||||
Equals("--- /dev/null"),
|
||||
Equals("+++ b/file1"),
|
||||
Equals("@@ -0,0 +1 @@"),
|
||||
Equals("+one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var StatUsesTheViewWidth = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The diffstat of a commit is laid out to the width of the view showing it",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// git's own diff, so that the render runs as a plain command rather
|
||||
// than through a renderer.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Type: "rawGit"},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
// Enough added lines that git scales the graph to the width it has,
|
||||
// rather than drawing one mark per line.
|
||||
lines := make([]string, 200)
|
||||
for i := range lines {
|
||||
lines[i] = "line " + strconv.Itoa(i)
|
||||
}
|
||||
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
|
||||
shell.Commit("add file1")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("add file1").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().
|
||||
Content(MatchesRegexp(`(?m)^ file1 \| 200 \+{80,}$`))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBehindBranch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch that is behind its upstream branch, without checking it out",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("one")
|
||||
shell.NewBranch("feature")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("feature", "origin/feature")
|
||||
|
||||
// remove the two commits so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~2")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ✓").IsSelected(),
|
||||
).
|
||||
PressEnter()
|
||||
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBehindBranchCheckedOut = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward the checked out branch when it is behind its upstream branch",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "content")
|
||||
shell.Commit("one")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("master", "origin/master")
|
||||
|
||||
// remove the two commits so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~2")
|
||||
|
||||
// a change that the fast-forward doesn't get in the way of
|
||||
shell.UpdateFile("file", "changed")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master ↓2").IsSelected(),
|
||||
).
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master ✓").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
// Moving the branch forward doesn't touch the change
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Contains("file"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBehindBranchWithoutReflogs = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch that is behind its upstream branch in a repo that keeps no reflogs",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
// This is the default in a bare repository, so it is what people with a
|
||||
// bare repo and linked worktrees work with
|
||||
shell.SetConfig("core.logAllRefUpdates", "false")
|
||||
|
||||
shell.EmptyCommit("one")
|
||||
shell.NewBranch("feature")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("feature", "origin/feature")
|
||||
|
||||
// remove the two commits so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~2")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ✓").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBranchBeingRebasedInWorktree = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a branch that is being rebased in another worktree",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "content")
|
||||
shell.Commit("one")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("master", "origin/master")
|
||||
|
||||
// remove a commit so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~1")
|
||||
|
||||
shell.NewBranch("other")
|
||||
shell.AddWorktreeCheckout("master", "../linked-worktree")
|
||||
|
||||
// the failing exec stops the rebase after picking "two", with HEAD
|
||||
// detached from master
|
||||
shell.RunCommandExpectError([]string{"git", "-C", "../linked-worktree", "rebase", "--exec", "false", "HEAD~1"})
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("other").IsSelected(),
|
||||
Contains("master (worktree linked-worktree) ↓1"),
|
||||
).
|
||||
NavigateToLine(Contains("master")).
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Equals("Cannot fast-forward 'master' because it is being rebased or bisected in worktree linked-worktree")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Worktrees().
|
||||
Focus().
|
||||
NavigateToLine(Contains("linked-worktree")).
|
||||
Press(keys.Universal.Select)
|
||||
|
||||
t.Views().Information().Content(Contains("Rebasing"))
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("─── Pending rebase todos"),
|
||||
Contains("─── Commits"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBranchesInWorktreesWithOneFailing = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward two branches checked out in other worktrees when the fast-forward fails in the first one",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "content")
|
||||
shell.Commit("one")
|
||||
shell.NewBranch("branch-a")
|
||||
shell.CreateFileAndAdd("a-file", "a")
|
||||
shell.Commit("a")
|
||||
shell.Checkout("master")
|
||||
shell.NewBranch("branch-b")
|
||||
shell.CreateFileAndAdd("b-file", "b")
|
||||
shell.Commit("b")
|
||||
shell.Checkout("master")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("branch-a", "origin/branch-a")
|
||||
shell.SetBranchUpstream("branch-b", "origin/branch-b")
|
||||
|
||||
// remove a commit from each branch so that there is something to
|
||||
// fast-forward to
|
||||
shell.RunCommand([]string{"git", "branch", "-f", "branch-a", "branch-a~1"})
|
||||
shell.RunCommand([]string{"git", "branch", "-f", "branch-b", "branch-b~1"})
|
||||
|
||||
shell.AddWorktreeCheckout("branch-a", "../worktree-a")
|
||||
shell.AddWorktreeCheckout("branch-b", "../worktree-b")
|
||||
|
||||
// an untracked file that the fast-forward in worktree-a would overwrite
|
||||
shell.CreateFile("../worktree-a/a-file", "mine")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("branch-a (worktree worktree-a) ↓1"),
|
||||
Contains("branch-b (worktree worktree-b) ↓1"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("would be overwritten by merge")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Branches().
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("branch-a (worktree worktree-a) ↓1").IsSelected(),
|
||||
Contains("branch-b (worktree worktree-b) ✓").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch that has diverged from its upstream because the upstream branch was rewritten",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2↑2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ✓").IsSelected(),
|
||||
).
|
||||
PressEnter()
|
||||
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("three-rewritten"),
|
||||
Contains("two-rewritten"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchCheckedOut = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward the checked out branch after its upstream branch was rewritten",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("feature")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
Contains("master"),
|
||||
).
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("feature ✓").IsSelected(),
|
||||
Contains("master"),
|
||||
)
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("three-rewritten"),
|
||||
Contains("two-rewritten"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchWithLocalCommits = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a branch that has a commit of its own on top of a rewritten upstream branch",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("feature")
|
||||
shell.EmptyCommit("mine")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2↑3"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().Title(Equals("Error")).
|
||||
Content(Contains("Cannot fast-forward 'feature' because it has commits")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Branches().
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ↓2↑3").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchWithStaleSubmodule = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch with a rewritten upstream branch while its worktree has a submodule checked out at a different commit than the one recorded",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("initial")
|
||||
shell.CloneIntoSubmodule("sub", "sub")
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("feature")
|
||||
shell.RunCommand([]string{"git", "-C", "sub", "commit", "--allow-empty", "-m", "newer"})
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Contains("sub"),
|
||||
)
|
||||
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
Contains("master"),
|
||||
).
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("feature ✓").IsSelected(),
|
||||
Contains("master"),
|
||||
)
|
||||
|
||||
// The submodule was left alone
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Contains("sub"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchWithUncommittedChanges = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a branch with a rewritten upstream branch while its worktree has uncommitted changes",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "content")
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("feature")
|
||||
shell.UpdateFile("file", "changed")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
Contains("master"),
|
||||
).
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().Title(Equals("Error")).
|
||||
Content(Contains("Cannot fast-forward 'feature' because the worktree")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Branches().
|
||||
Lines(
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
Contains("master"),
|
||||
)
|
||||
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Contains("file"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchWithoutReflogs = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a branch with a rewritten upstream branch in a repo that keeps no reflogs",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
// Without reflogs there is no record of the values the remote-tracking
|
||||
// branch had before, so lazygit can't tell whether the commits that
|
||||
// the branch is ahead by were ever on the remote branch
|
||||
shell.SetConfig("core.logAllRefUpdates", "false")
|
||||
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2↑2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().Title(Equals("Error")).
|
||||
Content(Contains("Cannot fast-forward 'feature' because it has commits")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Branches().
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenStackOfBranches = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a whole stack of branches whose upstream branches were rewritten",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createStackRewrittenOnTheRemote(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("branch1 ↓1↑1"),
|
||||
Contains("branch2 ↓2↑2"),
|
||||
Contains("branch3 ↓3↑3"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
SelectNextItem().
|
||||
SelectNextItem().
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("branch1 ↓1↑1").IsSelected(),
|
||||
Contains("branch2 ↓2↑2").IsSelected(),
|
||||
Contains("branch3 ↓3↑3").IsSelected(),
|
||||
).
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("branch1 ✓").IsSelected(),
|
||||
Contains("branch2 ✓").IsSelected(),
|
||||
Contains("branch3 ✓").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenStackOfBranchesWithLocalCommits = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a stack of branches of which one has a commit of its own",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createStackRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("branch2")
|
||||
shell.EmptyCommit("mine")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("branch1 ↓1↑1"),
|
||||
Contains("branch2 ↓2↑3"),
|
||||
Contains("branch3 ↓3↑3"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
SelectNextItem().
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().Title(Equals("Error")).
|
||||
Content(Contains("Cannot fast-forward 'branch2' because it has commits")).
|
||||
Confirm()
|
||||
|
||||
// None of them was touched, not even the ones we could have forwarded
|
||||
t.Views().Branches().
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("branch1 ↓1↑1").IsSelected(),
|
||||
Contains("branch2 ↓2↑3").IsSelected(),
|
||||
Contains("branch3 ↓3↑3").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user