Compare commits

...

31 Commits

Author SHA1 Message Date
Jesse Duffield
eaba9dd62d Land in the same panel when switching to a worktree 2023-07-16 14:37:49 +10:00
Jesse Duffield
3cb13f14bf Move current worktree to top of list 2023-07-16 14:23:31 +10:00
Jesse Duffield
5d52852df3 Prompt to switch to worktree when branch is checked out by other worktree 2023-07-16 14:14:09 +10:00
Jesse Duffield
7d4432c4b5 Use git lingo 2023-07-16 13:53:59 +10:00
Jesse Duffield
a1235fa468 Improve name handling 2023-07-16 13:43:20 +10:00
Jesse Duffield
8b90811c65 Use sentence case 2023-07-16 12:23:35 +10:00
Jesse Duffield
2dd2b9f5e3 Refactor 2023-07-16 12:21:43 +10:00
Jesse Duffield
e7484808e5 Update worktree model 2023-07-16 11:36:50 +10:00
Jesse Duffield
3245350bab Alert when attempting to enter the current worktree 2023-07-16 11:20:22 +10:00
Jesse Duffield
6e51dd1c85 Remove comment 2023-07-16 11:16:10 +10:00
Joel Baranick
77db982774 Address PR comments 2023-07-16 11:04:39 +10:00
Joel Baranick
e16f56e492 Basic support for adding a worktree 2023-07-16 10:55:56 +10:00
Joel Baranick
3055944b5d Put all worktree i18n strings together
Use tabwriter to align worktree panel contents
2023-07-16 10:53:00 +10:00
Joel Baranick
6194b17ebb Improve worktree panel 2023-07-16 10:50:25 +10:00
Joel Baranick
15ffb34474 Style missing worktree as red and display better error when trying to switch to them
Use a broken link icon for missing worktrees
2023-07-16 10:48:22 +10:00
Joel Baranick
32409dbb2f Hide worktrees in the worktree panel if they point at a non-existing filesystem location.
Remove unneeded check when filtering out branches from non-current worktrees from the branch panel.
Add link icon for linked worktrees
2023-07-16 10:32:36 +10:00
Joel Baranick
4ec960f07d Update status to differentiate the main vs linked worktrees 2023-07-16 10:29:08 +10:00
Joel Baranick
a7bdc6be01 Support for deleting a worktree 2023-07-16 10:23:17 +10:00
Joel Baranick
271f894106 Initial addition of support for worktrees 2023-07-16 10:20:36 +10:00
Stefan Haller
7e9f669421 Show all tags in commits panel (#2776) 2023-07-15 13:11:44 +02:00
Stefan Haller
6b769fb138 Fix populating the Commit.Tags field
We now store all tags in this field if there are several.
2023-07-15 13:07:02 +02:00
Stefan Haller
cc835a813e Extend commit_loader test to show how the Tags field is populated
It shows that right now, we take only the first tag if there are multiple.
Judging from how the code is written, I'm not sure this was intentional.
2023-07-15 13:07:02 +02:00
Stefan Haller
6103a4d13c Fix potentially wrong help text in commit message panel (#2777) 2023-07-15 13:06:16 +02:00
Stefan Haller
69575dd4f3 Fix potentially wrong help text in commit message panel
It said "Press tab to toggle focus", which is wrong for people who remapped
their togglePanel key binding to something else. Print the actual key binding
instead.
2023-07-15 13:03:13 +02:00
README-bot
bfcff3222c Updated README.md 2023-07-15 06:44:44 +00:00
Jesse Duffield
5adea789d0 Add test for cmd obj cloning (#2780) 2023-07-15 16:44:30 +10:00
Jesse Duffield
78bbdca757 Add test for cmd obj cloning 2023-07-15 11:05:43 +10:00
Stefan Haller
5cb82a49f8 config: rely on .gitconfig for verbose commit messages (#2664)
As discussed in https://github.com/jesseduffield/lazygit/pull/2599, it
makes more sense to have the user specify whether they want verbose
commits from their own git config, rather than lazygit config.

This means that we can remove all the code (including test coverage)
associated with the custom verbose flag, and lazygit will just inherit
the .gitconfig settings automatically.

---

Tested visually locally, as well as running the tests that all pass.
2023-07-14 08:05:22 +02:00
Scott Callaway
9617737352 config: rely on .gitconfig for verbose commit messages
As discussed in https://github.com/jesseduffield/lazygit/pull/2599, it
makes more sense to have the user specify whether they want verbose
commits from their own git config, rather than lazygit config.

This means that we can remove all the code (including test coverage)
associated with the custom verbose flag, and lazygit will just inherit
the .gitconfig settings automatically.
2023-07-14 07:56:09 +02:00
Jesse Duffield
a251f6ad6c Allow checking for merge conflicts after running a custom command (#2773) 2023-07-13 18:43:25 +10:00
Jesse Duffield
b61ca21a84 Allow checking for merge conflicts after running a custom command
We have a use-case to rebind 'm' to the merge action in the branches panel. There's three ways to handle this:
1) For all global keybindings, define a per-panel key that invokes it
2) Give a name to all controller actions and allow them to be invoked in custom commands
3) Allow checking for merge conflicts after running a custom command so that users can add their own 'git merge' custom command
that matches the in-built action

Option 1 is hairy, Option 2 though good for users introduces new backwards compatibility issues that I don't want to do
right now, and option 3 is trivially easy to implement so that's what I'm doing.

I've put this under an 'after' key so that we can add more things later. I'm imagining other things like being able to
move the cursor to a newly added item etc.

I considered always running this hook by default but I'd rather not: it's matching on the output text and I'd rather something
like that be explicitly opted-into to avoid cases where we erroneously believe that there are conflicts.
2023-07-13 18:40:34 +10:00
46 changed files with 1105 additions and 135 deletions

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ test/results/**
oryxBuildBinary
__debug_bin
.worktrees

File diff suppressed because one or more lines are too long

View File

@@ -77,7 +77,6 @@ git:
useConfig: false
commit:
signOff: false
verbose: default # one of 'default' | 'always' | 'never'
merging:
# only applicable to unix users
manualCommit: false

View File

@@ -59,6 +59,12 @@ For a given custom command, here are the allowed fields:
| description | Label for the custom command when displayed in the keybindings menu | no |
| stream | Whether you want to stream the command's output to the Command Log panel | no |
| showOutput | Whether you want to show the command's output in a popup within Lazygit | no |
| after | Actions to take after the command has completed | no |
Here are the options for the `after` key:
| _field_ | _description_ | required |
|-----------------|----------------------|-|
| checkForConflicts | true/false. If true, check for merge conflicts | no |
## Contexts

View File

@@ -116,6 +116,7 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
"stash": tr.StashTitle,
"suggestions": tr.SuggestionsCheatsheetTitle,
"extras": tr.ExtrasTitle,
"worktrees": tr.WorktreesTitle,
}
title, ok := contextTitleMap[str]

View File

@@ -37,6 +37,7 @@ type GitCommand struct {
Tag *git_commands.TagCommands
WorkingTree *git_commands.WorkingTreeCommands
Bisect *git_commands.BisectCommands
Worktree *git_commands.WorktreeCommands
Loaders Loaders
}
@@ -50,6 +51,7 @@ type Loaders struct {
RemoteLoader *git_commands.RemoteLoader
StashLoader *git_commands.StashLoader
TagLoader *git_commands.TagLoader
Worktrees *git_commands.WorktreeLoader
}
func NewGitCommand(
@@ -127,12 +129,14 @@ func NewGitCommandAux(
})
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon)
worktreeCommands := git_commands.NewWorktreeCommands(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, cmd, branchCommands.CurrentBranchInfo, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
commitLoader := git_commands.NewCommitLoader(cmn, cmd, dotGitDir, statusCommands.RebaseMode, gitCommon)
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
remoteLoader := git_commands.NewRemoteLoader(cmn, cmd, repo.Remotes)
worktreeLoader := git_commands.NewWorktreeLoader(cmn, cmd)
stashLoader := git_commands.NewStashLoader(cmn, cmd)
tagLoader := git_commands.NewTagLoader(cmn, cmd)
@@ -154,6 +158,7 @@ func NewGitCommandAux(
Tag: tagCommands,
Bisect: bisectCommands,
WorkingTree: workingTreeCommands,
Worktree: worktreeCommands,
Loaders: Loaders{
BranchLoader: branchLoader,
CommitFileLoader: commitFileLoader,
@@ -161,6 +166,7 @@ func NewGitCommandAux(
FileLoader: fileLoader,
ReflogCommitLoader: reflogCommitLoader,
RemoteLoader: remoteLoader,
Worktrees: worktreeLoader,
StashLoader: stashLoader,
TagLoader: tagLoader,
},

View File

@@ -2,6 +2,7 @@ package git_commands
import (
"fmt"
"os"
"regexp"
"strings"
@@ -117,6 +118,11 @@ outer:
}
func (self *BranchLoader) obtainBranches() []*models.Branch {
currentDir, err := os.Getwd()
if err != nil {
panic(err)
}
output, err := self.getRawBranches()
if err != nil {
panic(err)
@@ -138,7 +144,7 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
return nil, false
}
return obtainBranch(split), true
return obtainBranch(split, currentDir), true
})
}
@@ -166,28 +172,32 @@ var branchFields = []string{
"upstream:track",
"subject",
fmt.Sprintf("objectname:short=%d", utils.COMMIT_HASH_SHORT_SIZE),
"worktreepath",
}
// Obtain branch information from parsed line output of getRawBranches()
func obtainBranch(split []string) *models.Branch {
func obtainBranch(split []string, currentDir string) *models.Branch {
headMarker := split[0]
fullName := split[1]
upstreamName := split[2]
track := split[3]
subject := split[4]
commitHash := split[5]
branchDir := split[6]
checkedOutByOtherWorktree := len(branchDir) > 0 && branchDir != currentDir
name := strings.TrimPrefix(fullName, "heads/")
pushables, pullables, gone := parseUpstreamInfo(upstreamName, track)
return &models.Branch{
Name: name,
Pushables: pushables,
Pullables: pullables,
UpstreamGone: gone,
Head: headMarker == "*",
Subject: subject,
CommitHash: commitHash,
Name: name,
Pushables: pushables,
Pullables: pullables,
UpstreamGone: gone,
Head: headMarker == "*",
Subject: subject,
CommitHash: commitHash,
CheckedOutByOtherWorktree: checkedOutByOtherWorktree,
}
}

View File

@@ -81,7 +81,7 @@ func TestObtainBranch(t *testing.T) {
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
branch := obtainBranch(s.input)
branch := obtainBranch(s.input, "current-dir")
assert.EqualValues(t, s.expectedBranch, branch)
})
}

View File

@@ -95,7 +95,6 @@ func (self *CommitCommands) commitMessageArgs(message string) []string {
func (self *CommitCommands) CommitEditorCmdObj() oscommands.ICmdObj {
cmdArgs := NewGitCmd("commit").
ArgIf(self.signoffFlag() != "", self.signoffFlag()).
ArgIf(self.verboseFlag() != "", self.verboseFlag()).
ToArgv()
return self.cmd.New(cmdArgs)
@@ -109,17 +108,6 @@ func (self *CommitCommands) signoffFlag() string {
}
}
func (self *CommitCommands) verboseFlag() string {
switch self.config.UserConfig.Git.Commit.Verbose {
case "always":
return "--verbose"
case "never":
return "--no-verbose"
default:
return ""
}
}
// Get the subject of the HEAD commit
func (self *CommitCommands) GetHeadCommitMessage() (string, error) {
cmdArgs := NewGitCmd("log").Arg("-1", "--pretty=%s").ToArgv()

View File

@@ -162,11 +162,17 @@ func (self *CommitLoader) extractCommitFromLine(line string) *models.Commit {
tags := []string{}
if extraInfo != "" {
re := regexp.MustCompile(`tag: ([^,\)]+)`)
tagMatch := re.FindStringSubmatch(extraInfo)
if len(tagMatch) > 1 {
tags = append(tags, tagMatch[1])
extraInfoFields := strings.Split(extraInfo, ",")
for _, extraInfoField := range extraInfoFields {
extraInfoField = strings.TrimSpace(extraInfoField)
re := regexp.MustCompile(`tag: (.+)`)
tagMatch := re.FindStringSubmatch(extraInfoField)
if len(tagMatch) > 1 {
tags = append(tags, tagMatch[1])
}
}
extraInfo = "(" + extraInfo + ")"
}
unitTimestampInt, _ := strconv.Atoi(unixTimestamp)
@@ -585,4 +591,4 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
return self.cmd.New(cmdArgs).DontLog()
}
const prettyFormat = `--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s`
const prettyFormat = `--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s`

View File

@@ -14,16 +14,16 @@ import (
"github.com/stretchr/testify/assert"
)
var commitsOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com| (HEAD -> better-tests)|b21997d6b4cbdf84b149|better typing for rebase mode
b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164|1640824515|Jesse Duffield|jessedduffield@gmail.com| (origin/better-tests)|e94e8fc5b6fab4cb755f|fix logging
e94e8fc5b6fab4cb755f29f1bdb3ee5e001df35c|1640823749|Jesse Duffield|jessedduffield@gmail.com||d8084cd558925eb7c9c3|refactor
var commitsOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com|HEAD -> better-tests|b21997d6b4cbdf84b149|better typing for rebase mode
b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164|1640824515|Jesse Duffield|jessedduffield@gmail.com|origin/better-tests|e94e8fc5b6fab4cb755f|fix logging
e94e8fc5b6fab4cb755f29f1bdb3ee5e001df35c|1640823749|Jesse Duffield|jessedduffield@gmail.com|tag: 123, tag: 456|d8084cd558925eb7c9c3|refactor
d8084cd558925eb7c9c38afeed5725c21653ab90|1640821426|Jesse Duffield|jessedduffield@gmail.com||65f910ebd85283b5cce9|WIP
65f910ebd85283b5cce9bf67d03d3f1a9ea3813a|1640821275|Jesse Duffield|jessedduffield@gmail.com||26c07b1ab33860a1a759|WIP
26c07b1ab33860a1a7591a0638f9925ccf497ffa|1640750752|Jesse Duffield|jessedduffield@gmail.com||3d4470a6c072208722e5|WIP
3d4470a6c072208722e5ae9a54bcb9634959a1c5|1640748818|Jesse Duffield|jessedduffield@gmail.com||053a66a7be3da43aacdc|WIP
053a66a7be3da43aacdc7aa78e1fe757b82c4dd2|1640739815|Jesse Duffield|jessedduffield@gmail.com||985fe482e806b172aea4|refactoring the config struct`, "|", "\x00", -1)
var singleCommitOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com| (HEAD -> better-tests)|b21997d6b4cbdf84b149|better typing for rebase mode`, "|", "\x00", -1)
var singleCommitOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com|HEAD -> better-tests|b21997d6b4cbdf84b149|better typing for rebase mode`, "|", "\x00", -1)
func TestGetCommits(t *testing.T) {
type scenario struct {
@@ -45,7 +45,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
expectedCommits: []*models.Commit{},
expectedError: nil,
@@ -57,7 +57,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "refs/heads/mybranch", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "refs/heads/mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "refs/heads/mybranch", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
ExpectGitArgs([]string{"log", "refs/heads/mybranch", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
expectedCommits: []*models.Commit{},
expectedError: nil,
@@ -72,7 +72,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, commitsOutput, nil).
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, commitsOutput, nil).
// here it's testing which of the configured main branches have an upstream
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil). // this one does
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")). // this one doesn't, so it checks origin instead
@@ -117,8 +117,8 @@ func TestGetCommits(t *testing.T) {
Name: "refactor",
Status: models.StatusPushed,
Action: models.ActionNone,
Tags: []string{},
ExtraInfo: "",
Tags: []string{"123", "456"},
ExtraInfo: "(tag: 123, tag: 456)",
AuthorName: "Jesse Duffield",
AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640823749,
@@ -209,7 +209,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
// here it's testing which of the configured main branches exist; neither does
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "", errors.New("error")).
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/master"}, "", errors.New("error")).
@@ -246,7 +246,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
// here it's testing which of the configured main branches exist
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil).
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")).
@@ -282,7 +282,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
expectedCommits: []*models.Commit{},
expectedError: nil,
@@ -294,7 +294,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", FilterPath: "src"},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--follow", "--no-show-signature", "--", "src"}, "", nil),
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--follow", "--no-show-signature", "--", "src"}, "", nil),
expectedCommits: []*models.Commit{},
expectedError: nil,

View File

@@ -114,7 +114,6 @@ func TestCommitCommitEditorCmdObj(t *testing.T) {
type scenario struct {
testName string
configSignoff bool
configVerbose string
expected []string
}
@@ -122,33 +121,13 @@ func TestCommitCommitEditorCmdObj(t *testing.T) {
{
testName: "Commit using editor",
configSignoff: false,
configVerbose: "default",
expected: []string{"commit"},
},
{
testName: "Commit with --no-verbose flag",
configSignoff: false,
configVerbose: "never",
expected: []string{"commit", "--no-verbose"},
},
{
testName: "Commit with --verbose flag",
configSignoff: false,
configVerbose: "always",
expected: []string{"commit", "--verbose"},
},
{
testName: "Commit with --signoff",
configSignoff: true,
configVerbose: "default",
expected: []string{"commit", "--signoff"},
},
{
testName: "Commit with --signoff and --no-verbose",
configSignoff: true,
configVerbose: "never",
expected: []string{"commit", "--signoff", "--no-verbose"},
},
}
for _, s := range scenarios {
@@ -156,7 +135,6 @@ func TestCommitCommitEditorCmdObj(t *testing.T) {
t.Run(s.testName, func(t *testing.T) {
userConfig := config.GetDefaultConfig()
userConfig.Git.Commit.SignOff = s.configSignoff
userConfig.Git.Commit.Verbose = s.configVerbose
runner := oscommands.NewFakeRunner(t).ExpectGitArgs(s.expected, "", nil)
instance := buildCommitCommands(commonDeps{userConfig: userConfig, runner: runner})

View File

@@ -0,0 +1,62 @@
package git_commands
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
type WorktreeCommands struct {
*GitCommon
}
func NewWorktreeCommands(gitCommon *GitCommon) *WorktreeCommands {
return &WorktreeCommands{
GitCommon: gitCommon,
}
}
func (self *WorktreeCommands) New(worktreePath string, committish string) error {
cmdArgs := NewGitCmd("worktree").Arg("add", worktreePath, committish).ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *WorktreeCommands) Delete(worktreePath string, force bool) error {
cmdArgs := NewGitCmd("worktree").Arg("remove").ArgIf(force, "-f").Arg(worktreePath).ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *WorktreeCommands) IsCurrentWorktree(w *models.Worktree) bool {
return IsCurrentWorktree(w)
}
func IsCurrentWorktree(w *models.Worktree) bool {
pwd, err := os.Getwd()
if err != nil {
log.Fatalln(err.Error())
}
return EqualPath(pwd, w.Path)
}
func (self *WorktreeCommands) IsWorktreePathMissing(w *models.Worktree) bool {
if _, err := os.Stat(w.Path); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return true
}
log.Fatalln(fmt.Errorf("failed to check if worktree path `%s` exists\n%w", w.Path, err).Error())
}
return false
}
// checks if two paths are equal
// TODO: support relative paths
func EqualPath(a string, b string) bool {
return a == b
}

View File

@@ -0,0 +1,178 @@
package git_commands
import (
"os"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/samber/lo"
)
type WorktreeLoader struct {
*common.Common
cmd oscommands.ICmdObjBuilder
}
func NewWorktreeLoader(
common *common.Common,
cmd oscommands.ICmdObjBuilder,
) *WorktreeLoader {
return &WorktreeLoader{
Common: common,
cmd: cmd,
}
}
func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain", "-z").ToArgv()
worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
if err != nil {
return nil, err
}
splitLines := strings.Split(worktreesOutput, "\x00")
var worktrees []*models.Worktree
var current *models.Worktree
for _, splitLine := range splitLines {
if len(splitLine) == 0 && current != nil {
worktrees = append(worktrees, current)
current = nil
continue
}
if strings.HasPrefix(splitLine, "worktree ") {
path := strings.SplitN(splitLine, " ", 2)[1]
current = &models.Worktree{
IsMain: len(worktrees) == 0,
Path: path,
}
} else if strings.HasPrefix(splitLine, "branch ") {
branch := strings.SplitN(splitLine, " ", 2)[1]
current.Branch = strings.TrimPrefix(branch, "refs/heads/")
}
}
names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string {
return worktree.Path
}))
for index, worktree := range worktrees {
worktree.NameField = names[index]
}
pwd, err := os.Getwd()
if err != nil {
return nil, err
}
// move current worktree to the top
for i, worktree := range worktrees {
if EqualPath(worktree.Path, pwd) {
worktrees = append(worktrees[:i], worktrees[i+1:]...)
worktrees = append([]*models.Worktree{worktree}, worktrees...)
break
}
}
return worktrees, nil
}
type pathWithIndexT struct {
path string
index int
}
type nameWithIndexT struct {
name string
index int
}
func getUniqueNamesFromPaths(paths []string) []string {
pathsWithIndex := lo.Map(paths, func(path string, index int) pathWithIndexT {
return pathWithIndexT{path, index}
})
namesWithIndex := getUniqueNamesFromPathsAux(pathsWithIndex, 0)
// now sort based on index
result := make([]string, len(namesWithIndex))
for _, nameWithIndex := range namesWithIndex {
result[nameWithIndex.index] = nameWithIndex.name
}
return result
}
func getUniqueNamesFromPathsAux(paths []pathWithIndexT, depth int) []nameWithIndexT {
// If we have no paths, return an empty array
if len(paths) == 0 {
return []nameWithIndexT{}
}
// If we have only one path, return the last segment of the path
if len(paths) == 1 {
path := paths[0]
return []nameWithIndexT{{index: path.index, name: sliceAtDepth(path.path, depth)}}
}
// group the paths by their value at the specified depth
groups := make(map[string][]pathWithIndexT)
for _, path := range paths {
value := valueAtDepth(path.path, depth)
groups[value] = append(groups[value], path)
}
result := []nameWithIndexT{}
for _, group := range groups {
if len(group) == 1 {
path := group[0]
result = append(result, nameWithIndexT{index: path.index, name: sliceAtDepth(path.path, depth)})
} else {
result = append(result, getUniqueNamesFromPathsAux(group, depth+1)...)
}
}
return result
}
// if the path is /a/b/c/d, and the depth is 0, the value is 'd'. If the depth is 1, the value is 'c', etc
func valueAtDepth(path string, depth int) string {
path = strings.TrimPrefix(path, "/")
path = strings.TrimSuffix(path, "/")
// Split the path into segments
segments := strings.Split(path, "/")
// Get the length of segments
length := len(segments)
// If the depth is greater than the length of segments, return an empty string
if depth >= length {
return ""
}
// Return the segment at the specified depth from the end of the path
return segments[length-1-depth]
}
// if the path is /a/b/c/d, and the depth is 0, the value is 'd'. If the depth is 1, the value is 'b/c', etc
func sliceAtDepth(path string, depth int) string {
path = strings.TrimPrefix(path, "/")
path = strings.TrimSuffix(path, "/")
// Split the path into segments
segments := strings.Split(path, "/")
// Get the length of segments
length := len(segments)
// If the depth is greater than or equal to the length of segments, return an empty string
if depth >= length {
return ""
}
// Join the segments from the specified depth till end of the path
return strings.Join(segments[length-1-depth:], "/")
}

View File

@@ -0,0 +1,52 @@
package git_commands
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetUniqueNamesFromPaths(t *testing.T) {
for _, scenario := range []struct {
input []string
expected []string
}{
{
input: []string{},
expected: []string{},
},
{
input: []string{
"/my/path/feature/one",
},
expected: []string{
"one",
},
},
{
input: []string{
"/my/path/feature/one/",
},
expected: []string{
"one",
},
},
{
input: []string{
"/a/b/c/d",
"/a/b/c/e",
"/a/b/f/d",
"/a/e/c/d",
},
expected: []string{
"b/c/d",
"e",
"f/d",
"e/c/d",
},
},
} {
actual := getUniqueNamesFromPaths(scenario.input)
assert.EqualValues(t, scenario.expected, actual)
}
}

View File

@@ -26,6 +26,8 @@ type Branch struct {
Subject string
// commit hash
CommitHash string
CheckedOutByOtherWorktree bool
}
func (b *Branch) FullRefName() string {

View File

@@ -0,0 +1,31 @@
package models
// A git worktree
type Worktree struct {
// if false, this is a linked worktree
IsMain bool
Path string
Branch string
// based on the path, but uniquified
NameField string
}
func (w *Worktree) RefName() string {
return w.Name()
}
func (w *Worktree) ID() string {
return w.Path
}
func (w *Worktree) Description() string {
return w.RefName()
}
func (w *Worktree) Name() string {
return w.NameField
}
func (w *Worktree) Main() bool {
return w.IsMain
}

View File

@@ -1,7 +1,10 @@
package oscommands
import (
"os/exec"
"testing"
"github.com/jesseduffield/gocui"
)
func TestCmdObjToString(t *testing.T) {
@@ -31,3 +34,20 @@ func TestCmdObjToString(t *testing.T) {
}
}
}
func TestClone(t *testing.T) {
task := gocui.NewFakeTask()
cmdObj := &CmdObj{task: task, cmd: &exec.Cmd{}}
clone := cmdObj.Clone()
if clone == cmdObj {
t.Errorf("Clone should not return the same object")
}
if clone.GetTask() == nil {
t.Errorf("Clone task should not be nil")
}
if clone.GetTask() != task {
t.Errorf("Clone should have the same task")
}
}

View File

@@ -103,8 +103,7 @@ type PagingConfig struct {
}
type CommitConfig struct {
SignOff bool `yaml:"signOff"`
Verbose string `yaml:"verbose"`
SignOff bool `yaml:"signOff"`
}
type MergingConfig struct {
@@ -349,16 +348,21 @@ type OSConfig struct {
OpenLinkCommand string `yaml:"openLinkCommand,omitempty"`
}
type CustomCommandAfterHook struct {
CheckForConflicts bool `yaml:"checkForConflicts"`
}
type CustomCommand struct {
Key string `yaml:"key"`
Context string `yaml:"context"`
Command string `yaml:"command"`
Subprocess bool `yaml:"subprocess"`
Prompts []CustomCommandPrompt `yaml:"prompts"`
LoadingText string `yaml:"loadingText"`
Description string `yaml:"description"`
Stream bool `yaml:"stream"`
ShowOutput bool `yaml:"showOutput"`
Key string `yaml:"key"`
Context string `yaml:"context"`
Command string `yaml:"command"`
Subprocess bool `yaml:"subprocess"`
Prompts []CustomCommandPrompt `yaml:"prompts"`
LoadingText string `yaml:"loadingText"`
Description string `yaml:"description"`
Stream bool `yaml:"stream"`
ShowOutput bool `yaml:"showOutput"`
After CustomCommandAfterHook `yaml:"after"`
}
type CustomCommandPrompt struct {
@@ -445,7 +449,6 @@ func GetDefaultConfig() *UserConfig {
},
Commit: CommitConfig{
SignOff: false,
Verbose: "default",
},
Merging: MergingConfig{
ManualCommit: false,

View File

@@ -376,3 +376,16 @@ func (self *ContextMgr) AllPatchExplorer() []types.IPatchExplorerContext {
return listContexts
}
func (self *ContextMgr) ContextForKey(key types.ContextKey) types.Context {
self.RLock()
defer self.RUnlock()
for _, context := range self.allContexts.Flatten() {
if context.GetKey() == key {
return context
}
}
return nil
}

View File

@@ -5,12 +5,16 @@ import (
)
const (
// used as a nil value when passing a context key as an arg
NO_CONTEXT types.ContextKey = "none"
GLOBAL_CONTEXT_KEY types.ContextKey = "global"
STATUS_CONTEXT_KEY types.ContextKey = "status"
SNAKE_CONTEXT_KEY types.ContextKey = "snake"
FILES_CONTEXT_KEY types.ContextKey = "files"
LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees"
REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches"
TAGS_CONTEXT_KEY types.ContextKey = "tags"
LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits"
@@ -49,6 +53,7 @@ var AllContextKeys = []types.ContextKey{
FILES_CONTEXT_KEY,
LOCAL_BRANCHES_CONTEXT_KEY,
REMOTES_CONTEXT_KEY,
WORKTREES_CONTEXT_KEY,
REMOTE_BRANCHES_CONTEXT_KEY,
TAGS_CONTEXT_KEY,
LOCAL_COMMITS_CONTEXT_KEY,
@@ -84,6 +89,7 @@ type ContextTree struct {
LocalCommits *LocalCommitsContext
CommitFiles *CommitFilesContext
Remotes *RemotesContext
Worktrees *WorktreesContext
Submodules *SubmodulesContext
RemoteBranches *RemoteBranchesContext
ReflogCommits *ReflogCommitsContext
@@ -121,6 +127,7 @@ func (self *ContextTree) Flatten() []types.Context {
self.Files,
self.SubCommits,
self.Remotes,
self.Worktrees,
self.RemoteBranches,
self.Tags,
self.Branches,

View File

@@ -29,6 +29,7 @@ func NewContextTree(c *ContextCommon) *ContextTree {
Submodules: NewSubmodulesContext(c),
Menu: NewMenuContext(c),
Remotes: NewRemotesContext(c),
Worktrees: NewWorktreesContext(c),
RemoteBranches: NewRemoteBranchesContext(c),
LocalCommits: NewLocalCommitsContext(c),
CommitFiles: commitFilesContext,

View File

@@ -0,0 +1,56 @@
package context
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type WorktreesContext struct {
*FilteredListViewModel[*models.Worktree]
*ListContextTrait
}
var _ types.IListContext = (*WorktreesContext)(nil)
func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
viewModel := NewFilteredListViewModel(
func() []*models.Worktree { return c.Model().Worktrees },
func(Worktree *models.Worktree) []string {
return []string{Worktree.Name()}
},
)
getDisplayStrings := func(startIdx int, length int) [][]string {
return presentation.GetWorktreeDisplayStrings(
c.Model().Worktrees,
c.Git().Worktree.IsCurrentWorktree,
c.Git().Worktree.IsWorktreePathMissing,
)
}
return &WorktreesContext{
FilteredListViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Worktrees,
WindowName: "branches",
Key: WORKTREES_CONTEXT_KEY,
Kind: types.SIDE_CONTEXT,
Focusable: true,
})),
list: viewModel,
getDisplayStrings: getDisplayStrings,
c: c,
},
}
}
func (self *WorktreesContext) GetSelectedItemId() string {
item := self.GetSelected()
if item == nil {
return ""
}
return item.ID()
}

View File

@@ -45,7 +45,18 @@ func (gui *Gui) resetHelpersAndControllers() {
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon)
stagingHelper := helpers.NewStagingHelper(helperCommon)
mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon)
refreshHelper := helpers.NewRefreshHelper(helperCommon, refsHelper, rebaseHelper, patchBuildingHelper, stagingHelper, mergeConflictsHelper, gui.fileWatcher)
reposHelper := helpers.NewRecentReposHelper(helperCommon, recordDirectoryHelper, gui.onNewRepo)
worktreeHelper := helpers.NewWorktreeHelper(helperCommon, reposHelper)
refreshHelper := helpers.NewRefreshHelper(
helperCommon,
refsHelper,
rebaseHelper,
patchBuildingHelper,
stagingHelper,
mergeConflictsHelper,
worktreeHelper,
gui.fileWatcher,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
cherryPickHelper := helpers.NewCherryPickHelper(
helperCommon,
@@ -84,7 +95,7 @@ func (gui *Gui) resetHelpersAndControllers() {
Commits: commitsHelper,
Snake: helpers.NewSnakeHelper(helperCommon),
Diff: diffHelper,
Repos: helpers.NewRecentReposHelper(helperCommon, recordDirectoryHelper, gui.onNewRepo),
Repos: reposHelper,
RecordDirectory: recordDirectoryHelper,
Update: helpers.NewUpdateHelper(helperCommon, gui.Updater),
Window: windowHelper,
@@ -99,7 +110,8 @@ func (gui *Gui) resetHelpersAndControllers() {
modeHelper,
appStatusHelper,
),
Search: helpers.NewSearchHelper(helperCommon),
Search: helpers.NewSearchHelper(helperCommon),
Worktree: worktreeHelper,
}
gui.CustomCommandsClient = custom_commands.NewClient(
@@ -138,6 +150,7 @@ func (gui *Gui) resetHelpersAndControllers() {
common,
func(branches []*models.RemoteBranch) { gui.State.Model.RemoteBranches = branches },
)
worktreesController := controllers.NewWorktreesController(common)
undoController := controllers.NewUndoController(common)
globalController := controllers.NewGlobalController(common)
contextLinesController := controllers.NewContextLinesController(common)
@@ -177,6 +190,7 @@ func (gui *Gui) resetHelpersAndControllers() {
for _, context := range []types.Context{
gui.State.Contexts.Status,
gui.State.Contexts.Remotes,
gui.State.Contexts.Worktrees,
gui.State.Contexts.Tags,
gui.State.Contexts.Branches,
gui.State.Contexts.RemoteBranches,
@@ -298,6 +312,10 @@ func (gui *Gui) resetHelpersAndControllers() {
remotesController,
)
controllers.AttachControllers(gui.State.Contexts.Worktrees,
worktreesController,
)
controllers.AttachControllers(gui.State.Contexts.Stash,
stashController,
)

View File

@@ -202,10 +202,37 @@ func (self *BranchesController) press(selectedBranch *models.Branch) error {
return self.c.ErrorMsg(self.c.Tr.AlreadyCheckedOutBranch)
}
if selectedBranch.CheckedOutByOtherWorktree {
worktreeForRef, ok := self.worktreeForRef(selectedBranch.Name)
if ok && !self.c.Git().Worktree.IsCurrentWorktree(worktreeForRef) {
return self.promptToCheckoutWorktree(worktreeForRef)
}
}
self.c.LogAction(self.c.Tr.Actions.CheckoutBranch)
return self.c.Helpers().Refs.CheckoutRef(selectedBranch.Name, types.CheckoutRefOptions{})
}
func (self *BranchesController) worktreeForRef(ref string) (*models.Worktree, bool) {
for _, worktree := range self.c.Model().Worktrees {
if worktree.Branch == ref {
return worktree, true
}
}
return nil, false
}
func (self *BranchesController) promptToCheckoutWorktree(worktree *models.Worktree) error {
return self.c.Confirm(types.ConfirmOpts{
Title: "Switch to worktree",
Prompt: fmt.Sprintf("This branch is checked out by worktree %s. Do you want to switch to that worktree?", worktree.Name()),
HandleConfirm: func() error {
return self.c.Helpers().Worktree.Switch(worktree, context.LOCAL_BRANCHES_CONTEXT_KEY)
},
})
}
func (self *BranchesController) handleCreatePullRequest(selectedBranch *models.Branch) error {
return self.createPullRequest(selectedBranch.Name, "")
}

View File

@@ -47,6 +47,7 @@ type Helpers struct {
AppStatus *AppStatusHelper
WindowArrangement *WindowArrangementHelper
Search *SearchHelper
Worktree *WorktreeHelper
}
func NewStubHelpers() *Helpers {
@@ -80,5 +81,6 @@ func NewStubHelpers() *Helpers {
AppStatus: &AppStatusHelper{},
WindowArrangement: &WindowArrangementHelper{},
Search: &SearchHelper{},
Worktree: &WorktreeHelper{},
}
}

View File

@@ -137,33 +137,47 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
} else if strings.Contains(result.Error(), "No rebase in progress?") {
// assume in this case that we're already done
return nil
} else if isMergeConflictErr(result.Error()) {
mode := self.workingTreeStateNoun()
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.FoundConflictsTitle,
Items: []*types.MenuItem{
{
Label: self.c.Tr.ViewConflictsMenuItem,
OnPress: func() error {
return self.c.PushContext(self.c.Contexts().Files)
},
Key: 'v',
},
{
Label: fmt.Sprintf(self.c.Tr.AbortMenuItem, mode),
OnPress: func() error {
return self.genericMergeCommand(REBASE_OPTION_ABORT)
},
Key: 'a',
},
},
HideCancel: true,
})
} else {
return self.CheckForConflicts(result)
}
}
func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error {
if result == nil {
return nil
}
if isMergeConflictErr(result.Error()) {
return self.PromptForConflictHandling()
} else {
return self.c.ErrorMsg(result.Error())
}
}
func (self *MergeAndRebaseHelper) PromptForConflictHandling() error {
mode := self.workingTreeStateNoun()
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.FoundConflictsTitle,
Items: []*types.MenuItem{
{
Label: self.c.Tr.ViewConflictsMenuItem,
OnPress: func() error {
return self.c.PushContext(self.c.Contexts().Files)
},
Key: 'v',
},
{
Label: fmt.Sprintf(self.c.Tr.AbortMenuItem, mode),
OnPress: func() error {
return self.genericMergeCommand(REBASE_OPTION_ABORT)
},
Key: 'a',
},
},
HideCancel: true,
})
}
func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error {
// prompt user to confirm that they want to abort, then do it
mode := self.workingTreeStateNoun()

View File

@@ -27,6 +27,7 @@ type RefreshHelper struct {
patchBuildingHelper *PatchBuildingHelper
stagingHelper *StagingHelper
mergeConflictsHelper *MergeConflictsHelper
worktreeHelper *WorktreeHelper
fileWatcher types.IFileWatcher
}
@@ -37,6 +38,7 @@ func NewRefreshHelper(
patchBuildingHelper *PatchBuildingHelper,
stagingHelper *StagingHelper,
mergeConflictsHelper *MergeConflictsHelper,
worktreeHelper *WorktreeHelper,
fileWatcher types.IFileWatcher,
) *RefreshHelper {
return &RefreshHelper{
@@ -46,6 +48,7 @@ func NewRefreshHelper(
patchBuildingHelper: patchBuildingHelper,
stagingHelper: stagingHelper,
mergeConflictsHelper: mergeConflictsHelper,
worktreeHelper: worktreeHelper,
fileWatcher: fileWatcher,
}
}
@@ -77,6 +80,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) error {
types.REFLOG,
types.TAGS,
types.REMOTES,
types.WORKTREES,
types.STATUS,
types.BISECT_INFO,
types.STAGING,
@@ -128,6 +132,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) error {
refresh(func() { _ = self.refreshRemotes() })
}
if scopeSet.Includes(types.WORKTREES) {
refresh(func() { _ = self.refreshWorktrees() })
}
if scopeSet.Includes(types.STAGING) {
refresh(func() { _ = self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) })
}
@@ -170,6 +178,7 @@ func getScopeNames(scopes []types.RefreshableView) []string {
types.REFLOG: "reflog",
types.TAGS: "tags",
types.REMOTES: "remotes",
types.WORKTREES: "worktrees",
types.STATUS: "status",
types.BISECT_INFO: "bisect",
types.STAGING: "staging",
@@ -557,6 +566,17 @@ func (self *RefreshHelper) refreshRemotes() error {
return nil
}
func (self *RefreshHelper) refreshWorktrees() error {
worktrees, err := self.c.Git().Loaders.Worktrees.GetWorktrees()
if err != nil {
return self.c.Error(err)
}
self.c.Model().Worktrees = worktrees
return self.c.PostRefreshUpdate(self.c.Contexts().Worktrees)
}
func (self *RefreshHelper) refreshStashEntries() error {
self.c.Model().StashEntries = self.c.Git().Loaders.StashLoader.
GetStashEntries(self.c.Modes().Filtering.GetPath())
@@ -587,6 +607,10 @@ func (self *RefreshHelper) refreshStatus() {
name := presentation.GetBranchTextStyle(currentBranch.Name).Sprint(currentBranch.Name)
repoName := utils.GetCurrentRepoName()
mainWorktreeName := self.worktreeHelper.GetMainWorktreeName()
if repoName != mainWorktreeName {
repoName = fmt.Sprintf("%s(%s)", mainWorktreeName, style.FgBlue.Sprint(repoName))
}
status += fmt.Sprintf("%s → %s ", repoName, name)
self.c.SetViewContent(self.c.Views().Status, status)

View File

@@ -12,13 +12,14 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"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/utils"
)
type onNewRepoFn func(startArgs appTypes.StartArgs, reuseState bool) error
type onNewRepoFn func(startArgs appTypes.StartArgs, reuseState bool, contextKey types.ContextKey) error
// helps switch back and forth between repos
type ReposHelper struct {
@@ -46,7 +47,7 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error
}
self.c.State().GetRepoPathStack().Push(wd)
return self.DispatchSwitchToRepo(submodule.Path, true)
return self.DispatchSwitchToRepo(submodule.Path, true, context.NO_CONTEXT)
}
func (self *ReposHelper) getCurrentBranch(path string) string {
@@ -129,7 +130,7 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
// if we were in a submodule, we want to forget about that stack of repos
// so that hitting escape in the new repo does nothing
self.c.State().GetRepoPathStack().Clear()
return self.DispatchSwitchToRepo(path, false)
return self.DispatchSwitchToRepo(path, false, context.NO_CONTEXT)
},
}
})
@@ -137,7 +138,11 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems})
}
func (self *ReposHelper) DispatchSwitchToRepo(path string, reuse bool) error {
func (self *ReposHelper) DispatchSwitchToRepo(path string, reuse bool, contextKey types.ContextKey) error {
return self.DispatchSwitchTo(path, reuse, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey)
}
func (self *ReposHelper) DispatchSwitchTo(path string, reuse bool, errMsg string, contextKey types.ContextKey) error {
env.UnsetGitDirEnvs()
originalPath, err := os.Getwd()
if err != nil {
@@ -146,7 +151,7 @@ func (self *ReposHelper) DispatchSwitchToRepo(path string, reuse bool) error {
if err := os.Chdir(path); err != nil {
if os.IsNotExist(err) {
return self.c.ErrorMsg(self.c.Tr.ErrRepositoryMovedOrDeleted)
return self.c.ErrorMsg(errMsg)
}
return err
}
@@ -171,5 +176,5 @@ func (self *ReposHelper) DispatchSwitchToRepo(path string, reuse bool) error {
self.c.Mutexes().RefreshingFilesMutex.Lock()
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
return self.onNewRepo(appTypes.StartArgs{}, reuse)
return self.onNewRepo(appTypes.StartArgs{}, reuse, contextKey)
}

View File

@@ -0,0 +1,93 @@
package helpers
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type IWorktreeHelper interface {
GetMainWorktreeName() string
GetCurrentWorktreeName() string
}
type WorktreeHelper struct {
c *HelperCommon
reposHelper *ReposHelper
}
func NewWorktreeHelper(c *HelperCommon, reposHelper *ReposHelper) *WorktreeHelper {
return &WorktreeHelper{
c: c,
reposHelper: reposHelper,
}
}
func (self *WorktreeHelper) GetMainWorktreeName() string {
for _, worktree := range self.c.Model().Worktrees {
if worktree.Main() {
return worktree.Name()
}
}
return ""
}
func (self *WorktreeHelper) IsCurrentWorktree(w *models.Worktree) bool {
pwd, err := os.Getwd()
if err != nil {
log.Fatalln(err.Error())
}
return pwd == w.Path
}
func (self *WorktreeHelper) IsWorktreePathMissing(w *models.Worktree) bool {
if _, err := os.Stat(w.Path); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return true
}
log.Fatalln(fmt.Errorf("failed to check if worktree path `%s` exists\n%w", w.Path, err).Error())
}
return false
}
func (self *WorktreeHelper) NewWorktree() error {
return self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewWorktreePath,
HandleConfirm: func(path string) error {
return self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewWorktreePath,
HandleConfirm: func(committish string) error {
return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AddWorktree)
if err := self.c.Git().Worktree.New(sanitizedBranchName(path), committish); err != nil {
return err
}
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
})
},
})
},
})
}
func (self *WorktreeHelper) Switch(worktree *models.Worktree, contextKey types.ContextKey) error {
if self.c.Git().Worktree.IsCurrentWorktree(worktree) {
return self.c.ErrorMsg(self.c.Tr.AlreadyInWorktree)
}
self.c.LogAction(self.c.Tr.SwitchToWorktree)
// if we were in a submodule, we want to forget about that stack of repos
// so that hitting escape in the new repo does nothing
self.c.State().GetRepoPathStack().Clear()
return self.reposHelper.DispatchSwitchTo(worktree.Path, true, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
}

View File

@@ -2,6 +2,7 @@ package controllers
import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -77,7 +78,7 @@ func (self *QuitActions) Escape() error {
repoPathStack := self.c.State().GetRepoPathStack()
if !repoPathStack.IsEmpty() {
return self.c.Helpers().Repos.DispatchSwitchToRepo(repoPathStack.Pop(), true)
return self.c.Helpers().Repos.DispatchSwitchToRepo(repoPathStack.Pop(), true, context.NO_CONTEXT)
}
if self.c.UserConfig.QuitOnTopLevelReturn {

View File

@@ -0,0 +1,166 @@
package controllers
import (
"fmt"
"strings"
"text/tabwriter"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type WorktreesController struct {
baseController
c *ControllerCommon
}
var _ types.IController = &WorktreesController{}
func NewWorktreesController(
common *ControllerCommon,
) *WorktreesController {
return &WorktreesController{
baseController: baseController{},
c: common,
}
}
func (self *WorktreesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Key: opts.GetKey(opts.Config.Universal.Select),
Handler: self.checkSelected(self.enter),
Description: self.c.Tr.SwitchToWorktree,
},
{
Key: opts.GetKey(opts.Config.Universal.Remove),
Handler: self.checkSelected(self.remove),
Description: self.c.Tr.RemoveWorktree,
},
{
Key: opts.GetKey(opts.Config.Universal.New),
Handler: self.add,
Description: self.c.Tr.CreateWorktree,
},
}
return bindings
}
func (self *WorktreesController) GetOnRenderToMain() func() error {
return func() error {
var task types.UpdateTask
worktree := self.context().GetSelected()
if worktree == nil {
task = types.NewRenderStringTask(self.c.Tr.NoWorktreesThisRepo)
} else {
main := ""
if worktree.Main() {
main = style.FgDefault.Sprintf(" %s", self.c.Tr.MainWorktree)
}
missing := ""
if self.c.Git().Worktree.IsWorktreePathMissing(worktree) {
missing = style.FgRed.Sprintf(" %s", self.c.Tr.MissingWorktree)
}
var builder strings.Builder
w := tabwriter.NewWriter(&builder, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "%s:\t%s%s\n", self.c.Tr.Name, style.FgGreen.Sprint(worktree.Name()), main)
_, _ = fmt.Fprintf(w, "%s:\t%s\n", self.c.Tr.Branch, style.FgYellow.Sprint(worktree.Branch))
_, _ = fmt.Fprintf(w, "%s:\t%s%s\n", self.c.Tr.Path, style.FgCyan.Sprint(worktree.Path), missing)
_ = w.Flush()
task = types.NewRenderStringTask(builder.String())
}
return self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Title: self.c.Tr.WorktreeTitle,
Task: task,
},
})
}
}
func (self *WorktreesController) add() error {
return self.c.Helpers().Worktree.NewWorktree()
}
func (self *WorktreesController) remove(worktree *models.Worktree) error {
if worktree.Main() {
return self.c.ErrorMsg(self.c.Tr.CantDeleteMainWorktree)
}
if self.c.Git().Worktree.IsCurrentWorktree(worktree) {
return self.c.ErrorMsg(self.c.Tr.CantDeleteCurrentWorktree)
}
return self.removeWithForce(worktree, false)
}
func (self *WorktreesController) removeWithForce(worktree *models.Worktree, force bool) error {
title := self.c.Tr.RemoveWorktreeTitle
var templateStr string
if force {
templateStr = self.c.Tr.ForceRemoveWorktreePrompt
} else {
templateStr = self.c.Tr.RemoveWorktreePrompt
}
message := utils.ResolvePlaceholderString(
templateStr,
map[string]string{
"worktreeName": worktree.Name(),
},
)
return self.c.Confirm(types.ConfirmOpts{
Title: title,
Prompt: message,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.RemovingWorktree)
if err := self.c.Git().Worktree.Delete(worktree.Path, force); err != nil {
errMessage := err.Error()
if !force {
return self.removeWithForce(worktree, true)
}
return self.c.ErrorMsg(errMessage)
}
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES}})
})
},
})
}
func (self *WorktreesController) GetOnClick() func() error {
return self.checkSelected(self.enter)
}
func (self *WorktreesController) enter(worktree *models.Worktree) error {
return self.c.Helpers().Worktree.Switch(worktree, context.WORKTREES_CONTEXT_KEY)
}
func (self *WorktreesController) checkSelected(callback func(worktree *models.Worktree) error) func() error {
return func() error {
worktree := self.context().GetSelected()
if worktree == nil {
return nil
}
return callback(worktree)
}
}
func (self *WorktreesController) Context() types.Context {
return self.context()
}
func (self *WorktreesController) context() *context.WorktreesContext {
return self.c.Contexts().Worktrees
}

View File

@@ -274,7 +274,7 @@ func (self *GuiRepoState) GetSplitMainPanel() bool {
return self.SplitMainPanel
}
func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool) error {
func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool, contextKey types.ContextKey) error {
var err error
gui.git, err = commands.NewGitCommand(
gui.Common,
@@ -295,6 +295,17 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool) error {
return err
}
// if a context key has been given, push that instead, and set its index to 0
if contextKey != context.NO_CONTEXT {
contextToPush = gui.c.ContextForKey(contextKey)
// when we pass a list context, the expectation is that our cursor goes to the top,
// because e.g. with worktrees, we'll show the current worktree at the top of the list.
listContext, ok := contextToPush.(types.IListContext)
if ok {
listContext.GetList().SetSelectedLineIdx(0)
}
}
if err := gui.c.PushContext(contextToPush); err != nil {
return err
}
@@ -544,6 +555,10 @@ func (gui *Gui) viewTabMap() map[string][]context.TabView {
Tab: gui.c.Tr.TagsTitle,
ViewName: "tags",
},
{
Tab: gui.c.Tr.WorktreesTitle,
ViewName: "worktrees",
},
},
"commits": {
{
@@ -614,7 +629,7 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
}
// onNewRepo must be called after g.SetManager because SetManager deletes keybindings
if err := gui.onNewRepo(startArgs, false); err != nil {
if err := gui.onNewRepo(startArgs, false, context.NO_CONTEXT); err != nil {
return err
}

View File

@@ -76,6 +76,10 @@ func (self *guiCommon) Context() types.IContextMgr {
return self.gui.State.ContextMgr
}
func (self *guiCommon) ContextForKey(key types.ContextKey) types.Context {
return self.gui.State.ContextMgr.ContextForKey(key)
}
func (self *guiCommon) ActivateContext(context types.Context) error {
return self.gui.State.ContextMgr.ActivateContext(context, types.OnFocusOpts{})
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
var branchPrefixColorCache = make(map[string]style.TextStyle)
@@ -49,6 +50,10 @@ func getBranchDisplayStrings(
coloredName := nameTextStyle.Sprint(displayName)
branchStatus := utils.WithPadding(ColoredBranchStatus(b, tr), 2, utils.AlignLeft)
if b.CheckedOutByOtherWorktree {
worktreeIcon := lo.Ternary(icons.IsIconEnabled(), icons.LINKED_WORKTREE_ICON, "(worktree)")
coloredName = fmt.Sprintf("%s %s", coloredName, style.FgDefault.Sprint(worktreeIcon))
}
coloredName = fmt.Sprintf("%s %s", coloredName, branchStatus)
recencyColor := style.FgCyan
@@ -58,6 +63,7 @@ func getBranchDisplayStrings(
res := make([]string, 0, 6)
res = append(res, recencyColor.Sprint(b.Recency))
if icons.IsIconEnabled() {
res = append(res, nameTextStyle.Sprint(icons.IconForBranch(b)))
}

View File

@@ -7,13 +7,15 @@ import (
)
var (
BRANCH_ICON = "\U000f062c" // 󰘬
DETACHED_HEAD_ICON = "\ue729" // 
TAG_ICON = "\uf02b" // 
COMMIT_ICON = "\U000f0718" // 󰜘
MERGE_COMMIT_ICON = "\U000f062d" // 󰘭
DEFAULT_REMOTE_ICON = "\uf02a2" // 󰊢
STASH_ICON = "\uf01c" // 
BRANCH_ICON = "\U000f062c" // 󰘬
DETACHED_HEAD_ICON = "\ue729" // 
TAG_ICON = "\uf02b" // 
COMMIT_ICON = "\U000f0718" // 󰜘
MERGE_COMMIT_ICON = "\U000f062d" // 󰘭
DEFAULT_REMOTE_ICON = "\uf02a2" // 󰊢
STASH_ICON = "\uf01c" // 
LINKED_WORKTREE_ICON = "\uf838" // 
MISSING_LINKED_WORKTREE_ICON = "\uf839" // 
)
var remoteIcons = map[string]string{
@@ -68,3 +70,10 @@ func IconForRemote(remote *models.Remote) string {
func IconForStash(stash *models.StashEntry) string {
return STASH_ICON
}
func IconForWorktree(missing bool) string {
if missing {
return MISSING_LINKED_WORKTREE_ICON
}
return LINKED_WORKTREE_ICON
}

View File

@@ -0,0 +1,49 @@
package presentation
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/samber/lo"
)
func GetWorktreeDisplayStrings(worktrees []*models.Worktree, isCurrent func(*models.Worktree) bool, isMissing func(*models.Worktree) bool) [][]string {
return lo.Map(worktrees, func(worktree *models.Worktree, _ int) []string {
return GetWorktreeDisplayString(
isCurrent(worktree),
isMissing(worktree),
worktree)
})
}
func GetWorktreeDisplayString(isCurrent bool, isPathMissing bool, worktree *models.Worktree) []string {
textStyle := theme.DefaultTextColor
current := ""
currentColor := style.FgCyan
if isCurrent {
current = " *"
currentColor = style.FgGreen
}
icon := icons.IconForWorktree(false)
if isPathMissing {
textStyle = style.FgRed
icon = icons.IconForWorktree(true)
}
res := []string{}
res = append(res, currentColor.Sprint(current))
if icons.IsIconEnabled() {
res = append(res, textStyle.Sprint(icon))
}
name := worktree.Name()
if worktree.Main() {
// TODO: i18n
name += " (main worktree)"
}
res = append(res, textStyle.Sprint(name))
return res
}

View File

@@ -19,7 +19,12 @@ func NewClient(
helpers *helpers.Helpers,
) *Client {
sessionStateLoader := NewSessionStateLoader(c, helpers.Refs)
handlerCreator := NewHandlerCreator(c, sessionStateLoader, helpers.Suggestions)
handlerCreator := NewHandlerCreator(
c,
sessionStateLoader,
helpers.Suggestions,
helpers.MergeAndRebase,
)
keybindingCreator := NewKeybindingCreator(c)
customCommands := c.UserConfig.CustomCommands

View File

@@ -17,27 +17,30 @@ import (
// takes a custom command and returns a function that will be called when the corresponding user-defined keybinding is pressed
type HandlerCreator struct {
c *helpers.HelperCommon
sessionStateLoader *SessionStateLoader
resolver *Resolver
menuGenerator *MenuGenerator
suggestionsHelper *helpers.SuggestionsHelper
c *helpers.HelperCommon
sessionStateLoader *SessionStateLoader
resolver *Resolver
menuGenerator *MenuGenerator
suggestionsHelper *helpers.SuggestionsHelper
mergeAndRebaseHelper *helpers.MergeAndRebaseHelper
}
func NewHandlerCreator(
c *helpers.HelperCommon,
sessionStateLoader *SessionStateLoader,
suggestionsHelper *helpers.SuggestionsHelper,
mergeAndRebaseHelper *helpers.MergeAndRebaseHelper,
) *HandlerCreator {
resolver := NewResolver(c.Common)
menuGenerator := NewMenuGenerator(c.Common)
return &HandlerCreator{
c: c,
sessionStateLoader: sessionStateLoader,
resolver: resolver,
menuGenerator: menuGenerator,
suggestionsHelper: suggestionsHelper,
c: c,
sessionStateLoader: sessionStateLoader,
resolver: resolver,
menuGenerator: menuGenerator,
suggestionsHelper: suggestionsHelper,
mergeAndRebaseHelper: mergeAndRebaseHelper,
}
}
@@ -272,7 +275,16 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
cmdObj.StreamOutput()
}
output, err := cmdObj.RunWithOutput()
if refreshErr := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil {
self.c.Log.Error(refreshErr)
}
if err != nil {
if customCommand.After.CheckForConflicts {
return self.mergeAndRebaseHelper.CheckForConflicts(err)
}
return self.c.Error(err)
}
@@ -280,11 +292,9 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
if strings.TrimSpace(output) == "" {
output = self.c.Tr.EmptyOutput
}
if err = self.c.Alert(cmdStr, output); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{})
return self.c.Alert(cmdStr, output)
}
return self.c.Refresh(types.RefreshOptions{})
return nil
})
}

View File

@@ -66,6 +66,7 @@ type IGuiCommon interface {
IsCurrentContext(Context) bool
// TODO: replace the above context-based methods with just using Context() e.g. replace PushContext() with Context().Push()
Context() IContextMgr
ContextForKey(key ContextKey) Context
ActivateContext(context Context) error
@@ -196,6 +197,7 @@ type Model struct {
StashEntries []*models.StashEntry
SubCommits []*models.Commit
Remotes []*models.Remote
Worktrees []*models.Worktree
// FilteredReflogCommits are the ones that appear in the reflog panel.
// when in filtering mode we only include the ones that match the given path

View File

@@ -13,6 +13,7 @@ const (
REFLOG
TAGS
REMOTES
WORKTREES
STATUS
SUBMODULES
STAGING

View File

@@ -8,6 +8,7 @@ type Views struct {
Files *gocui.View
Branches *gocui.View
Remotes *gocui.View
Worktrees *gocui.View
Tags *gocui.View
RemoteBranches *gocui.View
ReflogCommits *gocui.View

View File

@@ -3,7 +3,9 @@ package gui
import (
"github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type viewNameMapping struct {
@@ -27,6 +29,7 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping {
{viewPtr: &gui.Views.Files, name: "files"},
{viewPtr: &gui.Views.Tags, name: "tags"},
{viewPtr: &gui.Views.Remotes, name: "remotes"},
{viewPtr: &gui.Views.Worktrees, name: "worktrees"},
{viewPtr: &gui.Views.Branches, name: "localBranches"},
{viewPtr: &gui.Views.RemoteBranches, name: "remoteBranches"},
{viewPtr: &gui.Views.ReflogCommits, name: "reflogCommits"},
@@ -111,6 +114,8 @@ func (gui *Gui) createAllViews() error {
gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle
gui.Views.Worktrees.Title = gui.c.Tr.WorktreesTitle
gui.Views.Tags.Title = gui.c.Tr.TagsTitle
gui.Views.Files.Title = gui.c.Tr.FilesTitle
@@ -159,7 +164,10 @@ func (gui *Gui) createAllViews() error {
gui.Views.CommitDescription.Visible = false
gui.Views.CommitDescription.Title = gui.c.Tr.CommitDescriptionTitle
gui.Views.CommitDescription.Subtitle = gui.Tr.CommitDescriptionSubTitle
gui.Views.CommitDescription.Subtitle = utils.ResolvePlaceholderString(gui.Tr.CommitDescriptionSubTitle,
map[string]string{
"togglePanelKeyBinding": keybindings.Label(gui.UserConfig.Keybinding.Universal.TogglePanel),
})
gui.Views.CommitDescription.FgColor = theme.GocuiDefaultTextColor
gui.Views.CommitDescription.Editable = true
gui.Views.CommitDescription.Editor = gocui.EditorFunc(gui.commitDescriptionEditor)

View File

@@ -480,6 +480,7 @@ type TranslationSet struct {
ErrCannotEditDirectory string
ErrStageDirWithInlineMergeConflicts string
ErrRepositoryMovedOrDeleted string
ErrWorktreeMovedOrRemoved string
CommandLog string
ToggleShowCommandLog string
FocusCommandLog string
@@ -540,6 +541,27 @@ type TranslationSet struct {
FilterPrefix string
ExitSearchMode string
ExitTextFilterMode string
SwitchToWorktree string
RemoveWorktree string
RemoveWorktreeTitle string
WorktreesTitle string
WorktreeTitle string
RemoveWorktreePrompt string
ForceRemoveWorktreePrompt string
RemovingWorktree string
AddingWorktree string
CantDeleteCurrentWorktree string
AlreadyInWorktree string
CantDeleteMainWorktree string
NoWorktreesThisRepo string
MissingWorktree string
MainWorktree string
CreateWorktree string
NewWorktreePath string
NewWorktreeBranch string
Name string
Branch string
Path string
Actions Actions
Bisect Bisect
}
@@ -666,6 +688,8 @@ type Actions struct {
ResetBisect string
BisectSkip string
BisectMark string
RemoveWorktree string
AddWorktree string
}
const englishIntroPopupMessage = `
@@ -886,7 +910,7 @@ func EnglishTranslationSet() TranslationSet {
RebaseOptionsTitle: "Rebase options",
CommitMessageTitle: "Commit summary",
CommitDescriptionTitle: "Commit description",
CommitDescriptionSubTitle: "Press tab to toggle focus",
CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus",
LocalBranchesTitle: "Local branches",
SearchTitle: "Search",
TagsTitle: "Tags",
@@ -1175,6 +1199,7 @@ func EnglishTranslationSet() TranslationSet {
ErrStageDirWithInlineMergeConflicts: "Cannot stage/unstage directory containing files with inline merge conflicts. Please fix up the merge conflicts first",
ErrRepositoryMovedOrDeleted: "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯",
CommandLog: "Command log",
ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯",
ToggleShowCommandLog: "Toggle show/hide command log",
FocusCommandLog: "Focus command log",
CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n",
@@ -1233,6 +1258,27 @@ func EnglishTranslationSet() TranslationSet {
SearchKeybindings: "%s: Next match, %s: Previous match, %s: Exit search mode",
SearchPrefix: "Search: ",
FilterPrefix: "Filter: ",
WorktreesTitle: "Worktrees",
WorktreeTitle: "Worktree",
SwitchToWorktree: "Switch to worktree",
RemoveWorktree: "Remove worktree",
RemoveWorktreeTitle: "Remove worktree",
RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?",
ForceRemoveWorktreePrompt: "'{{.worktreeName}}' is not fully merged. Are you sure you want to remove it?",
RemovingWorktree: "Deleting worktree",
AddingWorktree: "Adding worktree",
CantDeleteCurrentWorktree: "You cannot remove the current worktree!",
AlreadyInWorktree: "You are already in the selected worktree",
CantDeleteMainWorktree: "You cannot remove the main worktree!",
NoWorktreesThisRepo: "No worktrees",
MissingWorktree: "(missing)",
MainWorktree: "(main)",
CreateWorktree: "Create worktree",
NewWorktreePath: "New worktree path",
NewWorktreeBranch: "New worktree branch (leave blank to use the current branch)",
Name: "Name",
Branch: "Branch",
Path: "Path",
Actions: Actions{
// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
CheckoutCommit: "Checkout commit",
@@ -1340,6 +1386,8 @@ func EnglishTranslationSet() TranslationSet {
ResetBisect: "Reset bisect",
BisectSkip: "Bisect skip",
BisectMark: "Bisect mark",
RemoveWorktree: "Remove worktree",
AddWorktree: "Add worktree",
},
Bisect: Bisect{
Mark: "Mark %s as %s",

View File

@@ -0,0 +1,40 @@
package custom_commands
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
)
var CheckForConflicts = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Run a command and check for conflicts after",
ExtraCmdArgs: []string{},
Skip: false,
SetupRepo: func(shell *Shell) {
shared.MergeConflictsSetup(shell)
},
SetupConfig: func(cfg *config.AppConfig) {
cfg.UserConfig.CustomCommands = []config.CustomCommand{
{
Key: "m",
Context: "localBranches",
Command: "git merge {{ .SelectedLocalBranch.Name | quote }}",
After: config.CustomCommandAfterHook{
CheckForConflicts: true,
},
},
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
TopLines(
Contains("first-change-branch"),
Contains("second-change-branch"),
).
NavigateToLine(Contains("second-change-branch")).
Press("m")
t.Common().AcknowledgeConflicts()
},
})

View File

@@ -75,6 +75,7 @@ var tests = []*components.IntegrationTest{
conflicts.UndoChooseHunk,
custom_commands.BasicCmdAtRuntime,
custom_commands.BasicCmdFromConfig,
custom_commands.CheckForConflicts,
custom_commands.ComplexCmdAtRuntime,
custom_commands.FormPrompts,
custom_commands.MenuFromCommand,