Compare commits

..
Author SHA1 Message Date
Stefan Haller 781eb61ed2 Add a "Show full diff" checkbox to the filtering menu
It is only visible when filtering by path.
2025-05-25 15:22:34 +02:00
Stefan Haller b5d97570f1 Add key bindings to filtering menu 2025-05-25 15:08:15 +02:00
Jesse Duffield da32b59e11 Fix lazygit's UI becoming unresponsive when a background fetch asks for a passphrase (#4586)
Fix a regression introduced with #4525.

This fixes the problem that background fetching makes lazygit hang when
the fetch request needs to prompt for a passphrase. For Mac users who
use the keychain to store their ssh passphrases, this can happen when
lazygit is running while the machine goes to sleep, because macOS looks
the keychain in that case.
2025-05-23 20:33:22 +10:00
Stefan Haller 2a8ef71e8f Use PTY also with credentialStrategy=FAIL
This is a regression introduced with a199ed1396c; it is important to use a PTY
even with credentialStrategy=FAIL, otherwise the fetch command will spew the
credentials request into the UI and then hang.

This fixes the problem that background fetching makes lazygit hang when the
fetch request needs to prompt for a passphrase. For Mac users who use the
keychain to store their ssh passphrases, this can happen when lazygit is running
while the machine goes to sleep, because macOS looks the keychain in that case.
2025-05-23 11:34:57 +02:00
Stefan Haller 6705428a8d Fix crash when clicking in the status view (#4567)
- **PR Description**

The status view is not supposed to be focusable right now. (This might
change soon, but for now it isn't.) Pressing '0' on it does nothing.

However, clicking on it would still focus it, because the click handler
in MainViewController assumed that when the clicked view doesn't have
the focus, then its "other" view must have, and we just want to toggle
the focus between the two (like when pressing tab). It didn't take the
possibility into account that the current side panel isn't focusable at
all; if it was, then its SwitchToFocusedMainViewController would have
handled the click.

To fix this, check if the "other" view has the focus before handling the
click, and do nothing otherwise.

This also fixes clicking in the main views of the Worktrees or
Submodules tabs, or any other tabs whose main views are not focusable.

Fixes #4566.
2025-05-22 14:41:04 +02:00
Stefan Haller bbd17abc43 Add ContextMgr.NextInStack and use it to access side panel of focused main view
This way we don't have to abuse the parent context mechanism, which isn't meant
for this purpose.
2025-05-22 14:38:40 +02:00
Stefan Haller 12ed50464b Cleanup: pass target context to focusMainView directly
It's a bit silly to pass a window name and then call a function to get the
corresponding context, when we can simply pass the context directly.
2025-05-22 14:38:40 +02:00
Stefan Haller 57991c1da8 Fix crash when clicking in the status view
The click handler of MainViewController was registered as a global handler, so
it was used when a side panel was focused that doesn't have a
SwitchToFocusedMainViewController attached (e.g. Status, Worktrees, or
Submodules). This handler would then push the main view context, but with the
code that is meant only for toggling between the main view pair contexts, i.e.
with taking over the parentContext from the otherContext, which doesn't have one
at that point. This would later lead to a crash in onClick because the
parentContext was nil.

Fix this by splitting the click handler in two, one for when it already has the
focus, and one for toggling from the other view, and make these focus specific.
2025-05-22 14:38:40 +02:00
Stefan Haller eecb59dd8a Fix home and end keys in prompts (#4554)
- **PR Description**

In #4404 we added home/end support to confirmation popups. This broke
handling of the home/end keys in prompts, where they used to work as
alternatives to ctrl-a and ctrl-e.

Here's one way (maybe not the best) to fix this.

A better fix might have been to have separate views for confirmations
and prompts, so that we can have different keybindings for each. That's
a bit more work though.

Fixes #4553.
2025-05-22 08:50:45 +02:00
Stefan Haller b8fcaf01b8 Make home and end keys work in prompts 2025-05-22 08:48:38 +02:00
Stefan Haller c80b0e1910 Bump gocui 2025-05-22 08:48:14 +02:00
Stefan Haller f250dfb8de Fix main view occasionally scrolling to the top on its own when focused (#4573)
Ok, this is a long one. (It took me all weekend to figure out.)

We seem to have a race condition between re-rendering the main view and
the [layout
code](https://github.com/jesseduffield/lazygit/blob/a0ec22c251c67b40f2df9a61226f3bd514789e15/pkg/gui/layout.go#L75)
that checks whether a view has become smaller and therefore needs to
scroll up. When rerendering the main view, we are careful not to
invalidate the ViewLines array, so the code first calls Reset on the
view, and then starts writing lines to the view until we have written
enough for its old scroll position, and only then do we trigger a
redraw. This is all well and good, but theoretically it could happen
that the above-mentioned layout code runs shortly after the task has
started writing lines to the view (say, after the first one has been
written), and then it would see that the view's height is only 1, and
scroll it all the way to the top.

I have never seen this happen, so it seems that we are being lucky and
the race condition is only a theoretical one.

However: we had a very silly and embarrassing bug in the
focused-main-view code that triggers the race condition occasionally.
The bug is that when the main view is focused, we would refresh it
multiple times in quick succession, once for every side panel that is
being refreshed (instead of just once for the side panel that it belongs
to). So the first task would call Reset, start writing lines to the
view, and then the second task would come along, kill the first, call
Reset again, and start writing lines again, and so on. Apparently this
made it more likely for the layout code to run concurrently with this,
and see the view at a moment where it only has one or two lines. I have
seen it scroll to the top on its own a few times, which is very annoying
when you are in the middle of reviewing a longer commit, for instance.

We can fix this by refreshing the main view only for the side panel that
it belongs to, which is what this PR does. I have let lazygit run over
night with a `refresher.refreshInterval` value of 3, and it hadn't
scrolled to the top when I came to look in the morning, which makes me
pretty confident that we're good now.

It would still be nice if we could fix the race condition for real too,
but it's less urgent now, and it also doesn't seem trivial. I guess
instead of writing lines directly to the view we would have to buffer
them first, and only write them to the view when the original scroll
position is reached (with some synchronization, e.g. with a OnUIThread).
There are other complications that make this tricky though, and I have
no plans right now to tackle this.
2025-05-22 08:17:19 +02:00
Stefan Haller f3466e2525 Render the main view only for the side context that it belongs to
Previously we would re-render the focused main view several times during a
refresh, once for every side panel. While this should usually not be noticeable
for users because we are careful to avoid flicker when refreshing the main view,
this would sometimes lead to the main view scrolling up to the top by itself;
see PR description for more details.
2025-05-22 08:15:25 +02:00
Stefan Haller 7a24c5628a Print migration changes to the console when migrating config file (#4548)
- **PR Description**

This might be useful to see in general (users will normally only see it
after they quit lazygit again, but still). But it is especially useful
when writing back the config file fails; some users have their config
file in a read-only location, so we had reports of lazygit no longer
starting up when migration was necessary. #4210 was supposed to improve
this a bit, but it didn't tell users what changes need to be made to the
config file. Now we tell them, and users can then make these changes
manually if they want.

We do this only at startup, when the GUI hasn't started yet. This is
probably good enough, because it is much less likely that writing back a
migrated repo-local config fails because it is not writeable.

Example output:
```
The user config file /Users/stk/Library/Application Support/lazygit/config.yml must be migrated. Attempting to do this automatically.
The following changes were made:

- Renamed 'gui.windowSize' to 'screenMode'
- Changed 'null' to '<disabled>' for keybinding 'keybinding.universal.confirmInEditor'
- Changed 'stream: true' to 'output: log' in custom command

Config file saved successfully to /Users/stk/Library/Application Support/lazygit/config.yml
```

The branch also contains a lot of code cleanups.

- **Please check if the PR fulfills these requirements**

* [x] Cheatsheets are up-to-date (run `go generate ./...`)
* [x] Code has been formatted (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [x] Tests have been added/updated (see
[here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
for the integration test guide)
* [ ] Text is internationalised (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
* [ ] If a new UserConfig entry was added, make sure it can be
hot-reloaded (see
[here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
* [ ] Docs have been updated if necessary
* [x] You've read through your own file changes for silly mistakes etc
2025-05-21 08:54:55 +02:00
Stefan Haller cabcd54508 Include migration changes in the error message if we can't log them
This is for the unlikely case that a repo-local config file can't be written
back after migration; in this case we can't log the migration changes to the
console, so include them in the error popup instead.
2025-05-21 08:51:24 +02:00
Stefan Haller a4f43cb275 Log a list of migration changes to the console
This might be useful to see in general (users will normally only see it after
they quit lazygit again, but still). But it is especially useful when writing
back the config file fails for some reason, because users can then make these
changes manually if they want.

We do this only at startup, when the GUI hasn't started yet. This is probably
good enough, because it is much less likely that writing back a migrated
repo-local config fails because it is not writeable.
2025-05-21 08:51:24 +02:00
Stefan Haller caa8c921e6 Make RenameYamlKey return a bool 2025-05-21 08:51:24 +02:00
Stefan Haller 48305c18f7 Cleanup: remove redundant if statement 2025-05-21 08:51:24 +02:00
Stefan Haller ffda51014d Print migration hints only when GUI hasn't started yet
Most migrations happen at startup when loading the global config file, at a time
where the GUI hasn't been initialized yet. We can safely print to the console at
that point. However, it is also possible that repo-local config files need to be
migrated, and this happens when the GUI has already started, at which point we
had better not print anything to stdout; this totally messes up the UI.

In this commit we simply suppress the logging when the GUI is running already.
This is probably good enough, because the logging is mostly useful in the case
that writing back the migrated config file fails, so that users understand
better why lazygit doesn't start up; and this is very unlikely to happen for
repo-local config files, because why would users make them read-only.
2025-05-21 08:51:24 +02:00
Stefan Haller 61822b73f0 Add tests for migrating null keybindings to <disabled> 2025-05-21 08:51:24 +02:00
Stefan Haller df805f3a1a Add tests for migration of renamed keys 2025-05-21 08:51:24 +02:00
Stefan Haller 0249d4c8ab Cleanup: return didChange bool from computeMigratedConfig
It's a bit silly to find out by string comparison whether computeMigratedConfig
did something, when it knows this already and can just return the information.

This doesn't make a huge difference to the production code; the string
comparison isn't very expensive, so this isn't a big deal. However, it makes the
tests clearer; we don't have to bother specifying an expected output string if
the didChange flag is false, and in particular we can get rid of the ugly "This
test intentionally uses non-standard indentation" bit in one of the tests.
2025-05-21 08:51:24 +02:00
Stefan Haller 4f959da9f8 Cleanup: fix formatting of test cases 2025-05-21 08:51:24 +02:00
Stefan Haller da5789ba5f Cleanup: flip conditions for less indentation 2025-05-21 08:51:24 +02:00
Stefan Haller bf13e0bc6a Cleanup: use assert.NoError 2025-05-21 08:51:24 +02:00
Stefan Haller 5686e63af3 Bump generics dependency 2025-05-21 08:51:24 +02:00
Stefan Haller 1fbfefa625 Fix possible crash with auto-forwarding branches (#4565)
- **PR Description**

Fix crash in AutoForwardBranches when the branches slice is empty.

I'm not aware of any real scenario where this can happen, but we have
seen one stack trace where it crashed with an out-of-bounds error in the
range expression below, so there must be a way. And it seems better to
guard against it anyway, rather than assuming it can't happen.

(Hopefully) fixes #4564.
2025-05-21 08:24:58 +02:00
Stefan Haller 01b63a25ba Fix possible crash with auto-forwarding branches
I'm not aware of any real scenario where this can happen, but we have seen one
stack trace where it crashed with an out-of-bounds error in the range expression
below, so there must be a way. And it seems better to guard against it anyway,
rather than assuming it can't happen.
2025-05-21 08:21:38 +02:00
Stefan Haller 43efdded4a Split behavior of rendering allBranchesLogCmd and switching to next cmd (#4574)
- **PR Description**

This is a recreation of
https://github.com/jesseduffield/lazygit/pull/4556.

Compared to that PR description, this has actually solved the problem
that repeated attempts to `1` `a` when `gui.statusPanelView: dashboard`.
Now we only rotate logs on the 2nd `a` press. While this does differ
from current behavior, I believe it is better, and I have more details
in my commit description.

Fixes https://github.com/jesseduffield/lazygit/issues/4469
2025-05-21 08:20:23 +02:00
Chris McDonnell e67bc45d48 Split behavior of rendering allBranchesLogCmd and switching to next cmd
This now allows for leaving the status panel and returning back to the
same log command. Previously any return to the status panel would result
in the next command in the list being shown. Now, you need to press `a`,
with a log command being rendered, to rotate to the next
allBranchesLogCmd.
2025-05-21 08:17:09 +02:00
Stefan Haller a0ec22c251 Clear preserved commit message when entering CommitEditorPanel (#4558)
- **PR Description**

Fixes the problem described in #4557:

I often find myself initiating a commit message with `c`, but then
halfway through typing the message, realize that is is likely to be more
substantial than I thought. I press `<esc>` and then `C` to launch my
editor where I can edit a larger commit body more effectively. I finish
my commit message, close my editor, return to LazyGit and all is well.

Unfortunately, the next time I press `c`, creating a totally new commit,
my partially completed message is still present. It no longer is
relevant, so I have to delete the few words I typed before.
2025-05-15 21:46:19 +02:00
Chris McDonnell 3bd8a923ee Clear preserved commit message when entering CommitEditorPanel 2025-05-15 18:37:56 +02:00
Chris McDonnell 47f1d847f1 Rename OnCommitSuccess to ClearPreservedCommitMessage
In one case it was actually called *before* making a commit (when switching from
the commit message panel to committing in the editor). And clearing the
preserved message is the only thing it does, so name it after what it does
rather than when it's called.
2025-05-15 18:37:56 +02:00
Chris McDonnell 04bacbf9ce Remove the if statement from OnCommitSuccess
Previously it would only clear the message if the panel had been opened with
preserveMessage=true; we don't need this check, because callers know how they
opened the panel, and whether they want to clear the message.
2025-05-15 18:35:55 +02:00
Chris McDonnell 26e58b8def Remove the call to OnCommitSuccess from tag creation
This is no change in behavior because OnCommitSuccess only clears the message
when the commit message panel was opened with preserveMessage=true, which it
isn't in the case of creating a tag.

And this is in fact the desired behavior, because we don't want creating a tag
to interfere with preserving commit messages in any way.
2025-05-15 18:33:40 +02:00
Stefan Haller 5d30409f33 Add an alternate keybinding (default <c-s>) for ConfirmInEditor (#4532)
- **PR Description**

The default binding for ConfirmInEditor is <a-enter>, which has two
problems:
- some terminal emulators don't support it, including the default
terminal on Mac (Terminal.app)
- on Windows it is bound to toggling full-screen

Ideally we would use `<c-enter>` instead (and Command-Enter on Mac), but
neither is possible without https://github.com/gdamore/tcell/issues/671,
so for the time being add an alternate keybinding which works
everywhere.

Show both bindings in the footer of the commit description panel if they
are both non-null. While we're at it, fix the footer for the case where
either or both of the keybindings are set to `<disabled>`.

And finally, change "commit" to "submit" in that footer; we use the same
panel also for creating tags, in which case "commit" is not quite right.
2025-05-11 14:01:27 +02:00
Stefan Haller 450239d5c8 Add an alternate keybinding (default <c-s>) for ConfirmInEditor
The default binding for ConfirmInEditor is <a-enter>, which has two problems:
- some terminal emulators don't support it, including the default terminal on
  Mac (Terminal.app)
- on Windows it is bound to toggling full-screen

Ideally we would use <c-enter> instead (and Command-Enter on Mac), but neither
is possible without https://github.com/gdamore/tcell/issues/671, so for the time
being add an alternate keybinding which works everywhere.

Show both bindings in the footer of the commit description panel if they are
both non-null. While we're at it, fix the footer for the case where either or
both of the keybindings are set to <disabled>.

And finally, change "commit" to "submit" in that footer; we use the same panel
also for creating tags, in which case "commit" is not quite right.
2025-05-11 13:59:22 +02:00
Stefan Haller c3081ef02e Make '>' first jump to the beginning of the branch, and only then to the first commit (#4544)
- **PR Description**

In longer branches there's often the need to jump to the beginning of
the branch, e.g. in order to re-review all commits from the beginning.
There's no easy way to do this in lazygit.

In this PR I overload the "go to bottom" key (`>`) to jump to the first
commit of the current branch if the selection is above it, and only then
jump to the very bottom. I like that we don't need to introduce a new
key binding for this.
2025-05-11 13:59:02 +02:00
Stefan Haller 636b94cf22 Make '>' first jump to the beginning of the branch, and only then to the first commit 2025-05-11 13:55:43 +02:00
Stefan Haller 1a880f916c Remove unused keybinding handler
It's never called, the binding ListController.HandleGotoBottom wins.

The functionality of loading more commits is implemented by GetOnFocus, and this
way it works not only for '>', but also for other navigation keys like page
down.
2025-05-11 13:55:43 +02:00
Stefan Haller a27db87fb6 Add custom patch command "Move patch into new commit before the original commit" (#4552)
- **PR Description**

This is often useful to extract preparatory refactoring commits from a
bigger one. It works best when selecting only entire hunks or even
entire files; if partial hunks are in the patch, you are likely to get
conflicts.

Along the way, fix rewording merge commits. (Not because I find this
super important, but just because I came across the code while working
on this.)

- **Please check if the PR fulfills these requirements**

* [x] Cheatsheets are up-to-date (run `go generate ./...`)
* [x] Code has been formatted (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [x] Tests have been added/updated (see
[here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
for the integration test guide)
* [x] Text is internationalised (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
* [ ] If a new UserConfig entry was added, make sure it can be
hot-reloaded (see
[here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
* [ ] Docs have been updated if necessary
* [x] You've read through your own file changes for silly mistakes etc
2025-05-11 13:55:19 +02:00
Stefan Haller f6d13330dd Add custom patch command "Move patch into new commit before the original commit"
This is often useful to extract preparatory refactoring commits from a bigger
one.
2025-05-10 18:57:28 +02:00
Stefan Haller 5321101276 Use 'break' instead of 'edit' for BeginInteractiveRebaseForCommit with merge commit
BeginInteractiveRebaseForCommit is used for all the patch commands, and for
rewording. It works by setting the commit we want to stop at to 'edit'; this
doesn't work for merge commits. This wasn't a problem for the patch commands so
far, because you typically don't use custom patches with merge commits (although
we don't prevent this; maybe we should?).

However, it was a problem when you tried to reword a merge commit; this
previously failed with an error, as the test added in the previous commit
demonstrated.

Also, we want to add a new patch command that has to stop *before* the selected
commit (pull patch to new commit before the original one), and this wouldn't
work for the first commit in a feature branch, because it would have to set the
last commit before that to 'edit', which isn't possible if that's a merge (which
is likely).

To fix all this, use a 'break' before the selected commit if the commit is a
merge. It is important that we only do it in that case and not always, otherwise
we would break the new regression tests that were added a few commits ago.
2025-05-10 18:44:31 +02:00
Stefan Haller 50b2aa5843 Add test for rewording a merge commit
This currently fails with an error.
2025-05-10 18:19:35 +02:00
Stefan Haller b02441cdca Regression test for moving custom patch to new commit from last commit of a stacked branch
I almost broke this during the development of this branch, so add a test to
guard against that. The point here is that the stack remains intact, i.e. the
newly created commit is the last commit of the lower branch, and thus shows the
"*".
2025-05-10 18:19:35 +02:00
Stefan Haller 0c0f95168c Regression test for renaming the last commit of a stacked branch
I almost broke this during the development of this branch, so add a test to
guard against that. The point here is that the stack remains intact, i.e. the
renamed commit is the head of the lower branch, and thus shows the "*".
2025-05-10 18:19:09 +02:00
Stefan Haller 200b34ff28 Shorten commit hash in custom patch menu 2025-05-10 18:15:42 +02:00
Stefan Haller 144565ee03 Cleanup: remove unnecessary setup steps 2025-05-10 18:15:42 +02:00
Stefan Haller ef1da6f704 reword documentation for git.autoForwardBranches (#4545)
- **PR Description**

The wording "lazygit will automatically forward branches to their
upstream after fetching" could be interpreted to mean that lazygit
automatically pushes branches, rather than operating locally. Edited to
make the behavior more clear.

- **Please check if the PR fulfills these requirements**

* [x] Cheatsheets are up-to-date (run `go generate ./...`)
* [x] Code has been formatted (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [ ] Tests have been added/updated (see
[here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
for the integration test guide)
* [ ] Text is internationalised (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
* [ ] If a new UserConfig entry was added, make sure it can be
hot-reloaded (see
[here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
* [x] Docs have been updated if necessary
* [x] You've read through your own file changes for silly mistakes etc

<!--
Be sure to name your PR with an imperative e.g. 'Add worktrees view'
see https://github.com/jesseduffield/lazygit/releases/tag/v0.40.0 for
examples
-->
2025-05-09 08:48:02 +02:00
sean simonsen 4e497eef8a reword documentation for git.autoForwardBranches 2025-05-08 16:37:26 -04:00
Stefan Haller e6bd9d0ae6 Migrate deprecated AllBranchesLogCmd to AllBranchesLogCmds (#4345)
- **PR Description**

Fixes https://github.com/jesseduffield/lazygit/issues/3961

Their issue where the default `allBranchesLogCmd` default remains
present is because we just do a `lo.Uniq(lo.WithoutEmpty())` on the
combined list of `allBranchesLogCmd` and `allBranchesLogCmds`.

At the point of this code, it is not possible to tell whether the value
present in `allBranchesLogCmd` is user-provided or not. We have already
merged the config with the default config, so the user not setting
anything, and the user explicitly setting "Yes, I want the default", are
indistinguishable.

Based on that bug report, I'm assuming that users that have not set
anything for `allBranchesLogCmd`, but _have_ set something for
`allBranchesLogCmds`, just want the list they have specified in the
plural version. Some users have likely figured out they can explicitly
set `allBranchesLogCmd: ""` to get this behavior, but most would not.

To achieve this desired behavior, I figure it is easiest to just migrate
all user config to `allBranchesLogCmds`. If they have explicitly set a
non-empty value in `allBranchesLogCmd`, it will be pulled over. If they
set an empty string, it will be excluded.

- **Please check if the PR fulfills these requirements**

* [X] Cheatsheets are up-to-date (run `go generate ./...`)
* [X] Code has been formatted (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [X] Tests have been added/updated (see
[here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
for the integration test guide)
* [ ] Text is internationalised (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
* [ ] If a new UserConfig entry was added, make sure it can be
hot-reloaded (see
[here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
* [X] Docs have been updated if necessary
* [X] You've read through your own file changes for silly mistakes etc
2025-05-07 10:12:36 +02:00
Chris McDonnell 1028f8efb8 Migrate deprecated AllBranchesLogCmd to AllBranchesLogCmds
This solves https://github.com/jesseduffield/lazygit/issues/3961 because
we no longer have a combination of the default and the user defined
list. We just have the user defined list.
2025-05-06 23:33:17 -04:00
Stefan Haller c16c9f982f Clean up utils package (#4538)
- **PR Description**

The utils package is a bit of a heterogeneous bag of miscellaneous
things at different abstraction levels right now; ideally it should only
contain low-level utilities similar to the helpers in utils/slice.go.
Further cleanup is possible here, e.g. something like rebase_todo.go
shouldn't be in this utils package. This PR doesn't address that,
however.

The goal of this PR is just to make it possible to import utils from any
other package. Previously it wasn't possible to import it from config,
because some of the stuff in utils depended on the config package. So
here we move only those things to better places. See the individual
commit messages for details.
2025-05-06 13:07:30 +02:00
Stefan Haller 252dda5220 Move Loader to presentation package
It is very gui specific and shouldn't be in a low-level utils package.
2025-05-06 09:52:53 +02:00
Stefan Haller a6a68778ea Move NewDummyCommon to pkg/common
It's too special for a utils package, and it als makes sense to put it right
next to the thing that it is a dummy for.
2025-05-06 09:43:26 +02:00
Stefan Haller 223978eb82 Clean up the configuration of where a custom command's output goes (#4525)
- **PR Description**

Previously, custom commands had a `stream` field that was overloaded
with two meanings: 1) it made the command's output appear in the log
view, and 2) it used a pty for running the command. It makes sense to be
able to configure these independently, so add a separate `pty` field
(although that's probably rarely needed in practice).

Also, the `stream` and `showOutput` fields were conflicting; they could
be used together, but when setting them both to true, the popup would
always show "empty output", so this doesn't make sense. Combine them
both into a single `output` property with the possible values "none",
"log", or "popup".

We still have some more redundancy here, for example pty is only used
when output is set to "log", and neither output nor pty are used when
subprocess is true. But I stopped there, because I think this is already
an improvement over the previous state.

- **Please check if the PR fulfills these requirements**

* [x] Cheatsheets are up-to-date (run `go generate ./...`)
* [x] Code has been formatted (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [x] Tests have been added/updated (see
[here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
for the integration test guide)
* [ ] Text is internationalised (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
* [x] If a new UserConfig entry was added, make sure it can be
hot-reloaded (see
[here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
* [x] Docs have been updated if necessary
* [x] You've read through your own file changes for silly mistakes etc
2025-05-05 17:49:21 +02:00
Stefan Haller e8d365f1aa Add breaking change notice about 'stream: true' being converted to not use a pty any more 2025-05-01 16:09:10 +02:00
Stefan Haller a9f9dee30d Combine customCommand's subprocess, stream, and showOutput fields into a single output enum 2025-05-01 15:46:06 +02:00
Stefan HallerandChris McDonnell 5f4be3bfb7 Add yaml_utils.RemoveKey
Co-authored-by: Chris McDonnell <c.a.mcdonn@gmail.com>
2025-05-01 15:21:37 +02:00
Stefan Haller 02611dad7c Remove unused function UpdateYamlValue 2025-05-01 15:21:37 +02:00
Stefan Haller a199ed1396 Add separate UsePty flag for CmdObj
This decouples StreamOutput from whether a PTY is used. In most cases we just
want to see the output in the log window, but don't have to use a PTY, e.g. for
the bisect commands.

This has the implication that custom commands that are using "stream: true" no
longer use a PTY. In most cases that's probably a good thing, but we're going to
add a separate pty config for those who really wanted this.
2025-05-01 15:21:37 +02:00
Stefan Haller 8cf617b683 Rename getCmdHandler to getCmdHandlerPty, and add getCmdHandlerNonPty
getCmdHandlerNonPty is defined for all platforms.
2025-05-01 15:21:37 +02:00
Stefan Haller a400ef0079 Remove ICmdObj interface
It is only implemented by *CmdObj, so use that directly in client code.
2025-05-01 15:21:37 +02:00
Stefan Haller 4e3d09e9d8 Validate custom commands in sub menus 2025-05-01 15:21:27 +02:00
Stefan Haller e295477951 Add test demonstrating missing validation for custom commands in sub menus
We only validate the commands at top level right now.
2025-04-30 17:41:49 +02:00
Stefan Haller 7137196788 Add comments in tests to explain what they test
Looking at these again, I needed a moment to remember what they do, so make this
more obvious to help future readers.
2025-04-30 17:31:58 +02:00
109 changed files with 2358 additions and 843 deletions
+4 -2
View File
@@ -337,7 +337,7 @@ git:
# If true, periodically refresh files and submodules
autoRefresh: true
# If not "none", lazygit will automatically forward branches to 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).
# Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
autoForwardBranches: onlyMainBranches
@@ -354,7 +354,8 @@ git:
branchLogCmd: git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --
# Commands used to display git log of all branches in the main window, they will be cycled in order of appearance (array of strings)
allBranchesLogCmds: []
allBranchesLogCmds:
- git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium
# If true, do not spawn a separate process when using GPG
overrideGpg: false
@@ -515,6 +516,7 @@ keybinding:
goInto: <enter>
confirm: <enter>
confirmInEditor: <a-enter>
confirmInEditor-alt: <c-s>
remove: d
new: "n"
edit: e
+4 -6
View File
@@ -14,7 +14,7 @@ customCommands:
- key: 'C'
context: 'global'
command: "git commit"
subprocess: true
output: terminal
- key: 'n'
context: 'localBranches'
prompts:
@@ -53,13 +53,11 @@ For a given custom command, here are the allowed fields:
| key | The key to trigger the command. Use a single letter or one of the values from [here](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md). Custom commands without a key specified can be triggered by selecting them from the keybindings (`?`) menu | no |
| command | The command to run (using Go template syntax for placeholder values) | yes |
| context | The context in which to listen for the key (see [below](#contexts)) | yes |
| subprocess | Whether you want the command to run in a subprocess (e.g. if the command requires user input) | no |
| prompts | A list of prompts that will request user input before running the final command | no |
| loadingText | Text to display while waiting for command to finish | no |
| 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 |
| outputTitle | The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title. | no |
| output | Where the output of the command should go. 'none' discards it, 'terminal' suspends lazygit and runs the command in the terminal (useful for commands that require user input), 'log' streams it to the command log, 'logWithPty' is like 'log' but runs the command in a pseudo terminal (can be useful for commands that produce colored output when the output is a terminal), and 'popup' shows it in a popup. | no |
| outputTitle | The title to display in the popup panel if output is set to 'popup'. If left unset, the command will be used as the title. | no |
| after | Actions to take after the command has completed | no |
Here are the options for the `after` key:
@@ -365,7 +363,7 @@ If you use the commandMenu property, none of the other properties except key and
## Debugging
If you want to verify that your command actually does what you expect, you can wrap it in an 'echo' call and set `showOutput: true` so that it doesn't actually execute the command but you can see how the placeholders were resolved.
If you want to verify that your command actually does what you expect, you can wrap it in an 'echo' call and set `output: popup` so that it doesn't actually execute the command but you can see how the placeholders were resolved.
## More Examples
+6 -6
View File
@@ -13,9 +13,9 @@ require (
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.4.2
github.com/integrii/flaggy v1.4.0
github.com/jesseduffield/generics v0.0.0-20250406224309-4f541cb84918
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd
github.com/jesseduffield/gocui v0.3.1-0.20250421160159-82c9aaeba2b9
github.com/jesseduffield/gocui v0.3.1-0.20250522064656-07eb9218e08e
github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e
@@ -37,7 +37,7 @@ require (
github.com/stretchr/testify v1.10.0
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/sync v0.13.0
golang.org/x/sync v0.14.0
gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -78,9 +78,9 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
+12 -12
View File
@@ -190,12 +190,12 @@ github.com/invopop/jsonschema v0.10.0 h1:c1ktzNLBun3LyQQhyty5WE3lulbOdIIyOVlkmDL
github.com/invopop/jsonschema v0.10.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jesseduffield/generics v0.0.0-20250406224309-4f541cb84918 h1:meoUDZGF6jZAbhW5IBwj92mTqGmrOn+Cuu0jM7/aUcs=
github.com/jesseduffield/generics v0.0.0-20250406224309-4f541cb84918/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk=
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c h1:tC2PaiisXAC5sOjDPfMArSnbswDObtCssx+xn28edX4=
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c/go.mod h1:F2fEBk0ddf6ixrBrJjY7phfQ3hL9rXG0uSjvwYe50bE=
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd h1:ViKj6qth8FgcIWizn9KiACWwPemWSymx62OPN0tHT+Q=
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd/go.mod h1:lRhCiBr6XjQrvcQVa+UYsy/99d3wMXn/a0nSQlhnhlA=
github.com/jesseduffield/gocui v0.3.1-0.20250421160159-82c9aaeba2b9 h1:k23sCKHCNpAvwJP8Yr16CBUItuarmUHBGH7FaAm2glc=
github.com/jesseduffield/gocui v0.3.1-0.20250421160159-82c9aaeba2b9/go.mod h1:sLIyZ2J42R6idGdtemZzsiR3xY5EF0KsvYEGh3dQv3s=
github.com/jesseduffield/gocui v0.3.1-0.20250522064656-07eb9218e08e h1:NuSXZHAUSo+dSAYArlaDpmKgxVpojuKvNcRkYJcQxkY=
github.com/jesseduffield/gocui v0.3.1-0.20250522064656-07eb9218e08e/go.mod h1:sLIyZ2J42R6idGdtemZzsiR3xY5EF0KsvYEGh3dQv3s=
github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a h1:UDeJ3EBk04bXDLOPvuqM3on8HvyJfISw0+UMqW+0a4g=
github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a/go.mod h1:FSWDLKT0NQpntbDd1H3lbz51fhCVlMzy/J0S6nM727Q=
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY=
@@ -436,8 +436,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -490,8 +490,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -501,8 +501,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -517,8 +517,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+2 -2
View File
@@ -30,11 +30,11 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild
var defaultEnvVar = "GIT_OPTIONAL_LOCKS=0"
func (self *gitCmdObjBuilder) New(args []string) oscommands.ICmdObj {
func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj {
return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar)
}
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) oscommands.ICmdObj {
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj {
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar)
}
+4 -4
View File
@@ -20,12 +20,12 @@ type gitCmdObjRunner struct {
innerRunner oscommands.ICmdObjRunner
}
func (self *gitCmdObjRunner) Run(cmdObj oscommands.ICmdObj) error {
func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error {
_, err := self.RunWithOutput(cmdObj)
return err
}
func (self *gitCmdObjRunner) RunWithOutput(cmdObj oscommands.ICmdObj) (string, error) {
func (self *gitCmdObjRunner) RunWithOutput(cmdObj *oscommands.CmdObj) (string, error) {
var output string
var err error
for i := 0; i < RetryCount; i++ {
@@ -44,7 +44,7 @@ func (self *gitCmdObjRunner) RunWithOutput(cmdObj oscommands.ICmdObj) (string, e
return output, err
}
func (self *gitCmdObjRunner) RunWithOutputs(cmdObj oscommands.ICmdObj) (string, string, error) {
func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, string, error) {
var stdout, stderr string
var err error
for i := 0; i < RetryCount; i++ {
@@ -64,6 +64,6 @@ func (self *gitCmdObjRunner) RunWithOutputs(cmdObj oscommands.ICmdObj) (string,
}
// Retry logic not implemented here, but these commands typically don't need to obtain a lock.
func (self *gitCmdObjRunner) RunAndProcessLines(cmdObj oscommands.ICmdObj, onLine func(line string) (bool, error)) error {
func (self *gitCmdObjRunner) RunAndProcessLines(cmdObj *oscommands.CmdObj, onLine func(line string) (bool, error)) error {
return self.innerRunner.RunAndProcessLines(cmdObj, onLine)
}
+13 -11
View File
@@ -154,7 +154,7 @@ func (self *BranchCommands) GetGraph(branchName string) (string, error) {
return self.GetGraphCmdObj(branchName).DontLog().RunWithOutput()
}
func (self *BranchCommands) GetGraphCmdObj(branchName string) oscommands.ICmdObj {
func (self *BranchCommands) GetGraphCmdObj(branchName string) *oscommands.CmdObj {
branchLogCmdTemplate := self.UserConfig().Git.BranchLogCmd
templateValues := map[string]string{
"branchName": self.cmd.Quote(branchName),
@@ -255,22 +255,24 @@ func (self *BranchCommands) Merge(branchName string, opts MergeOpts) error {
return self.cmd.New(cmdArgs).Run()
}
func (self *BranchCommands) AllBranchesLogCmdObj() oscommands.ICmdObj {
// Only choose between non-empty, non-identical commands
candidates := lo.Uniq(lo.WithoutEmpty(append([]string{
self.UserConfig().Git.AllBranchesLogCmd,
},
self.UserConfig().Git.AllBranchesLogCmds...,
)))
// Only choose between non-empty, non-identical commands
func (self *BranchCommands) allBranchesLogCandidates() []string {
return lo.Uniq(lo.WithoutEmpty(self.UserConfig().Git.AllBranchesLogCmds))
}
n := len(candidates)
func (self *BranchCommands) AllBranchesLogCmdObj() *oscommands.CmdObj {
candidates := self.allBranchesLogCandidates()
i := self.allBranchesLogCmdIndex
self.allBranchesLogCmdIndex = uint8((int(i) + 1) % n)
return self.cmd.New(str.ToArgv(candidates[i])).DontLog()
}
func (self *BranchCommands) RotateAllBranchesLogIdx() {
n := len(self.allBranchesLogCandidates())
i := self.allBranchesLogCmdIndex
self.allBranchesLogCmdIndex = uint8((int(i) + 1) % n)
}
func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *MainBranches) (bool, error) {
branchesToCheckAgainst := []string{"HEAD"}
if branch.RemoteBranchStoredLocally() {
+10 -10
View File
@@ -85,7 +85,7 @@ func (self *CommitCommands) ResetToCommit(hash string, strength string, envVars
Run()
}
func (self *CommitCommands) CommitCmdObj(summary string, description string, forceSkipHooks bool) oscommands.ICmdObj {
func (self *CommitCommands) CommitCmdObj(summary string, description string, forceSkipHooks bool) *oscommands.CmdObj {
messageArgs := self.commitMessageArgs(summary, description)
skipHookPrefix := self.UserConfig().Git.SkipHookPrefix
cmdArgs := NewGitCmd("commit").
@@ -97,16 +97,16 @@ func (self *CommitCommands) CommitCmdObj(summary string, description string, for
return self.cmd.New(cmdArgs)
}
func (self *CommitCommands) RewordLastCommitInEditorCmdObj() oscommands.ICmdObj {
func (self *CommitCommands) RewordLastCommitInEditorCmdObj() *oscommands.CmdObj {
return self.cmd.New(NewGitCmd("commit").Arg("--allow-empty", "--amend", "--only").ToArgv())
}
func (self *CommitCommands) RewordLastCommitInEditorWithMessageFileCmdObj(tmpMessageFile string) oscommands.ICmdObj {
func (self *CommitCommands) RewordLastCommitInEditorWithMessageFileCmdObj(tmpMessageFile string) *oscommands.CmdObj {
return self.cmd.New(NewGitCmd("commit").
Arg("--allow-empty", "--amend", "--only", "--edit", "--file="+tmpMessageFile).ToArgv())
}
func (self *CommitCommands) CommitInEditorWithMessageFileCmdObj(tmpMessageFile string, forceSkipHooks bool) oscommands.ICmdObj {
func (self *CommitCommands) CommitInEditorWithMessageFileCmdObj(tmpMessageFile string, forceSkipHooks bool) *oscommands.CmdObj {
return self.cmd.New(NewGitCmd("commit").
ArgIf(forceSkipHooks, "--no-verify").
Arg("--edit").
@@ -116,7 +116,7 @@ func (self *CommitCommands) CommitInEditorWithMessageFileCmdObj(tmpMessageFile s
}
// RewordLastCommit rewords the topmost commit with the given message
func (self *CommitCommands) RewordLastCommit(summary string, description string) oscommands.ICmdObj {
func (self *CommitCommands) RewordLastCommit(summary string, description string) *oscommands.CmdObj {
messageArgs := self.commitMessageArgs(summary, description)
cmdArgs := NewGitCmd("commit").
@@ -138,7 +138,7 @@ func (self *CommitCommands) commitMessageArgs(summary string, description string
}
// runs git commit without the -m argument meaning it will invoke the user's editor
func (self *CommitCommands) CommitEditorCmdObj() oscommands.ICmdObj {
func (self *CommitCommands) CommitEditorCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("commit").
ArgIf(self.signoffFlag() != "", self.signoffFlag()).
ToArgv()
@@ -246,7 +246,7 @@ func (self *CommitCommands) AmendHead() error {
return self.AmendHeadCmdObj().Run()
}
func (self *CommitCommands) AmendHeadCmdObj() oscommands.ICmdObj {
func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("commit").
Arg("--amend", "--no-edit", "--allow-empty").
ToArgv()
@@ -254,7 +254,7 @@ func (self *CommitCommands) AmendHeadCmdObj() oscommands.ICmdObj {
return self.cmd.New(cmdArgs)
}
func (self *CommitCommands) ShowCmdObj(hash string, filterPath string) oscommands.ICmdObj {
func (self *CommitCommands) ShowCmdObj(hash string, filterPath string) *oscommands.CmdObj {
contextSize := self.AppState.DiffContextSize
extDiffCmd := self.UserConfig().Git.Paging.ExternalDiffCommand
@@ -271,14 +271,14 @@ func (self *CommitCommands) ShowCmdObj(hash string, filterPath string) oscommand
Arg(hash).
ArgIf(self.AppState.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg(fmt.Sprintf("--find-renames=%d%%", self.AppState.RenameSimilarityThreshold)).
ArgIf(filterPath != "", "--", filterPath).
ArgIf(filterPath != "" && !self.AppState.ShowFullDiffInFilterByPathMode, "--", filterPath).
Dir(self.repoPaths.worktreePath).
ToArgv()
return self.cmd.New(cmdArgs).DontLog()
}
func (self *CommitCommands) ShowFileContentCmdObj(hash string, filePath string) oscommands.ICmdObj {
func (self *CommitCommands) ShowFileContentCmdObj(hash string, filePath string) *oscommands.CmdObj {
cmdArgs := NewGitCmd("show").
Arg(fmt.Sprintf("%s:%s", hash, filePath)).
ToArgv()
+1 -1
View File
@@ -583,7 +583,7 @@ func (self *CommitLoader) getFirstPushedCommit(refName string) (string, error) {
}
// getLog gets the git log.
func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) *oscommands.CmdObj {
gitLogOrder := self.AppState.GitLogOrder
refSpec := opts.RefName
@@ -8,6 +8,7 @@ import (
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
@@ -296,7 +297,7 @@ func TestGetCommits(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.testName, func(t *testing.T) {
common := utils.NewDummyCommon()
common := common.NewDummyCommon()
common.AppState = &config.AppState{}
common.AppState.GitLogOrder = scenario.logOrder
cmd := oscommands.NewDummyCmdObjBuilder(scenario.runner)
@@ -516,7 +517,7 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.testName, func(t *testing.T) {
common := utils.NewDummyCommon()
common := common.NewDummyCommon()
builder := &CommitLoader{
Common: common,
+1 -2
View File
@@ -9,7 +9,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spf13/afero"
)
@@ -32,7 +31,7 @@ func buildGitCommon(deps commonDeps) *GitCommon {
gitCommon.Common = deps.common
if gitCommon.Common == nil {
gitCommon.Common = utils.NewDummyCommonWithUserConfigAndAppState(deps.userConfig, deps.appState)
gitCommon.Common = common.NewDummyCommonWithUserConfigAndAppState(deps.userConfig, deps.appState)
}
if deps.fs != nil {
+3 -3
View File
@@ -18,7 +18,7 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
// This is for generating diffs to be shown in the UI (e.g. rendering a range
// diff to the main view). It uses a custom pager if one is configured.
func (self *DiffCommands) DiffCmdObj(diffArgs []string) oscommands.ICmdObj {
func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
extDiffCmd := self.UserConfig().Git.Paging.ExternalDiffCommand
useExtDiff := extDiffCmd != ""
ignoreWhitespace := self.AppState.IgnoreWhitespaceInDiffView
@@ -83,7 +83,7 @@ type DiffToolCmdOptions struct {
Staged bool
}
func (self *DiffCommands) OpenDiffToolCmdObj(opts DiffToolCmdOptions) oscommands.ICmdObj {
func (self *DiffCommands) OpenDiffToolCmdObj(opts DiffToolCmdOptions) *oscommands.CmdObj {
return self.cmd.New(NewGitCmd("difftool").
Arg("--no-prompt").
ArgIf(opts.IsDirectory, "--dir-diff").
@@ -95,7 +95,7 @@ func (self *DiffCommands) OpenDiffToolCmdObj(opts DiffToolCmdOptions) oscommands
ToArgv())
}
func (self *DiffCommands) DiffIndexCmdObj(diffArgs ...string) oscommands.ICmdObj {
func (self *DiffCommands) DiffIndexCmdObj(diffArgs ...string) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff-index").
Config("diff.noprefix=false").
+2 -2
View File
@@ -24,7 +24,7 @@ func (self *FlowCommands) GitFlowEnabled() bool {
return self.config.GetGitFlowPrefixes() != ""
}
func (self *FlowCommands) FinishCmdObj(branchName string) (oscommands.ICmdObj, error) {
func (self *FlowCommands) FinishCmdObj(branchName string) (*oscommands.CmdObj, error) {
prefixes := self.config.GetGitFlowPrefixes()
// need to find out what kind of branch this is
@@ -54,7 +54,7 @@ func (self *FlowCommands) FinishCmdObj(branchName string) (oscommands.ICmdObj, e
return self.cmd.New(cmdArgs), nil
}
func (self *FlowCommands) StartCmdObj(branchType string, name string) oscommands.ICmdObj {
func (self *FlowCommands) StartCmdObj(branchType string, name string) *oscommands.CmdObj {
cmdArgs := NewGitCmd("flow").Arg(branchType, "start", name).ToArgv()
return self.cmd.New(cmdArgs)
+23
View File
@@ -314,6 +314,29 @@ func (self *PatchCommands) PullPatchIntoNewCommit(
return self.rebase.ContinueRebase()
}
func (self *PatchCommands) PullPatchIntoNewCommitBefore(
commits []*models.Commit,
commitIdx int,
commitSummary string,
commitDescription string,
) error {
if err := self.rebase.BeginInteractiveRebaseForCommit(commits, commitIdx+1, true); err != nil {
return err
}
if err := self.ApplyCustomPatch(false, false); err != nil {
_ = self.rebase.AbortRebase()
return err
}
if err := self.commit.CommitCmdObj(commitSummary, commitDescription, false).Run(); err != nil {
return err
}
self.PatchBuilder.Reset()
return self.rebase.ContinueRebase()
}
// We have just applied a patch in reverse to discard it from a commit; if we
// now try to apply the patch again to move it to a later commit, or to the
// index, then this would conflict "with itself" in case the patch contained
+17 -5
View File
@@ -53,7 +53,7 @@ func (self *RebaseCommands) RewordCommit(commits []*models.Commit, index int, su
return self.ContinueRebase()
}
func (self *RebaseCommands) RewordCommitInEditor(commits []*models.Commit, index int) (oscommands.ICmdObj, error) {
func (self *RebaseCommands) RewordCommitInEditor(commits []*models.Commit, index int) (*oscommands.CmdObj, error) {
changes := []daemon.ChangeTodoAction{{
Hash: commits[index].Hash(),
NewAction: todo.Reword,
@@ -209,7 +209,7 @@ type PrepareInteractiveRebaseCommandOpts struct {
// PrepareInteractiveRebaseCommand returns the cmd for an interactive rebase
// we tell git to run lazygit to edit the todo list, and we pass the client
// lazygit instructions what to do with the todo file
func (self *RebaseCommands) PrepareInteractiveRebaseCommand(opts PrepareInteractiveRebaseCommandOpts) oscommands.ICmdObj {
func (self *RebaseCommands) PrepareInteractiveRebaseCommand(opts PrepareInteractiveRebaseCommandOpts) *oscommands.CmdObj {
ex := oscommands.GetLazygitPath()
cmdArgs := NewGitCmd("rebase").
@@ -400,7 +400,19 @@ func (self *RebaseCommands) SquashAllAboveFixupCommits(commit *models.Commit) er
func (self *RebaseCommands) BeginInteractiveRebaseForCommit(
commits []*models.Commit, commitIndex int, keepCommitsThatBecomeEmpty bool,
) error {
return self.BeginInteractiveRebaseForCommitRange(commits, commitIndex, commitIndex, keepCommitsThatBecomeEmpty)
if commitIndex < len(commits) && commits[commitIndex].IsMerge() {
if self.config.NeedsGpgSubprocessForCommit() {
return errors.New(self.Tr.DisabledForGPG)
}
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: getBaseHashOrRoot(commits, commitIndex),
instruction: daemon.NewInsertBreakInstruction(),
keepCommitsThatBecomeEmpty: keepCommitsThatBecomeEmpty,
}).Run()
} else {
return self.BeginInteractiveRebaseForCommitRange(commits, commitIndex, commitIndex, keepCommitsThatBecomeEmpty)
}
}
func (self *RebaseCommands) BeginInteractiveRebaseForCommitRange(
@@ -446,7 +458,7 @@ func (self *RebaseCommands) RebaseBranchFromBaseCommit(targetBranchName string,
}).Run()
}
func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(commandType string, command string) oscommands.ICmdObj {
func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(commandType string, command string) *oscommands.CmdObj {
cmdArgs := NewGitCmd(commandType).Arg("--" + command).ToArgv()
return self.cmd.New(cmdArgs)
@@ -485,7 +497,7 @@ func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, comma
return nil
}
func (self *RebaseCommands) runSkipEditorCommand(cmdObj oscommands.ICmdObj) error {
func (self *RebaseCommands) runSkipEditorCommand(cmdObj *oscommands.CmdObj) error {
instruction := daemon.NewExitImmediatelyInstruction()
lazyGitPath := oscommands.GetLazygitPath()
return cmdObj.
+1 -1
View File
@@ -69,7 +69,7 @@ func TestRebaseRebaseBranch(t *testing.T) {
// environment variables that suppress an interactive editor
func TestRebaseSkipEditorCommand(t *testing.T) {
cmdArgs := []string{"git", "blah"}
runner := oscommands.NewFakeRunner(t).ExpectFunc("matches editor env var", func(cmdObj oscommands.ICmdObj) bool {
runner := oscommands.NewFakeRunner(t).ExpectFunc("matches editor env var", func(cmdObj *oscommands.CmdObj) bool {
assert.EqualValues(t, cmdArgs, cmdObj.Args())
envVars := cmdObj.GetEnvVars()
for _, regexStr := range []string{
@@ -7,6 +7,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sanity-io/litter"
@@ -181,7 +182,7 @@ func TestGetReflogCommits(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.testName, func(t *testing.T) {
builder := &ReflogCommitLoader{
Common: utils.NewDummyCommon(),
Common: common.NewDummyCommon(),
cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner),
}
+1 -1
View File
@@ -80,7 +80,7 @@ func (self *StashCommands) Hash(index int) (string, error) {
return strings.Trim(hash, "\r\n"), err
}
func (self *StashCommands) ShowStashEntryCmdObj(index int) oscommands.ICmdObj {
func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj {
// "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason
cmdArgs := NewGitCmd("stash").Arg("show").
Arg("-p").
@@ -5,7 +5,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/stretchr/testify/assert"
)
@@ -50,7 +50,7 @@ func TestGetStashEntries(t *testing.T) {
t.Run(s.testName, func(t *testing.T) {
cmd := oscommands.NewDummyCmdObjBuilder(s.runner)
loader := NewStashLoader(utils.NewDummyCommon(), cmd)
loader := NewStashLoader(common.NewDummyCommon(), cmd)
assert.EqualValues(t, s.expectedStashEntries, loader.GetStashEntries(s.filterPath))
})
+5 -5
View File
@@ -237,35 +237,35 @@ func (self *SubmoduleCommands) Update(path string) error {
return self.cmd.New(cmdArgs).Run()
}
func (self *SubmoduleCommands) BulkInitCmdObj() oscommands.ICmdObj {
func (self *SubmoduleCommands) BulkInitCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("init").
ToArgv()
return self.cmd.New(cmdArgs)
}
func (self *SubmoduleCommands) BulkUpdateCmdObj() oscommands.ICmdObj {
func (self *SubmoduleCommands) BulkUpdateCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("update").
ToArgv()
return self.cmd.New(cmdArgs)
}
func (self *SubmoduleCommands) ForceBulkUpdateCmdObj() oscommands.ICmdObj {
func (self *SubmoduleCommands) ForceBulkUpdateCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("update", "--force").
ToArgv()
return self.cmd.New(cmdArgs)
}
func (self *SubmoduleCommands) BulkUpdateRecursivelyCmdObj() oscommands.ICmdObj {
func (self *SubmoduleCommands) BulkUpdateRecursivelyCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("update", "--init", "--recursive").
ToArgv()
return self.cmd.New(cmdArgs)
}
func (self *SubmoduleCommands) BulkDeinitCmdObj() oscommands.ICmdObj {
func (self *SubmoduleCommands) BulkDeinitCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("deinit", "--all", "--force").
ToArgv()
+3 -3
View File
@@ -28,7 +28,7 @@ type PushOpts struct {
SetUpstream bool
}
func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (oscommands.ICmdObj, error) {
func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommands.CmdObj, error) {
if opts.UpstreamBranch != "" && opts.UpstreamRemote == "" {
return nil, errors.New(self.Tr.MustSpecifyOriginError)
}
@@ -62,7 +62,7 @@ func (self *SyncCommands) fetchCommandBuilder(fetchAll bool) *GitCommandBuilder
ArgIf(self.version.IsAtLeast(2, 29, 0), "--no-write-fetch-head")
}
func (self *SyncCommands) FetchCmdObj(task gocui.Task) oscommands.ICmdObj {
func (self *SyncCommands) FetchCmdObj(task gocui.Task) *oscommands.CmdObj {
cmdArgs := self.fetchCommandBuilder(self.UserConfig().Git.FetchAll).ToArgv()
cmdObj := self.cmd.New(cmdArgs)
@@ -74,7 +74,7 @@ func (self *SyncCommands) Fetch(task gocui.Task) error {
return self.FetchCmdObj(task).Run()
}
func (self *SyncCommands) FetchBackgroundCmdObj() oscommands.ICmdObj {
func (self *SyncCommands) FetchBackgroundCmdObj() *oscommands.CmdObj {
cmdArgs := self.fetchCommandBuilder(self.UserConfig().Git.FetchAll).ToArgv()
cmdObj := self.cmd.New(cmdArgs)
+14 -14
View File
@@ -12,14 +12,14 @@ func TestSyncPush(t *testing.T) {
type scenario struct {
testName string
opts PushOpts
test func(oscommands.ICmdObj, error)
test func(*oscommands.CmdObj, error)
}
scenarios := []scenario{
{
testName: "Push with force disabled",
opts: PushOpts{ForceWithLease: false},
test: func(cmdObj oscommands.ICmdObj, err error) {
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push"})
assert.NoError(t, err)
},
@@ -27,7 +27,7 @@ func TestSyncPush(t *testing.T) {
{
testName: "Push with force-with-lease enabled",
opts: PushOpts{ForceWithLease: true},
test: func(cmdObj oscommands.ICmdObj, err error) {
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force-with-lease"})
assert.NoError(t, err)
},
@@ -35,7 +35,7 @@ func TestSyncPush(t *testing.T) {
{
testName: "Push with force enabled",
opts: PushOpts{Force: true},
test: func(cmdObj oscommands.ICmdObj, err error) {
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force"})
assert.NoError(t, err)
},
@@ -48,7 +48,7 @@ func TestSyncPush(t *testing.T) {
UpstreamRemote: "origin",
UpstreamBranch: "master",
},
test: func(cmdObj oscommands.ICmdObj, err error) {
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "origin", "refs/heads/master:master"})
assert.NoError(t, err)
},
@@ -62,7 +62,7 @@ func TestSyncPush(t *testing.T) {
UpstreamBranch: "master",
SetUpstream: true,
},
test: func(cmdObj oscommands.ICmdObj, err error) {
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--set-upstream", "origin", "refs/heads/master-local:master"})
assert.NoError(t, err)
},
@@ -76,7 +76,7 @@ func TestSyncPush(t *testing.T) {
UpstreamBranch: "master",
SetUpstream: true,
},
test: func(cmdObj oscommands.ICmdObj, err error) {
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force-with-lease", "--set-upstream", "origin", "refs/heads/master:master"})
assert.NoError(t, err)
},
@@ -89,7 +89,7 @@ func TestSyncPush(t *testing.T) {
UpstreamBranch: "master",
SetUpstream: true,
},
test: func(cmdObj oscommands.ICmdObj, err error) {
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Error(t, err)
assert.EqualValues(t, "Must specify a remote if specifying a branch", err.Error())
},
@@ -109,14 +109,14 @@ func TestSyncFetch(t *testing.T) {
type scenario struct {
testName string
fetchAllConfig bool
test func(oscommands.ICmdObj)
test func(*oscommands.CmdObj)
}
scenarios := []scenario{
{
testName: "Fetch in foreground (all=false)",
fetchAllConfig: false,
test: func(cmdObj oscommands.ICmdObj) {
test: func(cmdObj *oscommands.CmdObj) {
assert.True(t, cmdObj.ShouldLog())
assert.Equal(t, cmdObj.GetCredentialStrategy(), oscommands.PROMPT)
assert.Equal(t, cmdObj.Args(), []string{"git", "fetch"})
@@ -125,7 +125,7 @@ func TestSyncFetch(t *testing.T) {
{
testName: "Fetch in foreground (all=true)",
fetchAllConfig: true,
test: func(cmdObj oscommands.ICmdObj) {
test: func(cmdObj *oscommands.CmdObj) {
assert.True(t, cmdObj.ShouldLog())
assert.Equal(t, cmdObj.GetCredentialStrategy(), oscommands.PROMPT)
assert.Equal(t, cmdObj.Args(), []string{"git", "fetch", "--all"})
@@ -147,14 +147,14 @@ func TestSyncFetchBackground(t *testing.T) {
type scenario struct {
testName string
fetchAllConfig bool
test func(oscommands.ICmdObj)
test func(*oscommands.CmdObj)
}
scenarios := []scenario{
{
testName: "Fetch in background (all=false)",
fetchAllConfig: false,
test: func(cmdObj oscommands.ICmdObj) {
test: func(cmdObj *oscommands.CmdObj) {
assert.False(t, cmdObj.ShouldLog())
assert.Equal(t, cmdObj.GetCredentialStrategy(), oscommands.FAIL)
assert.Equal(t, cmdObj.Args(), []string{"git", "fetch"})
@@ -163,7 +163,7 @@ func TestSyncFetchBackground(t *testing.T) {
{
testName: "Fetch in background (all=true)",
fetchAllConfig: true,
test: func(cmdObj oscommands.ICmdObj) {
test: func(cmdObj *oscommands.CmdObj) {
assert.False(t, cmdObj.ShouldLog())
assert.Equal(t, cmdObj.GetCredentialStrategy(), oscommands.FAIL)
assert.Equal(t, cmdObj.Args(), []string{"git", "fetch", "--all"})
+2 -2
View File
@@ -15,7 +15,7 @@ func NewTagCommands(gitCommon *GitCommon) *TagCommands {
}
}
func (self *TagCommands) CreateLightweightObj(tagName string, ref string, force bool) oscommands.ICmdObj {
func (self *TagCommands) CreateLightweightObj(tagName string, ref string, force bool) *oscommands.CmdObj {
cmdArgs := NewGitCmd("tag").
ArgIf(force, "--force").
Arg("--", tagName).
@@ -25,7 +25,7 @@ func (self *TagCommands) CreateLightweightObj(tagName string, ref string, force
return self.cmd.New(cmdArgs)
}
func (self *TagCommands) CreateAnnotatedObj(tagName, ref, msg string, force bool) oscommands.ICmdObj {
func (self *TagCommands) CreateAnnotatedObj(tagName, ref, msg string, force bool) *oscommands.CmdObj {
cmdArgs := NewGitCmd("tag").Arg(tagName).
ArgIf(force, "--force").
ArgIf(len(ref) > 0, ref).
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/stretchr/testify/assert"
)
@@ -46,7 +46,7 @@ func TestGetTags(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.testName, func(t *testing.T) {
loader := &TagLoader{
Common: utils.NewDummyCommon(),
Common: common.NewDummyCommon(),
cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner),
}
+3 -3
View File
@@ -29,7 +29,7 @@ func NewWorkingTreeCommands(
}
}
func (self *WorkingTreeCommands) OpenMergeToolCmdObj() oscommands.ICmdObj {
func (self *WorkingTreeCommands) OpenMergeToolCmdObj() *oscommands.CmdObj {
return self.cmd.New(NewGitCmd("mergetool").ToArgv())
}
@@ -255,7 +255,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool,
return s
}
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool) oscommands.ICmdObj {
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool) *oscommands.CmdObj {
colorArg := self.UserConfig().Git.Paging.ColorArg
if plain {
colorArg = "never"
@@ -293,7 +293,7 @@ func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bo
return self.ShowFileDiffCmdObj(from, to, reverse, fileName, plain).RunWithOutput()
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileName string, plain bool) oscommands.ICmdObj {
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileName string, plain bool) *oscommands.CmdObj {
contextSize := self.AppState.DiffContextSize
colorArg := self.UserConfig().Git.Paging.ColorArg
+57 -80
View File
@@ -11,70 +11,6 @@ import (
// A command object is a general way to represent a command to be run on the
// command line.
type ICmdObj interface {
GetCmd() *exec.Cmd
// outputs string representation of command. Note that if the command was built
// using NewFromArgs, the output won't be quite the same as what you would type
// into a terminal e.g. 'sh -c git commit' as opposed to 'sh -c "git commit"'
ToString() string
// outputs args vector e.g. ["git", "commit", "-m", "my message"]
Args() []string
// Set a string to be used as stdin for the command.
SetStdin(input string) ICmdObj
AddEnvVars(...string) ICmdObj
GetEnvVars() []string
// sets the working directory
SetWd(string) ICmdObj
// runs the command and returns an error if any
Run() error
// runs the command and returns the output as a string, and an error if any
RunWithOutput() (string, error)
// runs the command and returns stdout and stderr as a string, and an error if any
RunWithOutputs() (string, string, error)
// runs the command and runs a callback function on each line of the output. If the callback returns true for the boolean value, we kill the process and return.
RunAndProcessLines(onLine func(line string) (bool, error)) error
// Be calling DontLog(), we're saying that once we call Run(), we don't want to
// log the command in the UI (it'll still be logged in the log file). The general rule
// is that if a command doesn't change the git state (e.g. read commands like `git diff`)
// then we don't want to log it. If we are changing something (e.g. `git add .`) then
// we do. The only exception is if we're running a command in the background periodically
// like `git fetch`, which technically does mutate stuff but isn't something we need
// to notify the user about.
DontLog() ICmdObj
// This returns false if DontLog() was called
ShouldLog() bool
// when you call this, then call Run(), we'll stream the output to the cmdWriter (i.e. the command log panel)
StreamOutput() ICmdObj
// returns true if StreamOutput() was called
ShouldStreamOutput() bool
// if you call this before ShouldStreamOutput we'll consider an error with no
// stderr content as a non-error. Not yet supported for Run or RunWithOutput (
// but adding support is trivial)
IgnoreEmptyError() ICmdObj
// returns true if IgnoreEmptyError() was called
ShouldIgnoreEmptyError() bool
PromptOnCredentialRequest(task gocui.Task) ICmdObj
FailOnCredentialRequest() ICmdObj
WithMutex(mutex *deadlock.Mutex) ICmdObj
Mutex() *deadlock.Mutex
GetCredentialStrategy() CredentialStrategy
GetTask() gocui.Task
Clone() ICmdObj
}
type CmdObj struct {
cmd *exec.Cmd
@@ -86,6 +22,9 @@ type CmdObj struct {
// see StreamOutput()
streamOutput bool
// see UsePty()
usePty bool
// see IgnoreEmptyError()
ignoreEmptyError bool
@@ -112,12 +51,13 @@ const (
FAIL
)
var _ ICmdObj = &CmdObj{}
func (self *CmdObj) GetCmd() *exec.Cmd {
return self.cmd
}
// outputs string representation of command. Note that if the command was built
// using NewFromArgs, the output won't be quite the same as what you would type
// into a terminal e.g. 'sh -c git commit' as opposed to 'sh -c "git commit"'
func (self *CmdObj) ToString() string {
// if a given arg contains a space, we need to wrap it in quotes
quotedArgs := lo.Map(self.cmd.Args, func(arg string, _ int) string {
@@ -130,17 +70,19 @@ func (self *CmdObj) ToString() string {
return strings.Join(quotedArgs, " ")
}
// outputs args vector e.g. ["git", "commit", "-m", "my message"]
func (self *CmdObj) Args() []string {
return self.cmd.Args
}
func (self *CmdObj) SetStdin(input string) ICmdObj {
// Set a string to be used as stdin for the command.
func (self *CmdObj) SetStdin(input string) *CmdObj {
self.cmd.Stdin = strings.NewReader(input)
return self
}
func (self *CmdObj) AddEnvVars(vars ...string) ICmdObj {
func (self *CmdObj) AddEnvVars(vars ...string) *CmdObj {
self.cmd.Env = append(self.cmd.Env, vars...)
return self
@@ -150,76 +92,111 @@ func (self *CmdObj) GetEnvVars() []string {
return self.cmd.Env
}
func (self *CmdObj) SetWd(wd string) ICmdObj {
// sets the working directory
func (self *CmdObj) SetWd(wd string) *CmdObj {
self.cmd.Dir = wd
return self
}
func (self *CmdObj) DontLog() ICmdObj {
// By calling DontLog(), we're saying that once we call Run(), we don't want to
// log the command in the UI (it'll still be logged in the log file). The general rule
// is that if a command doesn't change the git state (e.g. read commands like `git diff`)
// then we don't want to log it. If we are changing something (e.g. `git add .`) then
// we do. The only exception is if we're running a command in the background periodically
// like `git fetch`, which technically does mutate stuff but isn't something we need
// to notify the user about.
func (self *CmdObj) DontLog() *CmdObj {
self.dontLog = true
return self
}
// This returns false if DontLog() was called
func (self *CmdObj) ShouldLog() bool {
return !self.dontLog
}
func (self *CmdObj) StreamOutput() ICmdObj {
// when you call this, then call Run(), we'll stream the output to the cmdWriter (i.e. the command log panel)
func (self *CmdObj) StreamOutput() *CmdObj {
self.streamOutput = true
return self
}
// returns true if StreamOutput() was called
func (self *CmdObj) ShouldStreamOutput() bool {
return self.streamOutput
}
func (self *CmdObj) IgnoreEmptyError() ICmdObj {
// when you call this, then call Run(), we'll use a PTY to run the command. Only
// has an effect if StreamOutput() was also called. Ignored on Windows.
func (self *CmdObj) UsePty() *CmdObj {
self.usePty = true
return self
}
// returns true if UsePty() was called
func (self *CmdObj) ShouldUsePty() bool {
return self.usePty
}
// if you call this before ShouldStreamOutput we'll consider an error with no
// stderr content as a non-error. Not yet supported for Run or RunWithOutput (
// but adding support is trivial)
func (self *CmdObj) IgnoreEmptyError() *CmdObj {
self.ignoreEmptyError = true
return self
}
// returns true if IgnoreEmptyError() was called
func (self *CmdObj) ShouldIgnoreEmptyError() bool {
return self.ignoreEmptyError
}
func (self *CmdObj) Mutex() *deadlock.Mutex {
return self.mutex
}
func (self *CmdObj) WithMutex(mutex *deadlock.Mutex) ICmdObj {
func (self *CmdObj) WithMutex(mutex *deadlock.Mutex) *CmdObj {
self.mutex = mutex
return self
}
func (self *CmdObj) ShouldIgnoreEmptyError() bool {
return self.ignoreEmptyError
}
// runs the command and returns an error if any
func (self *CmdObj) Run() error {
return self.runner.Run(self)
}
// runs the command and returns the output as a string, and an error if any
func (self *CmdObj) RunWithOutput() (string, error) {
return self.runner.RunWithOutput(self)
}
// runs the command and returns stdout and stderr as a string, and an error if any
func (self *CmdObj) RunWithOutputs() (string, string, error) {
return self.runner.RunWithOutputs(self)
}
// runs the command and runs a callback function on each line of the output. If the callback
// returns true for the boolean value, we kill the process and return.
func (self *CmdObj) RunAndProcessLines(onLine func(line string) (bool, error)) error {
return self.runner.RunAndProcessLines(self, onLine)
}
func (self *CmdObj) PromptOnCredentialRequest(task gocui.Task) ICmdObj {
func (self *CmdObj) PromptOnCredentialRequest(task gocui.Task) *CmdObj {
self.credentialStrategy = PROMPT
self.usePty = true
self.task = task
return self
}
func (self *CmdObj) FailOnCredentialRequest() ICmdObj {
func (self *CmdObj) FailOnCredentialRequest() *CmdObj {
self.credentialStrategy = FAIL
self.usePty = true
return self
}
@@ -232,7 +209,7 @@ func (self *CmdObj) GetTask() gocui.Task {
return self.task
}
func (self *CmdObj) Clone() ICmdObj {
func (self *CmdObj) Clone() *CmdObj {
clone := &CmdObj{}
*clone = *self
clone.cmd = cloneCmd(self.cmd)
+5 -5
View File
@@ -11,11 +11,11 @@ import (
type ICmdObjBuilder interface {
// NewFromArgs takes a slice of strings like []string{"git", "commit"} and returns a new command object.
New(args []string) ICmdObj
New(args []string) *CmdObj
// NewShell takes a string like `git commit` and returns an executable shell command for it e.g. `sh -c 'git commit'`
// shellFunctionsFile is an optional file path that will be sourced before executing the command. Callers should pass
// the value of UserConfig.OS.ShellFunctionsFile.
NewShell(commandStr string, shellFunctionsFile string) ICmdObj
NewShell(commandStr string, shellFunctionsFile string) *CmdObj
// Quote wraps a string in quotes with any necessary escaping applied. The reason for bundling this up with the other methods in this interface is that we basically always need to make use of this when creating new command objects.
Quote(str string) string
}
@@ -28,13 +28,13 @@ type CmdObjBuilder struct {
// poor man's version of explicitly saying that struct X implements interface Y
var _ ICmdObjBuilder = &CmdObjBuilder{}
func (self *CmdObjBuilder) New(args []string) ICmdObj {
func (self *CmdObjBuilder) New(args []string) *CmdObj {
cmdObj := self.NewWithEnviron(args, os.Environ())
return cmdObj
}
// A command with explicit environment from env
func (self *CmdObjBuilder) NewWithEnviron(args []string, env []string) ICmdObj {
func (self *CmdObjBuilder) NewWithEnviron(args []string, env []string) *CmdObj {
cmd := exec.Command(args[0], args[1:]...)
cmd.Env = env
@@ -44,7 +44,7 @@ func (self *CmdObjBuilder) NewWithEnviron(args []string, env []string) ICmdObj {
}
}
func (self *CmdObjBuilder) NewShell(commandStr string, shellFunctionsFile string) ICmdObj {
func (self *CmdObjBuilder) NewShell(commandStr string, shellFunctionsFile string) *CmdObj {
if len(shellFunctionsFile) > 0 {
commandStr = fmt.Sprintf("%ssource %s\n%s", self.platform.PrefixForShellFunctionsFile, shellFunctionsFile, commandStr)
}
+60 -17
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"io"
"os/exec"
"regexp"
"strings"
"time"
@@ -11,14 +12,15 @@ import (
"github.com/go-errors/errors"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
type ICmdObjRunner interface {
Run(cmdObj ICmdObj) error
RunWithOutput(cmdObj ICmdObj) (string, error)
RunWithOutputs(cmdObj ICmdObj) (string, string, error)
RunAndProcessLines(cmdObj ICmdObj, onLine func(line string) (bool, error)) error
Run(cmdObj *CmdObj) error
RunWithOutput(cmdObj *CmdObj) (string, error)
RunWithOutputs(cmdObj *CmdObj) (string, string, error)
RunAndProcessLines(cmdObj *CmdObj, onLine func(line string) (bool, error)) error
}
type cmdObjRunner struct {
@@ -28,7 +30,7 @@ type cmdObjRunner struct {
var _ ICmdObjRunner = &cmdObjRunner{}
func (self *cmdObjRunner) Run(cmdObj ICmdObj) error {
func (self *cmdObjRunner) Run(cmdObj *CmdObj) error {
if cmdObj.Mutex() != nil {
cmdObj.Mutex().Lock()
defer cmdObj.Mutex().Unlock()
@@ -46,7 +48,7 @@ func (self *cmdObjRunner) Run(cmdObj ICmdObj) error {
return err
}
func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
func (self *cmdObjRunner) RunWithOutput(cmdObj *CmdObj) (string, error) {
if cmdObj.Mutex() != nil {
cmdObj.Mutex().Lock()
defer cmdObj.Mutex().Unlock()
@@ -71,7 +73,7 @@ func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
return self.RunWithOutputAux(cmdObj)
}
func (self *cmdObjRunner) RunWithOutputs(cmdObj ICmdObj) (string, string, error) {
func (self *cmdObjRunner) RunWithOutputs(cmdObj *CmdObj) (string, string, error) {
if cmdObj.Mutex() != nil {
cmdObj.Mutex().Lock()
defer cmdObj.Mutex().Unlock()
@@ -96,7 +98,7 @@ func (self *cmdObjRunner) RunWithOutputs(cmdObj ICmdObj) (string, string, error)
return self.RunWithOutputsAux(cmdObj)
}
func (self *cmdObjRunner) RunWithOutputAux(cmdObj ICmdObj) (string, error) {
func (self *cmdObjRunner) RunWithOutputAux(cmdObj *CmdObj) (string, error) {
self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand")
if cmdObj.ShouldLog() {
@@ -114,7 +116,7 @@ func (self *cmdObjRunner) RunWithOutputAux(cmdObj ICmdObj) (string, error) {
return output, err
}
func (self *cmdObjRunner) RunWithOutputsAux(cmdObj ICmdObj) (string, string, error) {
func (self *cmdObjRunner) RunWithOutputsAux(cmdObj *CmdObj) (string, string, error) {
self.log.WithField("command", cmdObj.ToString()).Debug("RunCommand")
if cmdObj.ShouldLog() {
@@ -139,7 +141,7 @@ func (self *cmdObjRunner) RunWithOutputsAux(cmdObj ICmdObj) (string, string, err
return stdout, stderr, err
}
func (self *cmdObjRunner) RunAndProcessLines(cmdObj ICmdObj, onLine func(line string) (bool, error)) error {
func (self *cmdObjRunner) RunAndProcessLines(cmdObj *CmdObj, onLine func(line string) (bool, error)) error {
if cmdObj.Mutex() != nil {
cmdObj.Mutex().Lock()
defer cmdObj.Mutex().Unlock()
@@ -190,7 +192,7 @@ func (self *cmdObjRunner) RunAndProcessLines(cmdObj ICmdObj, onLine func(line st
return nil
}
func (self *cmdObjRunner) logCmdObj(cmdObj ICmdObj) {
func (self *cmdObjRunner) logCmdObj(cmdObj *CmdObj) {
self.guiIO.logCommandFn(cmdObj.ToString(), true)
}
@@ -213,7 +215,7 @@ type cmdHandler struct {
close func() error
}
func (self *cmdObjRunner) runAndStream(cmdObj ICmdObj) error {
func (self *cmdObjRunner) runAndStream(cmdObj *CmdObj) error {
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
go func() {
_, _ = io.Copy(cmdWriter, handler.stdoutPipe)
@@ -222,7 +224,7 @@ func (self *cmdObjRunner) runAndStream(cmdObj ICmdObj) error {
}
func (self *cmdObjRunner) runAndStreamAux(
cmdObj ICmdObj,
cmdObj *CmdObj,
onRun func(*cmdHandler, io.Writer),
) error {
cmdWriter := self.guiIO.newCmdWriterFn()
@@ -236,7 +238,13 @@ func (self *cmdObjRunner) runAndStreamAux(
var stderr bytes.Buffer
cmd.Stderr = io.MultiWriter(cmdWriter, &stderr)
handler, err := self.getCmdHandler(cmd)
var handler *cmdHandler
var err error
if cmdObj.ShouldUsePty() {
handler, err = self.getCmdHandlerPty(cmd)
} else {
handler, err = self.getCmdHandlerNonPty(cmd)
}
if err != nil {
return err
}
@@ -297,7 +305,7 @@ var failPromptFn = func(CredentialType) <-chan string {
return ch
}
func (self *cmdObjRunner) runWithCredentialHandling(cmdObj ICmdObj) error {
func (self *cmdObjRunner) runWithCredentialHandling(cmdObj *CmdObj) error {
promptFn, err := self.getCredentialPromptFn(cmdObj)
if err != nil {
return err
@@ -306,7 +314,7 @@ func (self *cmdObjRunner) runWithCredentialHandling(cmdObj ICmdObj) error {
return self.runAndDetectCredentialRequest(cmdObj, promptFn)
}
func (self *cmdObjRunner) getCredentialPromptFn(cmdObj ICmdObj) (func(CredentialType) <-chan string, error) {
func (self *cmdObjRunner) getCredentialPromptFn(cmdObj *CmdObj) (func(CredentialType) <-chan string, error) {
switch cmdObj.GetCredentialStrategy() {
case PROMPT:
return self.guiIO.promptForCredentialFn, nil
@@ -322,7 +330,7 @@ func (self *cmdObjRunner) getCredentialPromptFn(cmdObj ICmdObj) (func(Credential
// promptUserForCredential is a function that gets executed when this function detect you need to fill in a password or passphrase
// The promptUserForCredential argument will be "username", "password" or "passphrase" and expects the user's password/passphrase or username back
func (self *cmdObjRunner) runAndDetectCredentialRequest(
cmdObj ICmdObj,
cmdObj *CmdObj,
promptUserForCredential func(CredentialType) <-chan string,
) error {
// setting the output to english so we can parse it for a username/password request
@@ -410,3 +418,38 @@ func (self *cmdObjRunner) getCheckForCredentialRequestFunc() func([]byte) (Crede
return 0, false
}
}
type Buffer struct {
b bytes.Buffer
m deadlock.Mutex
}
func (b *Buffer) Read(p []byte) (n int, err error) {
b.m.Lock()
defer b.m.Unlock()
return b.b.Read(p)
}
func (b *Buffer) Write(p []byte) (n int, err error) {
b.m.Lock()
defer b.m.Unlock()
return b.b.Write(p)
}
func (self *cmdObjRunner) getCmdHandlerNonPty(cmd *exec.Cmd) (*cmdHandler, error) {
stdoutReader, stdoutWriter := io.Pipe()
cmd.Stdout = stdoutWriter
buf := &Buffer{}
cmd.Stdin = buf
if err := cmd.Start(); err != nil {
return nil, err
}
return &cmdHandler{
stdoutPipe: stdoutReader,
stdinPipe: buf,
close: func() error { return nil },
}, nil
}
@@ -11,7 +11,7 @@ import (
// we define this separately for windows and non-windows given that windows does
// not have great PTY support and we need a PTY to handle a credential request
func (self *cmdObjRunner) getCmdHandler(cmd *exec.Cmd) (*cmdHandler, error) {
func (self *cmdObjRunner) getCmdHandlerPty(cmd *exec.Cmd) (*cmdHandler, error) {
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, err
+3 -41
View File
@@ -4,48 +4,10 @@
package oscommands
import (
"bytes"
"io"
"os/exec"
"github.com/sasha-s/go-deadlock"
)
type Buffer struct {
b bytes.Buffer
m deadlock.Mutex
}
func (b *Buffer) Read(p []byte) (n int, err error) {
b.m.Lock()
defer b.m.Unlock()
return b.b.Read(p)
}
func (b *Buffer) Write(p []byte) (n int, err error) {
b.m.Lock()
defer b.m.Unlock()
return b.b.Write(p)
}
// TODO: Remove this hack and replace it with a proper way to run commands live on windows. We still have an issue where if a password is requested, the request for a password is written straight to stdout because we can't control the stdout of a subprocess of a subprocess. Keep an eye on https://github.com/creack/pty/pull/109
func (self *cmdObjRunner) getCmdHandler(cmd *exec.Cmd) (*cmdHandler, error) {
stdoutReader, stdoutWriter := io.Pipe()
cmd.Stdout = stdoutWriter
buf := &Buffer{}
cmd.Stdin = buf
if err := cmd.Start(); err != nil {
return nil, err
}
// because we don't yet have windows support for a pty, we instead just
// pass our standard stream handlers and because there's no pty to close
// we pass a no-op function for that.
return &cmdHandler{
stdoutPipe: stdoutReader,
stdinPipe: buf,
close: func() error { return nil },
}, nil
func (self *cmdObjRunner) getCmdHandlerPty(cmd *exec.Cmd) (*cmdHandler, error) {
// We don't have PTY support on Windows yet, so we just return a non-PTY handler.
return self.getCmdHandlerNonPty(cmd)
}
+6 -6
View File
@@ -8,7 +8,7 @@ import (
// NewDummyOSCommand creates a new dummy OSCommand for testing
func NewDummyOSCommand() *OSCommand {
osCmd := NewOSCommand(utils.NewDummyCommon(), config.NewDummyAppConfig(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog()))
osCmd := NewOSCommand(common.NewDummyCommon(), config.NewDummyAppConfig(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog()))
return osCmd
}
@@ -23,9 +23,9 @@ type OSCommandDeps struct {
}
func NewDummyOSCommandWithDeps(deps OSCommandDeps) *OSCommand {
common := deps.Common
if common == nil {
common = utils.NewDummyCommon()
cmn := deps.Common
if cmn == nil {
cmn = common.NewDummyCommon()
}
platform := deps.Platform
@@ -34,7 +34,7 @@ func NewDummyOSCommandWithDeps(deps OSCommandDeps) *OSCommand {
}
return &OSCommand{
Common: common,
Common: cmn,
Platform: platform,
getenvFn: deps.GetenvFn,
removeFileFn: deps.RemoveFileFn,
@@ -59,7 +59,7 @@ var dummyPlatform = &Platform{
}
func NewDummyOSCommandWithRunner(runner *FakeCmdObjRunner) *OSCommand {
osCommand := NewOSCommand(utils.NewDummyCommon(), config.NewDummyAppConfig(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog()))
osCommand := NewOSCommand(common.NewDummyCommon(), config.NewDummyAppConfig(), dummyPlatform, NewNullGuiIO(utils.NewDummyLog()))
osCommand.Cmd = NewDummyCmdObjBuilder(runner)
return osCommand
@@ -28,7 +28,7 @@ type FakeCmdObjRunner struct {
type CmdObjMatcher struct {
description string
// returns true if the matcher matches the command object
test func(ICmdObj) bool
test func(*CmdObj) bool
// output of the command
output string
@@ -48,12 +48,12 @@ func (self *FakeCmdObjRunner) remainingExpectedCmds() []CmdObjMatcher {
})
}
func (self *FakeCmdObjRunner) Run(cmdObj ICmdObj) error {
func (self *FakeCmdObjRunner) Run(cmdObj *CmdObj) error {
_, err := self.RunWithOutput(cmdObj)
return err
}
func (self *FakeCmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
func (self *FakeCmdObjRunner) RunWithOutput(cmdObj *CmdObj) (string, error) {
self.mutex.Lock()
defer self.mutex.Unlock()
@@ -78,12 +78,12 @@ func (self *FakeCmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
return "", nil
}
func (self *FakeCmdObjRunner) RunWithOutputs(cmdObj ICmdObj) (string, string, error) {
func (self *FakeCmdObjRunner) RunWithOutputs(cmdObj *CmdObj) (string, string, error) {
output, err := self.RunWithOutput(cmdObj)
return output, "", err
}
func (self *FakeCmdObjRunner) RunAndProcessLines(cmdObj ICmdObj, onLine func(line string) (bool, error)) error {
func (self *FakeCmdObjRunner) RunAndProcessLines(cmdObj *CmdObj, onLine func(line string) (bool, error)) error {
output, err := self.RunWithOutput(cmdObj)
if err != nil {
return err
@@ -105,7 +105,7 @@ func (self *FakeCmdObjRunner) RunAndProcessLines(cmdObj ICmdObj, onLine func(lin
return nil
}
func (self *FakeCmdObjRunner) ExpectFunc(description string, fn func(cmdObj ICmdObj) bool, output string, err error) *FakeCmdObjRunner {
func (self *FakeCmdObjRunner) ExpectFunc(description string, fn func(cmdObj *CmdObj) bool, output string, err error) *FakeCmdObjRunner {
self.mutex.Lock()
defer self.mutex.Unlock()
@@ -121,7 +121,7 @@ func (self *FakeCmdObjRunner) ExpectFunc(description string, fn func(cmdObj ICmd
func (self *FakeCmdObjRunner) ExpectArgs(expectedArgs []string, output string, err error) *FakeCmdObjRunner {
description := fmt.Sprintf("matches args %s", strings.Join(expectedArgs, " "))
self.ExpectFunc(description, func(cmdObj ICmdObj) bool {
self.ExpectFunc(description, func(cmdObj *CmdObj) bool {
return slices.Equal(expectedArgs, cmdObj.GetCmd().Args)
}, output, err)
@@ -130,7 +130,7 @@ func (self *FakeCmdObjRunner) ExpectArgs(expectedArgs []string, output string, e
func (self *FakeCmdObjRunner) ExpectGitArgs(expectedArgs []string, output string, err error) *FakeCmdObjRunner {
description := fmt.Sprintf("matches git args %s", strings.Join(expectedArgs, " "))
self.ExpectFunc(description, func(cmdObj ICmdObj) bool {
self.ExpectFunc(description, func(cmdObj *CmdObj) bool {
return slices.Equal(expectedArgs, cmdObj.GetCmd().Args[1:])
}, output, err)
+3 -3
View File
@@ -208,13 +208,13 @@ 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 ...ICmdObj) error {
cmds := lo.Map(cmdObjs, func(cmdObj ICmdObj, _ int) *exec.Cmd {
func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
return cmdObj.GetCmd()
})
logCmdStr := strings.Join(
lo.Map(cmdObjs, func(cmdObj ICmdObj, _ int) string {
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
return cmdObj.ToString()
}),
" | ",
+33
View File
@@ -0,0 +1,33 @@
package common
import (
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spf13/afero"
)
func NewDummyCommon() *Common {
tr := i18n.EnglishTranslationSet()
cmn := &Common{
Log: utils.NewDummyLog(),
Tr: tr,
Fs: afero.NewOsFs(),
}
cmn.SetUserConfig(config.GetDefaultConfig())
return cmn
}
func NewDummyCommonWithUserConfigAndAppState(userConfig *config.UserConfig, appState *config.AppState) *Common {
tr := i18n.EnglishTranslationSet()
cmn := &Common{
Log: utils.NewDummyLog(),
Tr: tr,
AppState: appState,
// TODO: remove dependency on actual filesystem in tests and switch to using
// in-memory for everything
Fs: afero.NewOsFs(),
}
cmn.SetUserConfig(userConfig)
return cmn
}
+180 -40
View File
@@ -1,6 +1,7 @@
package config
import (
"errors"
"fmt"
"log"
"os"
@@ -10,6 +11,8 @@ import (
"time"
"github.com/adrg/xdg"
"github.com/jesseduffield/generics/orderedset"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/utils/yaml_utils"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
@@ -94,7 +97,7 @@ func NewAppConfig(
configFiles = []*ConfigFile{configFile}
}
userConfig, err := loadUserConfigWithDefaults(configFiles)
userConfig, err := loadUserConfigWithDefaults(configFiles, false)
if err != nil {
return nil, err
}
@@ -143,11 +146,11 @@ func findOrCreateConfigDir() (string, error) {
return folder, os.MkdirAll(folder, 0o755)
}
func loadUserConfigWithDefaults(configFiles []*ConfigFile) (*UserConfig, error) {
return loadUserConfig(configFiles, GetDefaultConfig())
func loadUserConfigWithDefaults(configFiles []*ConfigFile, isGuiInitialized bool) (*UserConfig, error) {
return loadUserConfig(configFiles, GetDefaultConfig(), isGuiInitialized)
}
func loadUserConfig(configFiles []*ConfigFile, base *UserConfig) (*UserConfig, error) {
func loadUserConfig(configFiles []*ConfigFile, base *UserConfig, isGuiInitialized bool) (*UserConfig, error) {
for _, configFile := range configFiles {
path := configFile.Path
statInfo, err := os.Stat(path)
@@ -192,7 +195,7 @@ func loadUserConfig(configFiles []*ConfigFile, base *UserConfig) (*UserConfig, e
return nil, err
}
content, err = migrateUserConfig(path, content)
content, err = migrateUserConfig(path, content, isGuiInitialized)
if err != nil {
return nil, err
}
@@ -213,41 +216,64 @@ func loadUserConfig(configFiles []*ConfigFile, base *UserConfig) (*UserConfig, e
return base, nil
}
type ChangesSet = orderedset.OrderedSet[string]
func NewChangesSet() *ChangesSet {
return orderedset.New[string]()
}
// Do any backward-compatibility migrations of things that have changed in the
// config over time; examples are renaming a key to a better name, moving a key
// from one container to another, or changing the type of a key (e.g. from bool
// to an enum).
func migrateUserConfig(path string, content []byte) ([]byte, error) {
changedContent, err := computeMigratedConfig(path, content)
func migrateUserConfig(path string, content []byte, isGuiInitialized bool) ([]byte, error) {
changes := NewChangesSet()
changedContent, didChange, err := computeMigratedConfig(path, content, changes)
if err != nil {
return nil, err
}
// Write config back if changed
if string(changedContent) != string(content) {
fmt.Println("Provided user config is deprecated but auto-fixable. Attempting to write fixed version back to file...")
if err := os.WriteFile(path, changedContent, 0o644); err != nil {
return nil, fmt.Errorf("While attempting to write back fixed user config to %s, an error occurred: %s", path, err)
}
fmt.Printf("Success. New config written to %s\n", path)
return changedContent, nil
// Nothing to do if config didn't change
if !didChange {
return content, nil
}
return content, nil
changesText := "The following changes were made:\n\n"
changesText += strings.Join(lo.Map(changes.ToSliceFromOldest(), func(change string, _ int) string {
return fmt.Sprintf("- %s\n", change)
}), "")
// Write config back
if !isGuiInitialized {
fmt.Printf("The user config file %s must be migrated. Attempting to do this automatically.\n", path)
fmt.Println(changesText)
}
if err := os.WriteFile(path, changedContent, 0o644); err != nil {
errorMsg := fmt.Sprintf("While attempting to write back migrated user config to %s, an error occurred: %s", path, err)
if isGuiInitialized {
errorMsg += "\n\n" + changesText
}
return nil, errors.New(errorMsg)
}
if !isGuiInitialized {
fmt.Printf("Config file saved successfully to %s\n", path)
}
return changedContent, nil
}
// A pure function helper for testing purposes
func computeMigratedConfig(path string, content []byte) ([]byte, error) {
func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]byte, bool, error) {
var err error
var rootNode yaml.Node
err = yaml.Unmarshal(content, &rootNode)
if err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
return nil, false, fmt.Errorf("failed to parse YAML: %w", err)
}
var originalCopy yaml.Node
err = yaml.Unmarshal(content, &originalCopy)
if err != nil {
return nil, fmt.Errorf("failed to parse YAML, but only the second time!?!? How did that happen: %w", err)
return nil, false, fmt.Errorf("failed to parse YAML, but only the second time!?!? How did that happen: %w", err)
}
pathsToReplace := []struct {
@@ -260,50 +286,64 @@ func computeMigratedConfig(path string, content []byte) ([]byte, error) {
}
for _, pathToReplace := range pathsToReplace {
err := yaml_utils.RenameYamlKey(&rootNode, pathToReplace.oldPath, pathToReplace.newName)
err, didReplace := yaml_utils.RenameYamlKey(&rootNode, pathToReplace.oldPath, pathToReplace.newName)
if err != nil {
return nil, fmt.Errorf("Couldn't migrate config file at `%s` for key %s: %s", path, strings.Join(pathToReplace.oldPath, "."), err)
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s` for key %s: %s", path, strings.Join(pathToReplace.oldPath, "."), err)
}
if didReplace {
changes.Add(fmt.Sprintf("Renamed '%s' to '%s'", strings.Join(pathToReplace.oldPath, "."), pathToReplace.newName))
}
}
err = changeNullKeybindingsToDisabled(&rootNode)
err = changeNullKeybindingsToDisabled(&rootNode, changes)
if err != nil {
return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
}
err = changeElementToSequence(&rootNode, []string{"git", "commitPrefix"})
err = changeElementToSequence(&rootNode, []string{"git", "commitPrefix"}, changes)
if err != nil {
return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
}
err = changeCommitPrefixesMap(&rootNode)
err = changeCommitPrefixesMap(&rootNode, changes)
if err != nil {
return nil, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
}
err = changeCustomCommandStreamAndOutputToOutputEnum(&rootNode, changes)
if err != nil {
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
}
err = migrateAllBranchesLogCmd(&rootNode, changes)
if err != nil {
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %s", path, err)
}
// Add more migrations here...
if !reflect.DeepEqual(rootNode, originalCopy) {
newContent, err := yaml_utils.YamlMarshal(&rootNode)
if err != nil {
return nil, fmt.Errorf("Failed to remarsal!\n %w", err)
}
return newContent, nil
} else {
return content, nil
if reflect.DeepEqual(rootNode, originalCopy) {
return nil, false, nil
}
newContent, err := yaml_utils.YamlMarshal(&rootNode)
if err != nil {
return nil, false, fmt.Errorf("Failed to remarsal!\n %w", err)
}
return newContent, true, nil
}
func changeNullKeybindingsToDisabled(rootNode *yaml.Node) error {
func changeNullKeybindingsToDisabled(rootNode *yaml.Node, changes *ChangesSet) error {
return yaml_utils.Walk(rootNode, func(node *yaml.Node, path string) {
if strings.HasPrefix(path, "keybinding.") && node.Kind == yaml.ScalarNode && node.Tag == "!!null" {
node.Value = "<disabled>"
node.Tag = "!!str"
changes.Add(fmt.Sprintf("Changed 'null' to '<disabled>' for keybinding '%s'", path))
}
})
}
func changeElementToSequence(rootNode *yaml.Node, path []string) error {
func changeElementToSequence(rootNode *yaml.Node, path []string, changes *ChangesSet) error {
return yaml_utils.TransformNode(rootNode, path, func(node *yaml.Node) error {
if node.Kind == yaml.MappingNode {
nodeContentCopy := node.Content
@@ -315,13 +355,15 @@ func changeElementToSequence(rootNode *yaml.Node, path []string) error {
Content: nodeContentCopy,
}}
changes.Add(fmt.Sprintf("Changed '%s' to an array of strings", strings.Join(path, ".")))
return nil
}
return nil
})
}
func changeCommitPrefixesMap(rootNode *yaml.Node) error {
func changeCommitPrefixesMap(rootNode *yaml.Node, changes *ChangesSet) error {
return yaml_utils.TransformNode(rootNode, []string{"git", "commitPrefixes"}, func(prefixesNode *yaml.Node) error {
if prefixesNode.Kind == yaml.MappingNode {
for _, contentNode := range prefixesNode.Content {
@@ -334,6 +376,7 @@ func changeCommitPrefixesMap(rootNode *yaml.Node) error {
Kind: yaml.MappingNode,
Content: nodeContentCopy,
}}
changes.Add("Changed 'git.commitPrefixes' elements to arrays of strings")
}
}
}
@@ -341,6 +384,101 @@ func changeCommitPrefixesMap(rootNode *yaml.Node) error {
})
}
func changeCustomCommandStreamAndOutputToOutputEnum(rootNode *yaml.Node, changes *ChangesSet) error {
return yaml_utils.Walk(rootNode, func(node *yaml.Node, path string) {
// We are being lazy here and rely on the fact that the only mapping
// nodes in the tree under customCommands are actual custom commands. If
// this ever changes (e.g. because we add a struct field to
// customCommand), then we need to change this to iterate properly.
if strings.HasPrefix(path, "customCommands[") && node.Kind == yaml.MappingNode {
output := ""
if streamKey, streamValue := yaml_utils.RemoveKey(node, "subprocess"); streamKey != nil {
if streamValue.Kind == yaml.ScalarNode && streamValue.Value == "true" {
output = "terminal"
changes.Add("Changed 'subprocess: true' to 'output: terminal' in custom command")
} else {
changes.Add("Deleted redundant 'subprocess: false' in custom command")
}
}
if streamKey, streamValue := yaml_utils.RemoveKey(node, "stream"); streamKey != nil {
if streamValue.Kind == yaml.ScalarNode && streamValue.Value == "true" && output == "" {
output = "log"
changes.Add("Changed 'stream: true' to 'output: log' in custom command")
} else {
changes.Add(fmt.Sprintf("Deleted redundant 'stream: %v' property in custom command", streamValue.Value))
}
}
if streamKey, streamValue := yaml_utils.RemoveKey(node, "showOutput"); streamKey != nil {
if streamValue.Kind == yaml.ScalarNode && streamValue.Value == "true" && output == "" {
changes.Add("Changed 'showOutput: true' to 'output: popup' in custom command")
output = "popup"
} else {
changes.Add(fmt.Sprintf("Deleted redundant 'showOutput: %v' property in custom command", streamValue.Value))
}
}
if output != "" {
outputKeyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: "output",
Tag: "!!str",
}
outputValueNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: output,
Tag: "!!str",
}
node.Content = append(node.Content, outputKeyNode, outputValueNode)
}
}
})
}
// This migration is special because users have already defined
// a single element at `allBranchesLogCmd` and the sequence at `allBranchesLogCmds`.
// Some users have explicitly set `allBranchesLogCmd` to be an empty string in order
// to remove it, so in that case we just delete the element, and add nothing to the list
func migrateAllBranchesLogCmd(rootNode *yaml.Node, changes *ChangesSet) error {
return yaml_utils.TransformNode(rootNode, []string{"git"}, func(gitNode *yaml.Node) error {
cmdKeyNode, cmdValueNode := yaml_utils.LookupKey(gitNode, "allBranchesLogCmd")
// Nothing to do if they do not have the deprecated item
if cmdKeyNode == nil {
return nil
}
cmdsKeyNode, cmdsValueNode := yaml_utils.LookupKey(gitNode, "allBranchesLogCmds")
var change string
if cmdsKeyNode == nil {
// Create empty sequence node and attach it onto the root git node
// We will later populate it with the individual allBranchesLogCmd record
cmdsKeyNode = &yaml.Node{Kind: yaml.ScalarNode, Value: "allBranchesLogCmds"}
cmdsValueNode = &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{}}
gitNode.Content = append(gitNode.Content,
cmdsKeyNode,
cmdsValueNode,
)
change = "Created git.allBranchesLogCmds array containing value of git.allBranchesLogCmd"
} else {
if cmdsValueNode.Kind != yaml.SequenceNode {
return errors.New("You should have an allBranchesLogCmds defined as a sequence!")
}
change = "Prepended git.allBranchesLogCmd value to git.allBranchesLogCmds array"
}
if cmdValueNode.Value != "" {
// Prepending the individual element to make it show up first in the list, which was prior behavior
cmdsValueNode.Content = utils.Prepend(cmdsValueNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: cmdValueNode.Value})
changes.Add(change)
}
// Clear out the existing allBranchesLogCmd, now that we have migrated it into the list
_, _ = yaml_utils.RemoveKey(gitNode, "allBranchesLogCmd")
changes.Add("Removed obsolete git.allBranchesLogCmd")
return nil
})
}
func (c *AppConfig) GetDebug() bool {
return c.debug
}
@@ -381,7 +519,7 @@ func (c *AppConfig) GetUserConfigDir() string {
func (c *AppConfig) ReloadUserConfigForRepo(repoConfigFiles []*ConfigFile) error {
configFiles := append(c.globalUserConfigFiles, repoConfigFiles...)
userConfig, err := loadUserConfigWithDefaults(configFiles)
userConfig, err := loadUserConfigWithDefaults(configFiles, true)
if err != nil {
return err
}
@@ -406,7 +544,7 @@ func (c *AppConfig) ReloadChangedUserConfigFiles() (error, bool) {
return nil, false
}
userConfig, err := loadUserConfigWithDefaults(c.userConfigFiles)
userConfig, err := loadUserConfigWithDefaults(c.userConfigFiles, true)
if err != nil {
return err, false
}
@@ -553,6 +691,8 @@ type AppState struct {
// This determines whether the git graph is rendered in the commits panel
// One of 'always' | 'never' | 'when-maximised'
GitLogShowGraph string
ShowFullDiffInFilterByPathMode bool
}
func getDefaultAppState() *AppState {
+421 -30
View File
@@ -6,17 +6,165 @@ import (
"github.com/stretchr/testify/assert"
)
func TestCommitPrefixMigrations(t *testing.T) {
func TestMigrationOfRenamedKeys(t *testing.T) {
scenarios := []struct {
name string
input string
expected string
name string
input string
expected string
expectedDidChange bool
expectedChanges []string
}{
{
name: "Empty String",
input: "",
expected: "",
}, {
name: "Empty String",
input: "",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "No rename needed",
input: `foo:
bar: 5
`,
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "Rename one",
input: `gui:
skipUnstageLineWarning: true
`,
expected: `gui:
skipDiscardChangeWarning: true
`,
expectedDidChange: true,
expectedChanges: []string{"Renamed 'gui.skipUnstageLineWarning' to 'skipDiscardChangeWarning'"},
},
{
name: "Rename several",
input: `gui:
windowSize: half
skipUnstageLineWarning: true
keybinding:
universal:
executeCustomCommand: a
`,
expected: `gui:
screenMode: half
skipDiscardChangeWarning: true
keybinding:
universal:
executeShellCommand: a
`,
expectedDidChange: true,
expectedChanges: []string{
"Renamed 'gui.skipUnstageLineWarning' to 'skipDiscardChangeWarning'",
"Renamed 'keybinding.universal.executeCustomCommand' to 'executeShellCommand'",
"Renamed 'gui.windowSize' to 'screenMode'",
},
},
}
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())
})
}
}
func TestMigrateNullKeybindingsToDisabled(t *testing.T) {
scenarios := []struct {
name string
input string
expected string
expectedDidChange bool
expectedChanges []string
}{
{
name: "Empty String",
input: "",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "No change needed",
input: `keybinding:
universal:
quit: q
`,
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "Change one",
input: `keybinding:
universal:
quit: null
`,
expected: `keybinding:
universal:
quit: <disabled>
`,
expectedDidChange: true,
expectedChanges: []string{"Changed 'null' to '<disabled>' for keybinding 'keybinding.universal.quit'"},
},
{
name: "Change several",
input: `keybinding:
universal:
quit: null
return: <esc>
new: null
`,
expected: `keybinding:
universal:
quit: <disabled>
return: <esc>
new: <disabled>
`,
expectedDidChange: true,
expectedChanges: []string{
"Changed 'null' to '<disabled>' for keybinding 'keybinding.universal.quit'",
"Changed 'null' to '<disabled>' for keybinding 'keybinding.universal.new'",
},
},
}
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())
})
}
}
func TestCommitPrefixMigrations(t *testing.T) {
scenarios := []struct {
name string
input string
expected string
expectedDidChange bool
expectedChanges []string
}{
{
name: "Empty String",
input: "",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "Single CommitPrefix Rename",
input: `git:
commitPrefix:
@@ -28,7 +176,10 @@ func TestCommitPrefixMigrations(t *testing.T) {
- pattern: "^\\w+-\\w+.*"
replace: '[JIRA $0] '
`,
}, {
expectedDidChange: true,
expectedChanges: []string{"Changed 'git.commitPrefix' to an array of strings"},
},
{
name: "Complicated CommitPrefixes Rename",
input: `git:
commitPrefixes:
@@ -48,13 +199,16 @@ func TestCommitPrefixMigrations(t *testing.T) {
- pattern: "^foo.bar*"
replace: '[FUN $0] '
`,
}, {
name: "Incomplete Configuration",
input: "git:",
expected: "git:",
}, {
// This test intentionally uses non-standard indentation to test that the migration
// does not change the input.
expectedDidChange: true,
expectedChanges: []string{"Changed 'git.commitPrefixes' elements to arrays of strings"},
},
{
name: "Incomplete Configuration",
input: "git:",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "No changes made when already migrated",
input: `
git:
@@ -65,25 +219,144 @@ git:
foo:
- pattern: "^\\w+-\\w+.*"
replace: '[JIRA $0] '`,
expected: `
git:
commitPrefix:
- pattern: "Hello World"
replace: "Goodbye"
commitPrefixes:
foo:
- pattern: "^\\w+-\\w+.*"
replace: '[JIRA $0] '`,
expectedDidChange: false,
expectedChanges: []string{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
actual, err := computeMigratedConfig("path doesn't matter", []byte(s.input))
if err != nil {
t.Error(err)
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.expected, string(actual))
assert.Equal(t, s.expectedChanges, changes.ToSliceFromOldest())
})
}
}
func TestCustomCommandsOutputMigration(t *testing.T) {
scenarios := []struct {
name string
input string
expected string
expectedDidChange bool
expectedChanges []string
}{
{
name: "Empty String",
input: "",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "Convert subprocess to output=terminal",
input: `customCommands:
- command: echo 'hello'
subprocess: true
`,
expected: `customCommands:
- command: echo 'hello'
output: terminal
`,
expectedDidChange: true,
expectedChanges: []string{"Changed 'subprocess: true' to 'output: terminal' in custom command"},
},
{
name: "Convert stream to output=log",
input: `customCommands:
- command: echo 'hello'
stream: true
`,
expected: `customCommands:
- command: echo 'hello'
output: log
`,
expectedDidChange: true,
expectedChanges: []string{"Changed 'stream: true' to 'output: log' in custom command"},
},
{
name: "Convert showOutput to output=popup",
input: `customCommands:
- command: echo 'hello'
showOutput: true
`,
expected: `customCommands:
- command: echo 'hello'
output: popup
`,
expectedDidChange: true,
expectedChanges: []string{"Changed 'showOutput: true' to 'output: popup' in custom command"},
},
{
name: "Subprocess wins over the other two",
input: `customCommands:
- command: echo 'hello'
subprocess: true
stream: true
showOutput: true
`,
expected: `customCommands:
- command: echo 'hello'
output: terminal
`,
expectedDidChange: true,
expectedChanges: []string{
"Changed 'subprocess: true' to 'output: terminal' in custom command",
"Deleted redundant 'stream: true' property in custom command",
"Deleted redundant 'showOutput: true' property in custom command",
},
},
{
name: "Stream wins over showOutput",
input: `customCommands:
- command: echo 'hello'
stream: true
showOutput: true
`,
expected: `customCommands:
- command: echo 'hello'
output: log
`,
expectedDidChange: true,
expectedChanges: []string{
"Changed 'stream: true' to 'output: log' in custom command",
"Deleted redundant 'showOutput: true' property in custom command",
},
},
{
name: "Explicitly setting to false doesn't create an output=none key",
input: `customCommands:
- command: echo 'hello'
subprocess: false
stream: false
showOutput: false
`,
expected: `customCommands:
- command: echo 'hello'
`,
expectedDidChange: true,
expectedChanges: []string{
"Deleted redundant 'subprocess: false' in custom command",
"Deleted redundant 'stream: false' property in custom command",
"Deleted redundant 'showOutput: false' property in custom command",
},
},
}
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())
})
}
}
@@ -692,6 +965,124 @@ keybinding:
func BenchmarkMigrationOnLargeConfiguration(b *testing.B) {
for b.Loop() {
_, _ = computeMigratedConfig("path doesn't matter", largeConfiguration)
changes := NewChangesSet()
_, _, _ = computeMigratedConfig("path doesn't matter", largeConfiguration, changes)
}
}
func TestAllBranchesLogCmdMigrations(t *testing.T) {
scenarios := []struct {
name string
input string
expected string
expectedDidChange bool
expectedChanges []string
}{
{
name: "Incomplete Configuration Passes uneventfully",
input: "git:",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "Single Cmd with no Cmds",
input: `git:
allBranchesLogCmd: git log --graph --oneline
`,
expected: `git:
allBranchesLogCmds:
- git log --graph --oneline
`,
expectedDidChange: true,
expectedChanges: []string{
"Created git.allBranchesLogCmds array containing value of git.allBranchesLogCmd",
"Removed obsolete git.allBranchesLogCmd",
},
},
{
name: "Cmd with one existing Cmds",
input: `git:
allBranchesLogCmd: git log --graph --oneline
allBranchesLogCmds:
- git log --graph --oneline --pretty
`,
expected: `git:
allBranchesLogCmds:
- git log --graph --oneline
- git log --graph --oneline --pretty
`,
expectedDidChange: true,
expectedChanges: []string{
"Prepended git.allBranchesLogCmd value to git.allBranchesLogCmds array",
"Removed obsolete git.allBranchesLogCmd",
},
},
{
name: "Only Cmds set have no changes",
input: `git:
allBranchesLogCmds:
- git log
`,
expected: "",
expectedChanges: []string{},
},
{
name: "Removes Empty Cmd When at end of yaml",
input: `git:
allBranchesLogCmds:
- git log --graph --oneline
allBranchesLogCmd:
`,
expected: `git:
allBranchesLogCmds:
- git log --graph --oneline
`,
expectedDidChange: true,
expectedChanges: []string{"Removed obsolete git.allBranchesLogCmd"},
},
{
name: "Migrates when sequence defined inline",
input: `git:
allBranchesLogCmds: [foo, bar]
allBranchesLogCmd: baz
`,
expected: `git:
allBranchesLogCmds: [baz, foo, bar]
`,
expectedDidChange: true,
expectedChanges: []string{
"Prepended git.allBranchesLogCmd value to git.allBranchesLogCmds array",
"Removed obsolete git.allBranchesLogCmd",
},
},
{
name: "Removes Empty Cmd With Keys Afterwards",
input: `git:
allBranchesLogCmds:
- git log --graph --oneline
allBranchesLogCmd:
foo: bar
`,
expected: `git:
allBranchesLogCmds:
- git log --graph --oneline
foo: bar
`,
expectedDidChange: true,
expectedChanges: []string{"Removed obsolete git.allBranchesLogCmd"},
},
}
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())
})
}
}
+7 -15
View File
@@ -244,7 +244,7 @@ type GitConfig struct {
AutoFetch bool `yaml:"autoFetch"`
// If true, periodically refresh files and submodules
AutoRefresh bool `yaml:"autoRefresh"`
// If not "none", lazygit will automatically forward branches to 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).
// 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
@@ -256,9 +256,6 @@ type GitConfig struct {
AutoStageResolvedConflicts bool `yaml:"autoStageResolvedConflicts"`
// Command used when displaying the current branch git log in the main window
BranchLogCmd string `yaml:"branchLogCmd"`
// Command used to display git log of all branches in the main window.
// Deprecated: Use `allBranchesLogCmds` instead.
AllBranchesLogCmd string `yaml:"allBranchesLogCmd" jsonschema:"deprecated"`
// Commands used to display git log of all branches in the main window, they will be cycled in order of appearance (array of strings)
AllBranchesLogCmds []string `yaml:"allBranchesLogCmds"`
// If true, do not spawn a separate process when using GPG
@@ -408,6 +405,7 @@ type KeybindingUniversalConfig struct {
GoInto string `yaml:"goInto"`
Confirm string `yaml:"confirm"`
ConfirmInEditor string `yaml:"confirmInEditor"`
ConfirmInEditorAlt string `yaml:"confirmInEditor-alt"`
Remove string `yaml:"remove"`
New string `yaml:"new"`
Edit string `yaml:"edit"`
@@ -637,22 +635,15 @@ type CustomCommand struct {
Context string `yaml:"context" jsonschema:"example=status,example=files,example=worktrees,example=localBranches,example=remotes,example=remoteBranches,example=tags,example=commits,example=reflogCommits,example=subCommits,example=commitFiles,example=stash,example=global"`
// The command to run (using Go template syntax for placeholder values)
Command string `yaml:"command" jsonschema:"example=git fetch {{.Form.Remote}} {{.Form.Branch}} && git checkout FETCH_HEAD"`
// If true, run the command in a subprocess (e.g. if the command requires user input)
// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
Subprocess *bool `yaml:"subprocess"`
// A list of prompts that will request user input before running the final command
Prompts []CustomCommandPrompt `yaml:"prompts"`
// Text to display while waiting for command to finish
LoadingText string `yaml:"loadingText" jsonschema:"example=Loading..."`
// Label for the custom command when displayed in the keybindings menu
Description string `yaml:"description"`
// If true, stream the command's output to the Command Log panel
// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
Stream *bool `yaml:"stream"`
// If true, show the command's output in a popup within Lazygit
// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
ShowOutput *bool `yaml:"showOutput"`
// The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title.
// Where the output of the command should go. 'none' discards it, 'terminal' suspends lazygit and runs the command in the terminal (useful for commands that require user input), 'log' streams it to the command log, 'logWithPty' is like 'log' but runs the command in a pseudo terminal (can be useful for commands that produce colored output when the output is a terminal), and 'popup' shows it in a popup.
Output string `yaml:"output" jsonschema:"enum=none,enum=terminal,enum=log,enum=logWithPty,enum=popup"`
// The title to display in the popup panel if output is set to 'popup'. If left unset, the command will be used as the title.
OutputTitle string `yaml:"outputTitle"`
// Actions to take after the command has completed
// [dev] Pointer so that we can tell whether it appears in the config file
@@ -830,7 +821,7 @@ func GetDefaultConfig() *UserConfig {
FetchAll: true,
AutoStageResolvedConflicts: true,
BranchLogCmd: "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --",
AllBranchesLogCmd: "git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium",
AllBranchesLogCmds: []string{"git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium"},
DisableForcePushing: false,
CommitPrefixes: map[string][]CommitPrefixConfig(nil),
BranchPrefix: "",
@@ -892,6 +883,7 @@ func GetDefaultConfig() *UserConfig {
GoInto: "<enter>",
Confirm: "<enter>",
ConfirmInEditor: "<a-enter>",
ConfirmInEditorAlt: "<c-s>",
Remove: "d",
New: "n",
Edit: "e",
+18 -10
View File
@@ -101,21 +101,29 @@ func validateCustomCommands(customCommands []CustomCommand) error {
return err
}
if len(customCommand.CommandMenu) > 0 &&
(len(customCommand.Context) > 0 ||
if len(customCommand.CommandMenu) > 0 {
if len(customCommand.Context) > 0 ||
len(customCommand.Command) > 0 ||
customCommand.Subprocess != nil ||
len(customCommand.Prompts) > 0 ||
len(customCommand.LoadingText) > 0 ||
customCommand.Stream != nil ||
customCommand.ShowOutput != nil ||
len(customCommand.Output) > 0 ||
len(customCommand.OutputTitle) > 0 ||
customCommand.After != nil) {
commandRef := ""
if len(customCommand.Key) > 0 {
commandRef = fmt.Sprintf(" with key '%s'", customCommand.Key)
customCommand.After != nil {
commandRef := ""
if len(customCommand.Key) > 0 {
commandRef = fmt.Sprintf(" with key '%s'", customCommand.Key)
}
return fmt.Errorf("Error with custom command%s: it is not allowed to use both commandMenu and any of the other fields except key and description.", commandRef)
}
if err := validateCustomCommands(customCommand.CommandMenu); err != nil {
return err
}
} else {
if err := validateEnum("customCommand.output", customCommand.Output,
[]string{"", "none", "terminal", "log", "logWithPty", "popup"}); err != nil {
return err
}
return fmt.Errorf("Error with custom command%s: it is not allowed to use both commandMenu and any of the other fields except key and description.", commandRef)
}
}
return nil
+43 -4
View File
@@ -74,6 +74,46 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command keybinding in sub menu",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Key: "X",
Description: "My Custom Commands",
CommandMenu: []CustomCommand{
{Key: value, Command: "echo 'hello'", Context: "global"},
},
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "<disabled>", valid: true},
{value: "q", valid: true},
{value: "<c-c>", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command output",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Output: value,
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "none", valid: true},
{value: "terminal", valid: true},
{value: "log", valid: true},
{value: "logWithPty", valid: true},
{value: "popup", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) {
@@ -97,7 +137,7 @@ func TestUserConfigValidate_enums(t *testing.T) {
config.CustomCommands = []CustomCommand{
{
Key: "X",
Context: "global",
Context: "global", // context is not allowed for submenus
CommandMenu: []CustomCommand{
{Key: "1", Command: "echo 'hello'", Context: "global"},
},
@@ -111,11 +151,10 @@ func TestUserConfigValidate_enums(t *testing.T) {
{
name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) {
falseVal := false
config.CustomCommands = []CustomCommand{
{
Key: "X",
Subprocess: &falseVal,
Key: "X",
LoadingText: "loading", // other properties are not allowed for submenus (using loadingText as an example)
CommandMenu: []CustomCommand{
{Key: "1", Command: "echo 'hello'", Context: "global"},
},
+16
View File
@@ -357,3 +357,19 @@ func (self *ContextMgr) CurrentPopup() []types.Context {
return context.GetKind() == types.TEMPORARY_POPUP || context.GetKind() == types.PERSISTENT_POPUP
})
}
func (self *ContextMgr) NextInStack(c types.Context) types.Context {
self.RLock()
defer self.RUnlock()
for i := range self.ContextStack {
if self.ContextStack[i].GetKey() == c.GetKey() {
if i == 0 {
return nil
}
return self.ContextStack[i-1]
}
}
panic("context not in stack")
}
+4
View File
@@ -149,3 +149,7 @@ func (self *ListContextTrait) TotalContentHeight() int {
}
return result
}
func (self *ListContextTrait) IndexForGotoBottom() int {
return self.list.Len() - 1
}
+17
View File
@@ -289,3 +289,20 @@ func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPosi
strings.Contains(normalize(commit.ExtraInfo), searchStr) // allow searching for tags
})
}
func (self *LocalCommitsContext) IndexForGotoBottom() int {
commits := self.GetCommits()
selectedIdx := self.GetSelectedLineIdx()
if selectedIdx >= 0 && selectedIdx < len(commits)-1 {
if commits[selectedIdx+1].Status != models.StatusMerged {
_, idx, found := lo.FindIndexOf(commits, func(c *models.Commit) bool {
return c.Status == models.StatusMerged
})
if found {
return idx - 1
}
}
}
return self.list.Len() - 1
}
+17
View File
@@ -227,3 +227,20 @@ func (self *SubCommitsContext) RefForAdjustingLineNumberInDiff() string {
func (self *SubCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), searchStr)
}
func (self *SubCommitsContext) IndexForGotoBottom() int {
commits := self.GetCommits()
selectedIdx := self.GetSelectedLineIdx()
if selectedIdx >= 0 && selectedIdx < len(commits)-1 {
if commits[selectedIdx+1].Status != models.StatusMerged {
_, idx, found := lo.FindIndexOf(commits, func(c *models.Commit) bool {
return c.Status == models.StatusMerged
})
if found {
return idx - 1
}
}
}
return self.list.Len() - 1
}
@@ -38,6 +38,10 @@ func (self *CommitDescriptionController) GetKeybindings(opts types.KeybindingsOp
Key: opts.GetKey(opts.Config.Universal.ConfirmInEditor),
Handler: self.confirm,
},
{
Key: opts.GetKey(opts.Config.Universal.ConfirmInEditorAlt),
Handler: self.confirm,
},
{
Key: opts.GetKey(opts.Config.CommitMessage.CommitMenu),
Handler: self.openCommitMenu,
@@ -63,10 +67,27 @@ func (self *CommitDescriptionController) GetMouseKeybindings(opts types.Keybindi
func (self *CommitDescriptionController) GetOnFocus() func(types.OnFocusOpts) {
return func(types.OnFocusOpts) {
self.c.Views().CommitDescription.Footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooter,
map[string]string{
"confirmInEditorKeybinding": keybindings.Label(self.c.UserConfig().Keybinding.Universal.ConfirmInEditor),
})
footer := ""
if self.c.UserConfig().Keybinding.Universal.ConfirmInEditor != "<disabled>" || self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt != "<disabled>" {
if self.c.UserConfig().Keybinding.Universal.ConfirmInEditor == "<disabled>" {
footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooter,
map[string]string{
"confirmInEditorKeybinding": keybindings.Label(self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt),
})
} else if self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt == "<disabled>" {
footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooter,
map[string]string{
"confirmInEditorKeybinding": keybindings.Label(self.c.UserConfig().Keybinding.Universal.ConfirmInEditor),
})
} else {
footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooterTwoBindings,
map[string]string{
"confirmInEditorKeybinding1": keybindings.Label(self.c.UserConfig().Keybinding.Universal.ConfirmInEditor),
"confirmInEditorKeybinding2": keybindings.Label(self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt),
})
}
}
self.c.Views().CommitDescription.Footer = footer
}
}
@@ -131,7 +131,9 @@ func (self *ContextLinesController) currentSidePanel() types.Context {
currentContext := self.c.Context().CurrentStatic()
if currentContext.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY ||
currentContext.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY {
return currentContext.GetParentContext()
if sidePanelContext := self.c.Context().NextInStack(currentContext); sidePanelContext != nil {
return sidePanelContext
}
}
return currentContext
@@ -7,6 +7,7 @@ import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type CustomPatchOptionsMenuAction struct {
@@ -46,7 +47,7 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
if self.c.Git().Patch.PatchBuilder.CanRebase && self.c.Git().Status.WorkingTreeState().None() {
menuItems = append(menuItems, []*types.MenuItem{
{
Label: fmt.Sprintf(self.c.Tr.RemovePatchFromOriginalCommit, self.c.Git().Patch.PatchBuilder.To),
Label: fmt.Sprintf(self.c.Tr.RemovePatchFromOriginalCommit, utils.ShortHash(self.c.Git().Patch.PatchBuilder.To)),
Tooltip: self.c.Tr.RemovePatchFromOriginalCommitTooltip,
OnPress: self.handleDeletePatchFromCommit,
Key: 'd',
@@ -63,6 +64,12 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
OnPress: self.handlePullPatchIntoNewCommit,
Key: 'n',
},
{
Label: self.c.Tr.MovePatchIntoNewCommitBefore,
Tooltip: self.c.Tr.MovePatchIntoNewCommitBeforeTooltip,
OnPress: self.handlePullPatchIntoNewCommitBefore,
Key: 'N',
},
}...)
if self.c.Context().Current().GetKey() == self.c.Contexts().LocalCommits.GetKey() {
@@ -222,6 +229,41 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error {
return nil
}
func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() error {
if ok, err := self.validateNormalWorkingTreeState(); !ok {
return err
}
self.returnFocusFromPatchExplorerIfNecessary()
commitIndex := self.getPatchCommitIndex()
self.c.Helpers().Commits.OpenCommitMessagePanel(
&helpers.OpenCommitMessagePanelOpts{
// Pass a commit index of one less than the moved-from commit, so that
// you can press up arrow once to recall the original commit message:
CommitIndex: commitIndex - 1,
InitialMessage: "",
SummaryTitle: self.c.Tr.CommitSummaryTitle,
DescriptionTitle: self.c.Tr.CommitDescriptionTitle,
PreserveMessage: false,
OnConfirm: func(summary string, description string) error {
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.Helpers().Commits.CloseCommitMessagePanel()
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit)
err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(self.c.Model().Commits, commitIndex, summary, description)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
return err
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
})
},
},
)
return nil
}
func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error {
self.returnFocusFromPatchExplorerIfNecessary()
@@ -60,6 +60,7 @@ func (self *FilteringMenuAction) Call() error {
menuItems = append(menuItems, &types.MenuItem{
Label: self.c.Tr.FilterPathOption,
Key: 'p',
OnPress: func() error {
self.c.Prompt(types.PromptOpts{
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetFilePathSuggestionsFunc(),
@@ -76,6 +77,7 @@ func (self *FilteringMenuAction) Call() error {
menuItems = append(menuItems, &types.MenuItem{
Label: self.c.Tr.FilterAuthorOption,
Key: 'a',
OnPress: func() error {
self.c.Prompt(types.PromptOpts{
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
@@ -90,9 +92,23 @@ func (self *FilteringMenuAction) Call() error {
Tooltip: tooltip,
})
if path := self.c.Modes().Filtering.GetPath(); path != "" {
menuItems = append(menuItems, &types.MenuItem{
Label: "Show full diff", // TODO: i18n (and tooltip?)
Key: 'f',
OnPress: func() error {
self.c.AppState.ShowFullDiffInFilterByPathMode = !self.c.AppState.ShowFullDiffInFilterByPathMode
self.c.SaveAppStateAndLogError()
return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
},
Widget: types.MakeMenuCheckBox(self.c.AppState.ShowFullDiffInFilterByPathMode),
})
}
if self.c.Modes().Filtering.Active() {
menuItems = append(menuItems, &types.MenuItem{
Label: self.c.Tr.ExitFilterMode,
Key: 's',
OnPress: self.c.Helpers().Mode.ClearFiltering,
})
}
@@ -270,8 +270,12 @@ func (self *BranchesHelper) AutoForwardBranches() error {
return nil
}
allBranches := self.c.UserConfig().Git.AutoForwardBranches == "allBranches"
branches := self.c.Model().Branches
if len(branches) == 0 {
return nil
}
allBranches := self.c.UserConfig().Git.AutoForwardBranches == "allBranches"
updateCommands := ""
// The first branch is the currently checked out branch; skip it
for _, branch := range branches[1:] {
@@ -157,11 +157,8 @@ func (self *CommitsHelper) OpenCommitMessagePanel(opts *OpenCommitMessagePanelOp
self.c.Context().Push(self.c.Contexts().CommitMessage, types.OnFocusOpts{})
}
func (self *CommitsHelper) OnCommitSuccess() {
// if we have a preserved message we want to clear it on success
if self.c.Contexts().CommitMessage.GetPreserveMessage() {
self.c.Contexts().CommitMessage.SetPreservedMessageAndLogError("")
}
func (self *CommitsHelper) ClearPreservedCommitMessage() {
self.c.Contexts().CommitMessage.SetPreservedMessageAndLogError("")
}
func (self *CommitsHelper) HandleCommitConfirm() error {
+1 -1
View File
@@ -57,7 +57,7 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm
from, to := refRange.From, refRange.To
args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"}
args = append(args, "--")
if path := self.c.Modes().Filtering.GetPath(); path != "" {
if path := self.c.Modes().Filtering.GetPath(); path != "" && !self.c.AppState.ShowFullDiffInFilterByPathMode {
args = append(args, path)
}
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
+2 -2
View File
@@ -23,7 +23,7 @@ func NewGpgHelper(c *HelperCommon) *GpgHelper {
// WithWaitingStatus we get stuck there and can't return to lazygit. We could
// fix this bug, or just stop running subprocesses from within there, given that
// we don't need to see a loading status if we're in a subprocess.
func (self *GpgHelper) WithGpgHandling(cmdObj oscommands.ICmdObj, configKey git_commands.GpgConfigKey, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error {
func (self *GpgHelper) WithGpgHandling(cmdObj *oscommands.CmdObj, configKey git_commands.GpgConfigKey, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error {
useSubprocess := self.c.Git().Config.NeedsGpgSubprocess(configKey)
if useSubprocess {
success, err := self.c.RunSubprocess(cmdObj)
@@ -42,7 +42,7 @@ func (self *GpgHelper) WithGpgHandling(cmdObj oscommands.ICmdObj, configKey git_
}
}
func (self *GpgHelper) runAndStream(cmdObj oscommands.ICmdObj, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error {
func (self *GpgHelper) runAndStream(cmdObj *oscommands.CmdObj, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error {
return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error {
if err := cmdObj.StreamOutput().Run(); err != nil {
_ = self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope})
+1 -2
View File
@@ -24,7 +24,7 @@ func NewTagsHelper(c *HelperCommon, commitsHelper *CommitsHelper, gpg *GpgHelper
func (self *TagsHelper) OpenCreateTagPrompt(ref string, onCreate func()) error {
doCreateTag := func(tagName string, description string, force bool) error {
var command oscommands.ICmdObj
var command *oscommands.CmdObj
if description != "" || self.c.Git().Config.GetGpgTagSign() {
self.c.LogAction(self.c.Tr.Actions.CreateAnnotatedTag)
command = self.c.Git().Tag.CreateAnnotatedObj(tagName, ref, description, force)
@@ -34,7 +34,6 @@ func (self *TagsHelper) OpenCreateTagPrompt(ref string, onCreate func()) error {
}
return self.gpg.WithGpgHandling(command, git_commands.TagGpgSign, self.c.Tr.CreatingTag, func() error {
self.commitsHelper.OnCommitSuccess()
return nil
}, []types.RefreshableView{types.COMMITS, types.TAGS})
}
@@ -112,7 +112,7 @@ func (self *WorkingTreeHelper) handleCommit(summary string, description string,
self.c.LogAction(self.c.Tr.Actions.Commit)
return self.gpgHelper.WithGpgHandling(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus,
func() error {
self.commitsHelper.OnCommitSuccess()
self.commitsHelper.ClearPreservedCommitMessage()
return nil
}, nil)
}
@@ -124,7 +124,7 @@ func (self *WorkingTreeHelper) switchFromCommitMessagePanelToEditor(filepath str
// access to the last message that the user typed, and it might be very
// different from what was last in the commit panel. So the best we can do
// here is to always clear the remembered commit message.
self.commitsHelper.OnCommitSuccess()
self.commitsHelper.ClearPreservedCommitMessage()
self.c.LogAction(self.c.Tr.Actions.Commit)
return self.c.RunSubprocessAndRefresh(
@@ -136,6 +136,10 @@ func (self *WorkingTreeHelper) switchFromCommitMessagePanelToEditor(filepath str
// their editor rather than via the popup panel
func (self *WorkingTreeHelper) HandleCommitEditorPress() error {
return self.WithEnsureCommittableFiles(func() error {
// See reasoning in switchFromCommitMessagePanelToEditor for why it makes sense
// to clear this message before calling into the editor
self.commitsHelper.ClearPreservedCommitMessage()
self.c.LogAction(self.c.Tr.Actions.Commit)
return self.c.RunSubprocessAndRefresh(
self.c.Git().Commit.CommitEditorCmdObj(),
+3 -1
View File
@@ -138,7 +138,9 @@ func (self *ListController) HandleGotoTop() error {
}
func (self *ListController) HandleGotoBottom() error {
return self.handleLineChange(self.context.GetList().Len())
bottomIdx := self.context.IndexForGotoBottom()
change := bottomIdx - self.context.GetList().GetSelectedLineIdx()
return self.handleLineChange(change)
}
func (self *ListController) HandleToggleRangeSelect() error {
@@ -207,14 +207,6 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
Description: self.c.Tr.MarkAsBaseCommit,
Tooltip: self.c.Tr.MarkAsBaseCommitTooltip,
},
// overriding this navigation keybinding because we might need to load
// more commits on demand
{
Key: opts.GetKey(opts.Config.Universal.GotoBottom),
Handler: self.gotoBottom,
Description: self.c.Tr.GotoBottom,
Tag: "navigation",
},
}
for _, binding := range outsideFilterModeBindings {
@@ -1154,20 +1146,6 @@ func (self *LocalCommitsController) openSearch() error {
return self.c.Helpers().Search.OpenSearchPrompt(self.context())
}
func (self *LocalCommitsController) gotoBottom() error {
// we usually lazyload these commits but now that we're jumping to the bottom we need to load them now
if self.context().GetLimitCommits() {
self.context().SetLimitCommits(false)
if err := self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}}); err != nil {
return err
}
}
self.context().SetSelectedLineIdx(self.context().Len() - 1)
return nil
}
func (self *LocalCommitsController) handleOpenLogMenu() error {
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.LogMenuTitle,
+23 -24
View File
@@ -56,21 +56,16 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Handler: func(opts gocui.ViewMouseBindingOpts) error {
if self.isFocused() {
return self.onClick(opts)
}
self.context.SetParentContext(self.otherContext.GetParentContext())
self.c.Context().Push(self.context, types.OnFocusOpts{
ClickedWindowName: self.context.GetWindowName(),
ClickedViewLineIdx: opts.Y,
})
return nil
},
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Handler: self.onClickInAlreadyFocusedView,
FocusedView: self.context.GetViewName(),
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Handler: self.onClickInOtherViewOfMainViewPair,
FocusedView: self.otherContext.GetViewName(),
},
}
}
@@ -81,7 +76,6 @@ func (self *MainViewController) Context() types.Context {
func (self *MainViewController) togglePanel() error {
if self.otherContext.GetView().Visible {
self.otherContext.SetParentContext(self.context.GetParentContext())
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
}
@@ -93,14 +87,23 @@ func (self *MainViewController) escape() error {
return nil
}
func (self *MainViewController) onClick(opts gocui.ViewMouseBindingOpts) error {
parentCtx := self.context.GetParentContext()
if parentCtx.GetOnClickFocusedMainView() != nil {
return parentCtx.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y)
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
sidePanelContext := self.c.Context().NextInStack(self.context)
if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil {
return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y)
}
return nil
}
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
self.c.Context().Push(self.context, types.OnFocusOpts{
ClickedWindowName: self.context.GetWindowName(),
ClickedViewLineIdx: opts.Y,
})
return nil
}
func (self *MainViewController) openSearch() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
@@ -112,7 +115,3 @@ func (self *MainViewController) openSearch() error {
return nil
}
func (self *MainViewController) isFocused() bool {
return self.c.Context().Current().GetKey() == self.context.GetKey()
}
@@ -106,7 +106,9 @@ func (self *RenameSimilarityThresholdController) currentSidePanel() types.Contex
currentContext := self.c.Context().CurrentStatic()
if currentContext.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY ||
currentContext.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY {
return currentContext.GetParentContext()
if sidePanelContext := self.c.Context().NextInStack(currentContext); sidePanelContext != nil {
return sidePanelContext
}
}
return currentContext
+13 -1
View File
@@ -60,7 +60,7 @@ func (self *StatusController) GetKeybindings(opts types.KeybindingsOpts) []*type
},
{
Key: opts.GetKey(opts.Config.Status.AllBranchesLogGraph),
Handler: func() error { self.showAllBranchLogs(); return nil },
Handler: func() error { self.switchToOrRotateAllBranchesLogs(); return nil },
Description: self.c.Tr.AllBranchesLogGraph,
},
}
@@ -190,6 +190,18 @@ func (self *StatusController) showAllBranchLogs() {
})
}
// Switches to the all branches view, or, if already on that view,
// rotates to the next command in the list, and then renders it.
func (self *StatusController) switchToOrRotateAllBranchesLogs() {
// A bit of a hack to ensure we only rotate to the next branch log command
// if we currently are looking at a branch log. Otherwise, we should just show
// the current index (if we are coming from the dashboard).
if self.c.Views().Main.Title == self.c.Tr.LogTitle {
self.c.Git().Branch.RotateAllBranchesLogIdx()
}
self.showAllBranchLogs()
}
func (self *StatusController) showDashboard() {
versionStr := "master"
version, err := types.ParseVersionNumber(self.c.GetConfig().GetVersion())
@@ -60,20 +60,18 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context {
}
func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
return self.focusMainView("main")
return self.focusMainView(self.c.Contexts().Normal)
}
func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error {
return self.focusMainView("secondary")
return self.focusMainView(self.c.Contexts().NormalSecondary)
}
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
return self.focusMainView("main")
return self.focusMainView(self.c.Contexts().Normal)
}
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewName string) error {
mainViewContext := self.c.Helpers().Window.GetContextForWindow(mainViewName)
mainViewContext.SetParentContext(self.context)
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error {
if context, ok := mainViewContext.(types.ISearchableContext); ok {
context.ClearSearchString()
}
+3 -3
View File
@@ -3,20 +3,20 @@ package gui
import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/updates"
"github.com/jesseduffield/lazygit/pkg/utils"
)
func NewDummyUpdater() *updates.Updater {
newAppConfig := config.NewDummyAppConfig()
dummyUpdater, _ := updates.NewUpdater(utils.NewDummyCommon(), newAppConfig, oscommands.NewDummyOSCommand())
dummyUpdater, _ := updates.NewUpdater(common.NewDummyCommon(), newAppConfig, oscommands.NewDummyOSCommand())
return dummyUpdater
}
// NewDummyGui creates a new dummy GUI for testing
func NewDummyGui() *Gui {
newAppConfig := config.NewDummyAppConfig()
dummyGui, _ := NewGui(utils.NewDummyCommon(), newAppConfig, &git_commands.GitVersion{Major: 2, Minor: 0, Patch: 0}, NewDummyUpdater(), false, "", nil)
dummyGui, _ := NewGui(common.NewDummyCommon(), newAppConfig, &git_commands.GitVersion{Major: 2, Minor: 0, Patch: 0}, NewDummyUpdater(), false, "", nil)
return dummyGui
}
+2 -2
View File
@@ -131,7 +131,7 @@ func (gui *Gui) pageDownConfirmationPanel() error {
func (gui *Gui) goToConfirmationPanelTop() error {
if gui.Views.Confirmation.Editable {
return nil
return gocui.ErrKeybindingNotHandled
}
gui.Views.Confirmation.ScrollUp(gui.Views.Confirmation.ViewLinesHeight())
@@ -141,7 +141,7 @@ func (gui *Gui) goToConfirmationPanelTop() error {
func (gui *Gui) goToConfirmationPanelBottom() error {
if gui.Views.Confirmation.Editable {
return nil
return gocui.ErrKeybindingNotHandled
}
gui.Views.Confirmation.ScrollDown(gui.Views.Confirmation.ViewLinesHeight())
+3 -3
View File
@@ -932,7 +932,7 @@ func (gui *Gui) checkForDeprecatedEditConfigs() {
}
// returns whether command exited without error or not
func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess oscommands.ICmdObj) error {
func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *oscommands.CmdObj) error {
_, err := gui.runSubprocessWithSuspense(subprocess)
if err != nil {
return err
@@ -946,7 +946,7 @@ func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess oscommands.ICmdOb
}
// returns whether command exited without error or not
func (gui *Gui) runSubprocessWithSuspense(subprocess oscommands.ICmdObj) (bool, error) {
func (gui *Gui) runSubprocessWithSuspense(subprocess *oscommands.CmdObj) (bool, error) {
gui.Mutexes.SubprocessMutex.Lock()
defer gui.Mutexes.SubprocessMutex.Unlock()
@@ -970,7 +970,7 @@ func (gui *Gui) runSubprocessWithSuspense(subprocess oscommands.ICmdObj) (bool,
return true, nil
}
func (gui *Gui) runSubprocess(cmdObj oscommands.ICmdObj) error { //nolint:unparam
func (gui *Gui) runSubprocess(cmdObj *oscommands.CmdObj) error { //nolint:unparam
gui.LogCommand(cmdObj.ToString(), true)
subprocess := cmdObj.GetCmd()
+2 -2
View File
@@ -34,11 +34,11 @@ func (self *guiCommon) PostRefreshUpdate(context types.Context) {
self.gui.postRefreshUpdate(context)
}
func (self *guiCommon) RunSubprocessAndRefresh(cmdObj oscommands.ICmdObj) error {
func (self *guiCommon) RunSubprocessAndRefresh(cmdObj *oscommands.CmdObj) error {
return self.gui.runSubprocessWithSuspenseAndRefresh(cmdObj)
}
func (self *guiCommon) RunSubprocess(cmdObj oscommands.ICmdObj) (bool, error) {
func (self *guiCommon) RunSubprocess(cmdObj *oscommands.CmdObj) (bool, error) {
return self.gui.runSubprocessWithSuspense(cmdObj)
}
+1 -1
View File
@@ -165,7 +165,7 @@ func BranchStatus(
) string {
itemOperationStr := ItemOperationToString(itemOperation, tr)
if itemOperationStr != "" {
return style.FgCyan.Sprintf("%s %s", itemOperationStr, utils.Loader(now, userConfig.Gui.Spinner))
return style.FgCyan.Sprintf("%s %s", itemOperationStr, Loader(now, userConfig.Gui.Spinner))
}
result := ""
+2 -2
View File
@@ -8,9 +8,9 @@ import (
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
@@ -320,7 +320,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
defer color.ForceSetColorLevel(oldColorLevel)
c := utils.NewDummyCommon()
c := common.NewDummyCommon()
SetCustomBranches(c.UserConfig().Gui.BranchColorPatterns, true)
for i, s := range scenarios {
+2 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/jesseduffield/generics/set"
"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/utils"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
@@ -539,7 +540,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
}
}
common := utils.NewDummyCommon()
common := common.NewDummyCommon()
for _, s := range scenarios {
if !focusing || s.focus {
+14
View File
@@ -0,0 +1,14 @@
package presentation
import (
"time"
"github.com/jesseduffield/lazygit/pkg/config"
)
// Loader dumps a string to be displayed as a loader
func Loader(now time.Time, config config.SpinnerConfig) string {
milliseconds := now.UnixMilli()
index := milliseconds / int64(config.Rate) % int64(len(config.Frames))
return config.Frames[index]
}
+1 -2
View File
@@ -10,7 +10,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -49,7 +48,7 @@ func getRemoteDisplayStrings(
descriptionStr := style.FgBlue.Sprintf("%d branches", branchCount)
itemOperationStr := ItemOperationToString(itemOperation, tr)
if itemOperationStr != "" {
descriptionStr += " " + style.FgCyan.Sprint(itemOperationStr+" "+utils.Loader(time.Now(), userConfig.Gui.Spinner))
descriptionStr += " " + style.FgCyan.Sprint(itemOperationStr+" "+Loader(time.Now(), userConfig.Gui.Spinner))
}
res = append(res, textStyle.Sprint(r.Name), descriptionStr)
return res
+1 -2
View File
@@ -10,7 +10,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -47,7 +46,7 @@ func getTagDisplayStrings(
descriptionStr := descriptionColor.Sprint(t.Description())
itemOperationStr := ItemOperationToString(itemOperation, tr)
if itemOperationStr != "" {
descriptionStr = style.FgCyan.Sprint(itemOperationStr+" "+utils.Loader(time.Now(), userConfig.Gui.Spinner)) + " " + descriptionStr
descriptionStr = style.FgCyan.Sprint(itemOperationStr+" "+Loader(time.Now(), userConfig.Gui.Spinner)) + " " + descriptionStr
}
res = append(res, textStyle.Sprint(t.Name), descriptionStr)
return res
@@ -262,7 +262,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
cmdObj := self.c.OS().Cmd.NewShell(cmdStr, self.c.UserConfig().OS.ShellFunctionsFile)
if customCommand.Subprocess != nil && *customCommand.Subprocess {
if customCommand.Output == "terminal" {
return self.c.RunSubprocessAndRefresh(cmdObj)
}
@@ -274,9 +274,12 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
return self.c.WithWaitingStatus(loadingText, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.CustomCommand)
if customCommand.Stream != nil && *customCommand.Stream {
if customCommand.Output == "log" || customCommand.Output == "logWithPty" {
cmdObj.StreamOutput()
}
if customCommand.Output == "logWithPty" {
cmdObj.UsePty()
}
output, err := cmdObj.RunWithOutput()
if refreshErr := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil {
@@ -291,7 +294,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
return err
}
if customCommand.ShowOutput != nil && *customCommand.ShowOutput {
if customCommand.Output == "popup" {
if strings.TrimSpace(output) == "" {
output = self.c.Tr.EmptyOutput
}
@@ -3,7 +3,7 @@ package custom_commands
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/stretchr/testify/assert"
)
@@ -82,7 +82,7 @@ func TestMenuGenerator(t *testing.T) {
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
s.test(NewMenuGenerator(utils.NewDummyCommon()).call(s.cmdOut, s.filter, s.valueFormat, s.labelFormat))
s.test(NewMenuGenerator(common.NewDummyCommon()).call(s.cmdOut, s.filter, s.valueFormat, s.labelFormat))
})
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
)
@@ -75,7 +75,7 @@ func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (strin
}
topStatus := self.statuses[0]
if topStatus.statusType == "waiting" {
return topStatus.message + " " + utils.Loader(time.Now(), userConfig.Gui.Spinner), topStatus.color
return topStatus.message + " " + presentation.Loader(time.Now(), userConfig.Gui.Spinner), topStatus.color
}
return topStatus.message, topStatus.color
}
+2 -2
View File
@@ -53,8 +53,8 @@ type IGuiCommon interface {
GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager
// returns true if command completed successfully
RunSubprocess(cmdObj oscommands.ICmdObj) (bool, error)
RunSubprocessAndRefresh(oscommands.ICmdObj) error
RunSubprocess(cmdObj *oscommands.CmdObj) (bool, error)
RunSubprocessAndRefresh(*oscommands.CmdObj) error
Context() IContextMgr
ContextForKey(key ContextKey) Context
+3
View File
@@ -180,6 +180,8 @@ type IListContext interface {
IsListContext() // used for type switch
RangeSelectEnabled() bool
RenderOnlyVisibleLines() bool
IndexForGotoBottom() int
}
type IPatchExplorerContext interface {
@@ -298,6 +300,7 @@ type IContextMgr interface {
CurrentStatic() Context
CurrentSide() Context
CurrentPopup() []Context
NextInStack(context Context) Context
IsCurrent(c Context) bool
IsCurrentOrParent(c Context) bool
ForEach(func(Context))
+4 -2
View File
@@ -152,8 +152,10 @@ func (gui *Gui) postRefreshUpdate(c types.Context) {
// just don't rerender the view while searching, on the assumption that users will probably
// either search or change their data, but not both at the same time.
if !currentCtx.GetView().IsSearching() {
parentCtx := currentCtx.GetParentContext()
parentCtx.HandleRenderToMain()
sidePanelContext := gui.State.ContextMgr.NextInStack(currentCtx)
if sidePanelContext != nil && sidePanelContext.GetKey() == c.GetKey() {
sidePanelContext.HandleRenderToMain()
}
}
}
}
+9 -2
View File
@@ -330,6 +330,7 @@ type TranslationSet struct {
CommitDescriptionTitle string
CommitDescriptionSubTitle string
CommitDescriptionFooter string
CommitDescriptionFooterTwoBindings string
CommitHooksDisabledSubTitle string
LocalBranchesTitle string
SearchTitle string
@@ -813,6 +814,8 @@ type TranslationSet struct {
MovePatchOutIntoIndexTooltip string
MovePatchIntoNewCommit string
MovePatchIntoNewCommitTooltip string
MovePatchIntoNewCommitBefore string
MovePatchIntoNewCommitBeforeTooltip string
MovePatchToSelectedCommit string
MovePatchToSelectedCommitTooltip string
CopyPatchToClipboard string
@@ -1408,7 +1411,8 @@ func EnglishTranslationSet() *TranslationSet {
CommitSummaryTitle: "Commit summary",
CommitDescriptionTitle: "Commit description",
CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus, {{.commitMenuKeybinding}} to open menu",
CommitDescriptionFooter: "Press {{.confirmInEditorKeybinding}} to commit",
CommitDescriptionFooter: "Press {{.confirmInEditorKeybinding}} to submit",
CommitDescriptionFooterTwoBindings: "Press {{.confirmInEditorKeybinding1}} or {{.confirmInEditorKeybinding2}} to submit",
CommitHooksDisabledSubTitle: "(hooks disabled)",
LocalBranchesTitle: "Local branches",
SearchTitle: "Search",
@@ -1896,8 +1900,10 @@ func EnglishTranslationSet() *TranslationSet {
RemovePatchFromOriginalCommitTooltip: "Remove the current patch from its commit. This is achieved by starting an interactive rebase at the commit, applying the patch in reverse, and then continuing the rebase. If later commits depend on the patch, you may need to resolve conflicts.",
MovePatchOutIntoIndex: "Move patch out into index",
MovePatchOutIntoIndexTooltip: "Move the patch out of its commit and into the index. This is achieved by starting an interactive rebase at the commit, applying the patch in reverse, continuing the rebase to completion, and then applying the patch to the index. If later commits depend on the patch, you may need to resolve conflicts.",
MovePatchIntoNewCommit: "Move patch into new commit",
MovePatchIntoNewCommit: "Move patch into new commit after the original commit",
MovePatchIntoNewCommitTooltip: "Move the patch out of its commit and into a new commit sitting on top of the original commit. This is achieved by starting an interactive rebase at the original commit, applying the patch in reverse, then applying the patch to the index and committing it as a new commit, before continuing the rebase to completion. If later commits depend on the patch, you may need to resolve conflicts.",
MovePatchIntoNewCommitBefore: "Move patch into new commit before the original commit",
MovePatchIntoNewCommitBeforeTooltip: "Move the patch out of its commit and into a new commit before the original commit. This works best when the custom patch contains only entire hunks or even entire files; if it contains partial hunks, you are likely to get conflicts.",
MovePatchToSelectedCommit: "Move patch to selected commit (%s)",
MovePatchToSelectedCommitTooltip: "Move the patch out of its original commit and into the selected commit. This is achieved by starting an interactive rebase at the original commit, applying the patch in reverse, then continuing the rebase up to the selected commit, before applying the patch forward and amending the selected commit. The rebase is then continued to completion. If commits between the source and destination commit depend on the patch, you may need to resolve conflicts.",
CopyPatchToClipboard: "Copy patch to clipboard",
@@ -2169,6 +2175,7 @@ git:
autoForwardBranches: none
If, on the other hand, you want this even for feature branches, you can set it to 'allBranches' instead.`,
"0.51.0": `- The 'subprocess', 'stream', and 'showOutput' fields of custom commands have been replaced by a single 'output' field. This should be transparent, if you used these in your config file it should have been automatically updated for you. There's one notable change though: the 'stream' field used to mean both that the command's output would be streamed to the command log, and that the command would be run in a pseudo terminal (pty). We converted this to 'output: log', which means that the command's output will be streamed to the command log, but not use a pty, on the assumption that this is what most people wanted. If you do actually want to run a command in a pty, you can change this to 'output: logWithPty' instead.`,
},
}
}
@@ -15,8 +15,6 @@ var CherryPickMerge = NewIntegrationTest(NewIntegrationTestArgs{
EmptyCommit("base").
NewBranch("first-branch").
NewBranch("second-branch").
Checkout("first-branch").
Checkout("second-branch").
CreateFileAndAdd("file1.txt", "content").
Commit("one").
CreateFileAndAdd("file2.txt", "content").
@@ -15,19 +15,18 @@ var ShowOutputInPanel = NewIntegrationTest(NewIntegrationTestArgs{
shell.EmptyCommit("my change")
},
SetupConfig: func(cfg *config.AppConfig) {
trueVal := true
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
{
Key: "X",
Context: "commits",
Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'",
ShowOutput: &trueVal,
Key: "X",
Context: "commits",
Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'",
Output: "popup",
},
{
Key: "Y",
Context: "commits",
Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'",
ShowOutput: &trueVal,
Output: "popup",
OutputTitle: "Subject of commit {{ .SelectedLocalCommit.Hash }}",
},
}
@@ -0,0 +1,55 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RewordLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Rewords the last commit of a branch in the middle of a stack",
ExtraCmdArgs: []string{},
Skip: false,
GitVersion: AtLeast("2.38.0"),
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.MainBranches = []string{"master"}
config.GetAppState().GitLogShowGraph = "never"
},
SetupRepo: func(shell *Shell) {
shell.
CreateNCommits(1).
NewBranch("branch1").
CreateNCommitsStartingAt(2, 2).
NewBranch("branch2").
CreateNCommitsStartingAt(2, 4)
shell.SetConfig("rebase.updateRefs", "true")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI commit 05").IsSelected(),
Contains("CI commit 04"),
Contains("CI * commit 03"),
Contains("CI commit 02"),
Contains("CI commit 01"),
).
NavigateToLine(Contains("commit 03")).
Press(keys.Commits.RenameCommit).
Tap(func() {
t.ExpectPopup().CommitMessagePanel().
Title(Equals("Reword commit")).
InitialText(Equals("commit 03")).
Clear().
Type("renamed 03").
Confirm()
}).
Lines(
Contains("CI commit 05"),
Contains("CI commit 04"),
Contains("CI * renamed 03"),
Contains("CI commit 02"),
Contains("CI commit 01"),
)
},
})
@@ -0,0 +1,50 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RewordMergeCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Rewords a merge commit which is not the current head commit",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.
EmptyCommit("base").
NewBranch("first-branch").
CreateFileAndAdd("file1.txt", "content").
Commit("one").
Checkout("master").
Merge("first-branch").
NewBranch("second-branch").
EmptyCommit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI ◯ two").IsSelected(),
Contains("CI ⏣─╮ Merge branch 'first-branch'"),
Contains("CI │ ◯ one"),
Contains("CI ◯─╯ base"),
).
SelectNextItem().
Press(keys.Commits.RenameCommit).
Tap(func() {
t.ExpectPopup().CommitMessagePanel().
Title(Equals("Reword commit")).
InitialText(Equals("Merge branch 'first-branch'")).
Clear().
Type("renamed merge").
Confirm()
}).
Lines(
Contains("CI ◯ two"),
Contains("CI ⏣─╮ renamed merge").IsSelected(),
Contains("CI │ ◯ one"),
Contains("CI ◯ ╯ base"),
)
},
})
@@ -48,7 +48,7 @@ var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
@@ -0,0 +1,91 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MoveToNewCommitBefore = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move a patch from a commit to a new commit before the original one",
ExtraCmdArgs: []string{},
Skip: false,
GitVersion: AtLeast("2.26.0"),
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir")
shell.CreateFileAndAdd("dir/file1", "file1 content")
shell.CreateFileAndAdd("dir/file2", "file2 content")
shell.Commit("first commit")
shell.UpdateFileAndAdd("dir/file1", "file1 content with old changes")
shell.DeleteFileAndAdd("dir/file2")
shell.CreateFileAndAdd("dir/file3", "file3 content")
shell.Commit("commit to move from")
shell.UpdateFileAndAdd("dir/file1", "file1 content with new changes")
shell.Commit("third commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("third commit").IsSelected(),
Contains("commit to move from"),
Contains("first commit"),
).
SelectNextItem().
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("dir").IsSelected(),
Contains(" M file1"),
Contains(" D file2"),
Contains(" A file3"),
).
PressPrimaryAction().
PressEscape()
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit before the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
Type("new commit").Confirm()
t.Views().Commits().
IsFocused().
Lines(
Contains("third commit"),
Contains("commit to move from").IsSelected(),
Contains("new commit"),
Contains("first commit"),
).
SelectNextItem().
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("dir").IsSelected(),
Contains(" M file1"),
Contains(" D file2"),
Contains(" A file3"),
).
PressEscape()
t.Views().Commits().
IsFocused().
SelectPreviousItem().
PressEnter()
// the original commit has no more files in it
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("(none)"),
)
},
})
@@ -0,0 +1,77 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MoveToNewCommitBeforeNoKeepEmpty = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move a patch from a commit to a new commit before the original one, for older git versions that don't keep the empty commit",
ExtraCmdArgs: []string{},
Skip: false,
GitVersion: Before("2.26.0"),
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir")
shell.CreateFileAndAdd("dir/file1", "file1 content")
shell.CreateFileAndAdd("dir/file2", "file2 content")
shell.Commit("first commit")
shell.UpdateFileAndAdd("dir/file1", "file1 content with old changes")
shell.DeleteFileAndAdd("dir/file2")
shell.CreateFileAndAdd("dir/file3", "file3 content")
shell.Commit("commit to move from")
shell.UpdateFileAndAdd("dir/file1", "file1 content with new changes")
shell.Commit("third commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("third commit").IsSelected(),
Contains("commit to move from"),
Contains("first commit"),
).
SelectNextItem().
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("dir").IsSelected(),
Contains(" M file1"),
Contains(" D file2"),
Contains(" A file3"),
).
PressPrimaryAction().
PressEscape()
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit before the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
Type("new commit").Confirm()
t.Views().Commits().
IsFocused().
Lines(
Contains("third commit"),
Contains("new commit").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("dir").IsSelected(),
Contains(" M file1"),
Contains(" D file2"),
Contains(" A file3"),
).
PressEscape()
},
})
@@ -39,7 +39,7 @@ var MoveToNewCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
@@ -39,7 +39,7 @@ var MoveToNewCommitFromDeletedFile = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
@@ -0,0 +1,72 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move a patch from a commit to a new commit, in the last commit of a branch in the middle of a stack",
ExtraCmdArgs: []string{},
Skip: false,
GitVersion: AtLeast("2.38.0"),
SetupConfig: func(config *config.AppConfig) {
config.GetAppState().GitLogShowGraph = "never"
},
SetupRepo: func(shell *Shell) {
shell.
EmptyCommit("commit 01").
NewBranch("branch1").
EmptyCommit("commit 02").
CreateFileAndAdd("file1", "file1 content").
CreateFileAndAdd("file2", "file2 content").
Commit("commit 03").
NewBranch("branch2").
CreateNCommitsStartingAt(2, 4)
shell.SetConfig("rebase.updateRefs", "true")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI commit 05").IsSelected(),
Contains("CI commit 04"),
Contains("CI * commit 03"),
Contains("CI commit 02"),
Contains("CI commit 01"),
).
NavigateToLine(Contains("commit 03")).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Equals("▼ /").IsSelected(),
Equals(" A file1"),
Equals(" A file2"),
).
SelectNextItem().
PressPrimaryAction().
PressEscape()
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
Type("new commit").Confirm()
t.Views().Commits().
IsFocused().
Lines(
Contains("CI commit 05"),
Contains("CI commit 04"),
Contains("CI * new commit").IsSelected(),
Contains("CI commit 03"),
Contains("CI commit 02"),
Contains("CI commit 01"),
)
},
})
@@ -44,7 +44,7 @@ var MoveToNewCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
+1 -2
View File
@@ -10,8 +10,7 @@ var LogCmd = NewIntegrationTest(NewIntegrationTestArgs{
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.AllBranchesLogCmd = `echo "view1"`
config.GetUserConfig().Git.AllBranchesLogCmds = []string{`echo "view2"`}
config.GetUserConfig().Git.AllBranchesLogCmds = []string{`echo "view1"`, `echo "view2"`}
},
SetupRepo: func(shell *Shell) {},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
@@ -0,0 +1,38 @@
package status
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var LogCmdStatusPanelAllBranchesLog = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Cycle between two different log commands in the Status view when it has status panel AllBranchesLog",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.AllBranchesLogCmds = []string{`echo "view1"`, `echo "view2"`}
config.GetUserConfig().Gui.StatusPanelView = "allBranchesLog"
},
SetupRepo: func(shell *Shell) {},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Status().
Focus()
t.Views().Main().Content(Contains("view1"))
// We head to the branches view and return
t.Views().Branches().
Focus()
t.Views().Status().
Focus()
t.Views().Main().Content(Contains("view1").DoesNotContain("view2"))
t.Views().Status().
Press(keys.Status.AllBranchesLogGraph)
t.Views().Main().Content(Contains("view2").DoesNotContain("view1"))
t.Views().Status().
Press(keys.Status.AllBranchesLogGraph)
t.Views().Main().Content(Contains("view1").DoesNotContain("view2"))
},
})
+6
View File
@@ -274,6 +274,8 @@ var tests = []*components.IntegrationTest{
interactive_rebase.RewordCommitWithEditorAndFail,
interactive_rebase.RewordFirstCommit,
interactive_rebase.RewordLastCommit,
interactive_rebase.RewordLastCommitOfStackedBranch,
interactive_rebase.RewordMergeCommit,
interactive_rebase.RewordYouAreHereCommit,
interactive_rebase.RewordYouAreHereCommitWithEditor,
interactive_rebase.ShowExecTodos,
@@ -308,8 +310,11 @@ var tests = []*components.IntegrationTest{
patch_building.MoveToLaterCommit,
patch_building.MoveToLaterCommitPartialHunk,
patch_building.MoveToNewCommit,
patch_building.MoveToNewCommitBefore,
patch_building.MoveToNewCommitBeforeNoKeepEmpty,
patch_building.MoveToNewCommitFromAddedFile,
patch_building.MoveToNewCommitFromDeletedFile,
patch_building.MoveToNewCommitInLastCommitOfStackedBranch,
patch_building.MoveToNewCommitPartialHunk,
patch_building.RemoveFromCommit,
patch_building.RemovePartsOfAddedFile,
@@ -355,6 +360,7 @@ var tests = []*components.IntegrationTest{
status.ClickToFocus,
status.ClickWorkingTreeStateToOpenRebaseOptionsMenu,
status.LogCmd,
status.LogCmdStatusPanelAllBranchesLog,
status.ShowDivergenceFromBaseBranch,
submodule.Add,
submodule.Enter,
-29
View File
@@ -3,11 +3,7 @@ package utils
import (
"io"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/sirupsen/logrus"
"github.com/spf13/afero"
)
// NewDummyLog creates a new dummy Log for testing
@@ -16,28 +12,3 @@ func NewDummyLog() *logrus.Entry {
log.Out = io.Discard
return log.WithField("test", "test")
}
func NewDummyCommon() *common.Common {
tr := i18n.EnglishTranslationSet()
cmn := &common.Common{
Log: NewDummyLog(),
Tr: tr,
Fs: afero.NewOsFs(),
}
cmn.SetUserConfig(config.GetDefaultConfig())
return cmn
}
func NewDummyCommonWithUserConfigAndAppState(userConfig *config.UserConfig, appState *config.AppState) *common.Common {
tr := i18n.EnglishTranslationSet()
cmn := &common.Common{
Log: NewDummyLog(),
Tr: tr,
AppState: appState,
// TODO: remove dependency on actual filesystem in tests and switch to using
// in-memory for everything
Fs: afero.NewOsFs(),
}
cmn.SetUserConfig(userConfig)
return cmn
}
-9
View File
@@ -8,10 +8,8 @@ import (
"runtime"
"strconv"
"strings"
"time"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/config"
)
// GetProjectRoot returns the path to the root of the project. Only to be used
@@ -25,13 +23,6 @@ func GetProjectRoot() string {
return strings.Split(dir, "lazygit")[0] + "lazygit"
}
// Loader dumps a string to be displayed as a loader
func Loader(now time.Time, config config.SpinnerConfig) string {
milliseconds := now.UnixMilli()
index := milliseconds / int64(config.Rate) % int64(len(config.Frames))
return config.Frames[index]
}
func SortRange(x int, y int) (int, int) {
if x < y {
return x, y
+26 -98
View File
@@ -4,93 +4,12 @@ import (
"bytes"
"errors"
"fmt"
"slices"
"gopkg.in/yaml.v3"
)
// takes a yaml document in bytes, a path to a key, and a value to set. The value must be a scalar.
func UpdateYamlValue(yamlBytes []byte, path []string, value string) ([]byte, error) {
// Parse the YAML file.
var node yaml.Node
err := yaml.Unmarshal(yamlBytes, &node)
if err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
// Empty document: need to create the top-level map ourselves
if len(node.Content) == 0 {
node.Content = append(node.Content, &yaml.Node{
Kind: yaml.MappingNode,
})
}
body := node.Content[0]
if body.Kind != yaml.MappingNode {
return yamlBytes, errors.New("yaml document is not a dictionary")
}
if didChange, err := updateYamlNode(body, path, value); err != nil || !didChange {
return yamlBytes, err
}
// Convert the updated YAML node back to YAML bytes.
updatedYAMLBytes, err := YamlMarshal(body)
if err != nil {
return nil, fmt.Errorf("failed to convert YAML node to bytes: %w", err)
}
return updatedYAMLBytes, nil
}
// Recursive function to update the YAML node.
func updateYamlNode(node *yaml.Node, path []string, value string) (bool, error) {
if len(path) == 0 {
if node.Kind != yaml.ScalarNode {
return false, errors.New("yaml node is not a scalar")
}
if node.Value != value {
node.Value = value
return true, nil
}
return false, nil
}
if node.Kind != yaml.MappingNode {
return false, errors.New("yaml node in path is not a dictionary")
}
key := path[0]
if _, valueNode := lookupKey(node, key); valueNode != nil {
return updateYamlNode(valueNode, path[1:], value)
}
// if the key doesn't exist, we'll add it
// at end of path: add the new key, done
if len(path) == 1 {
node.Content = append(node.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: key,
}, &yaml.Node{
Kind: yaml.ScalarNode,
Value: value,
})
return true, nil
}
// otherwise, create the missing intermediate node and continue
newNode := &yaml.Node{
Kind: yaml.MappingNode,
}
node.Content = append(node.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: key,
}, newNode)
return updateYamlNode(newNode, path[1:], value)
}
func lookupKey(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) {
func LookupKey(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) {
for i := 0; i < len(node.Content)-1; i += 2 {
if node.Content[i].Value == key {
return node.Content[i], node.Content[i+1]
@@ -100,6 +19,19 @@ func lookupKey(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) {
return nil, nil
}
// Returns the key and value if they were present
func RemoveKey(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) {
for i := 0; i < len(node.Content)-1; i += 2 {
if node.Content[i].Value == key {
key, value := node.Content[i], node.Content[i+1]
node.Content = slices.Delete(node.Content, i, i+2)
return key, value
}
}
return nil, nil
}
// Walks a yaml document from the root node to the specified path, and then applies the transformation to that node.
// If the requested path is not defined in the document, no changes are made to the document.
func TransformNode(rootNode *yaml.Node, path []string, transform func(node *yaml.Node) error) error {
@@ -123,7 +55,7 @@ func transformNode(node *yaml.Node, path []string, transform func(node *yaml.Nod
return transform(node)
}
keyNode, valueNode := lookupKey(node, path[0])
keyNode, valueNode := LookupKey(node, path[0])
if keyNode == nil {
return nil
}
@@ -133,41 +65,37 @@ func transformNode(node *yaml.Node, path []string, transform func(node *yaml.Nod
// Takes the root node of a yaml document, a path to a key, and a new name for the key.
// Will rename the key to the new name if it exists, and do nothing otherwise.
func RenameYamlKey(rootNode *yaml.Node, path []string, newKey string) error {
func RenameYamlKey(rootNode *yaml.Node, path []string, newKey string) (error, bool) {
// Empty document: nothing to do.
if len(rootNode.Content) == 0 {
return nil
return nil, false
}
body := rootNode.Content[0]
if err := renameYamlKey(body, path, newKey); err != nil {
return err
}
return nil
return renameYamlKey(body, path, newKey)
}
// Recursive function to rename the YAML key.
func renameYamlKey(node *yaml.Node, path []string, newKey string) error {
func renameYamlKey(node *yaml.Node, path []string, newKey string) (error, bool) {
if node.Kind != yaml.MappingNode {
return errors.New("yaml node in path is not a dictionary")
return errors.New("yaml node in path is not a dictionary"), false
}
keyNode, valueNode := lookupKey(node, path[0])
keyNode, valueNode := LookupKey(node, path[0])
if keyNode == nil {
return nil
return nil, false
}
// end of path reached: rename key
if len(path) == 1 {
// Check that new key doesn't exist yet
if newKeyNode, _ := lookupKey(node, newKey); newKeyNode != nil {
return fmt.Errorf("new key `%s' already exists", newKey)
if newKeyNode, _ := LookupKey(node, newKey); newKeyNode != nil {
return fmt.Errorf("new key `%s' already exists", newKey), false
}
keyNode.Value = newKey
return nil
return nil, true
}
return renameYamlKey(valueNode, path[1:], newKey)
+108 -156
View File
@@ -8,186 +8,87 @@ import (
"gopkg.in/yaml.v3"
)
func TestUpdateYamlValue(t *testing.T) {
tests := []struct {
name string
in string
path []string
value string
expectedOut string
expectedErr string
}{
{
name: "update value",
in: "foo: bar\n",
path: []string{"foo"},
value: "baz",
expectedOut: "foo: baz\n",
expectedErr: "",
},
{
name: "add new key and value",
in: "foo: bar\n",
path: []string{"foo2"},
value: "baz",
expectedOut: "foo: bar\nfoo2: baz\n",
expectedErr: "",
},
{
name: "add new key and value when document was empty",
in: "",
path: []string{"foo"},
value: "bar",
expectedOut: "foo: bar\n",
expectedErr: "",
},
{
name: "preserve inline comment",
in: "foo: bar # my comment\n",
path: []string{"foo2"},
value: "baz",
expectedOut: "foo: bar # my comment\nfoo2: baz\n",
expectedErr: "",
},
{
name: "nested update",
in: "foo:\n bar: baz\n",
path: []string{"foo", "bar"},
value: "qux",
expectedOut: "foo:\n bar: qux\n",
expectedErr: "",
},
{
name: "nested where parents doesn't exist yet",
in: "",
path: []string{"foo", "bar", "baz"},
value: "qux",
expectedOut: "foo:\n bar:\n baz: qux\n",
expectedErr: "",
},
{
name: "don't rewrite file if value didn't change",
in: "foo:\n bar: baz\n",
path: []string{"foo", "bar"},
value: "baz",
expectedOut: "foo:\n bar: baz\n",
expectedErr: "",
},
// Error cases
{
name: "existing document is not a dictionary",
in: "42\n",
path: []string{"foo"},
value: "bar",
expectedOut: "42\n",
expectedErr: "yaml document is not a dictionary",
},
{
name: "trying to update a note that is not a scalar",
in: "foo: [1, 2, 3]\n",
path: []string{"foo"},
value: "bar",
expectedOut: "foo: [1, 2, 3]\n",
expectedErr: "yaml node is not a scalar",
},
{
name: "not all path elements are dictionaries",
in: "foo:\n bar: [1, 2, 3]\n",
path: []string{"foo", "bar", "baz"},
value: "qux",
expectedOut: "foo:\n bar: [1, 2, 3]\n",
expectedErr: "yaml node in path is not a dictionary",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out, actualErr := UpdateYamlValue([]byte(test.in), test.path, test.value)
if test.expectedErr == "" {
assert.NoError(t, actualErr)
} else {
assert.EqualError(t, actualErr, test.expectedErr)
}
assert.Equal(t, test.expectedOut, string(out))
})
}
}
func TestRenameYamlKey(t *testing.T) {
tests := []struct {
name string
in string
path []string
newKey string
expectedOut string
expectedErr string
name string
in string
path []string
newKey string
expectedOut string
expectedDidRename bool
expectedErr string
}{
{
name: "rename key",
in: "foo: 5\n",
path: []string{"foo"},
newKey: "bar",
expectedOut: "bar: 5\n",
expectedErr: "",
name: "rename key",
in: "foo: 5\n",
path: []string{"foo"},
newKey: "bar",
expectedOut: "bar: 5\n",
expectedDidRename: true,
expectedErr: "",
},
{
name: "rename key, nested",
in: "foo:\n bar: 5\n",
path: []string{"foo", "bar"},
newKey: "baz",
expectedOut: "foo:\n baz: 5\n",
expectedErr: "",
name: "rename key, nested",
in: "foo:\n bar: 5\n",
path: []string{"foo", "bar"},
newKey: "baz",
expectedOut: "foo:\n baz: 5\n",
expectedDidRename: true,
expectedErr: "",
},
{
name: "rename non-scalar key",
in: "foo:\n bar: 5\n",
path: []string{"foo"},
newKey: "qux",
expectedOut: "qux:\n bar: 5\n",
expectedErr: "",
name: "rename non-scalar key",
in: "foo:\n bar: 5\n",
path: []string{"foo"},
newKey: "qux",
expectedOut: "qux:\n bar: 5\n",
expectedDidRename: true,
expectedErr: "",
},
{
name: "don't rewrite file if value didn't change",
in: "foo:\n bar: 5\n",
path: []string{"nonExistingKey"},
newKey: "qux",
expectedOut: "foo:\n bar: 5\n",
expectedErr: "",
name: "don't rewrite file if value didn't change",
in: "foo:\n bar: 5\n",
path: []string{"nonExistingKey"},
newKey: "qux",
expectedOut: "foo:\n bar: 5\n",
expectedDidRename: false,
expectedErr: "",
},
// Error cases
{
name: "existing document is not a dictionary",
in: "42\n",
path: []string{"foo"},
newKey: "bar",
expectedOut: "42\n",
expectedErr: "yaml node in path is not a dictionary",
name: "existing document is not a dictionary",
in: "42\n",
path: []string{"foo"},
newKey: "bar",
expectedOut: "42\n",
expectedDidRename: false,
expectedErr: "yaml node in path is not a dictionary",
},
{
name: "not all path elements are dictionaries",
in: "foo:\n bar: [1, 2, 3]\n",
path: []string{"foo", "bar", "baz"},
newKey: "qux",
expectedOut: "foo:\n bar: [1, 2, 3]\n",
expectedErr: "yaml node in path is not a dictionary",
name: "not all path elements are dictionaries",
in: "foo:\n bar: [1, 2, 3]\n",
path: []string{"foo", "bar", "baz"},
newKey: "qux",
expectedOut: "foo:\n bar: [1, 2, 3]\n",
expectedDidRename: false,
expectedErr: "yaml node in path is not a dictionary",
},
{
name: "new key exists",
in: "foo: 5\nbar: 7\n",
path: []string{"foo"},
newKey: "bar",
expectedOut: "foo: 5\nbar: 7\n",
expectedErr: "new key `bar' already exists",
name: "new key exists",
in: "foo: 5\nbar: 7\n",
path: []string{"foo"},
newKey: "bar",
expectedOut: "foo: 5\nbar: 7\n",
expectedDidRename: false,
expectedErr: "new key `bar' already exists",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
node := unmarshalForTest(t, test.in)
actualErr := RenameYamlKey(&node, test.path, test.newKey)
actualErr, didRename := RenameYamlKey(&node, test.path, test.newKey)
if test.expectedErr == "" {
assert.NoError(t, actualErr)
} else {
@@ -196,6 +97,8 @@ func TestRenameYamlKey(t *testing.T) {
out := marshalForTest(t, &node)
assert.Equal(t, test.expectedOut, out)
assert.Equal(t, test.expectedDidRename, didRename)
})
}
}
@@ -389,6 +292,55 @@ foo:
}
}
func TestRemoveKey(t *testing.T) {
tests := []struct {
name string
in string
key string
expectedOut string
expectedRemovedKey string
expectedRemovedValue string
}{
{
name: "Key not present",
in: "foo: 1",
key: "bar",
},
{
name: "Key present",
in: "foo: 1\nbar: 2\nbaz: 3\n",
key: "bar",
expectedOut: "foo: 1\nbaz: 3\n",
expectedRemovedKey: "bar",
expectedRemovedValue: "2",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
node := unmarshalForTest(t, test.in)
removedKey, removedValue := RemoveKey(node.Content[0], test.key)
if test.expectedOut == "" {
unmodifiedOriginal := unmarshalForTest(t, test.in)
assert.Equal(t, unmodifiedOriginal, node)
} else {
result := marshalForTest(t, &node)
assert.Equal(t, test.expectedOut, result)
}
if test.expectedRemovedKey == "" {
assert.Nil(t, removedKey)
} else {
assert.Equal(t, test.expectedRemovedKey, removedKey.Value)
}
if test.expectedRemovedValue == "" {
assert.Nil(t, removedValue)
} else {
assert.Equal(t, test.expectedRemovedValue, removedValue.Value)
}
})
}
}
func unmarshalForTest(t *testing.T, input string) yaml.Node {
t.Helper()
var node yaml.Node
+20 -19
View File
@@ -96,10 +96,6 @@
"git fetch {{.Form.Remote}} {{.Form.Branch}} \u0026\u0026 git checkout FETCH_HEAD"
]
},
"subprocess": {
"type": "boolean",
"description": "If true, run the command in a subprocess (e.g. if the command requires user input)"
},
"prompts": {
"items": {
"$ref": "#/$defs/CustomCommandPrompt"
@@ -118,17 +114,20 @@
"type": "string",
"description": "Label for the custom command when displayed in the keybindings menu"
},
"stream": {
"type": "boolean",
"description": "If true, stream the command's output to the Command Log panel"
},
"showOutput": {
"type": "boolean",
"description": "If true, show the command's output in a popup within Lazygit"
"output": {
"type": "string",
"enum": [
"none",
"terminal",
"log",
"logWithPty",
"popup"
],
"description": "Where the output of the command should go. 'none' discards it, 'terminal' suspends lazygit and runs the command in the terminal (useful for commands that require user input), 'log' streams it to the command log, 'logWithPty' is like 'log' but runs the command in a pseudo terminal (can be useful for commands that produce colored output when the output is a terminal), and 'popup' shows it in a popup."
},
"outputTitle": {
"type": "string",
"description": "The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title."
"description": "The title to display in the popup panel if output is set to 'popup'. If left unset, the command will be used as the title."
},
"after": {
"$ref": "#/$defs/CustomCommandAfterHook",
@@ -332,7 +331,7 @@
"onlyMainBranches",
"allBranches"
],
"description": "If not \"none\", lazygit will automatically forward branches to 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).\nPossible values: 'none' | 'onlyMainBranches' | 'allBranches'",
"description": "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).\nPossible values: 'none' | 'onlyMainBranches' | 'allBranches'",
"default": "onlyMainBranches"
},
"fetchAll": {
@@ -350,17 +349,15 @@
"description": "Command used when displaying the current branch git log in the main window",
"default": "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --"
},
"allBranchesLogCmd": {
"type": "string",
"description": "Command used to display git log of all branches in the main window.\nDeprecated: Use `allBranchesLogCmds` instead.",
"default": "git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium"
},
"allBranchesLogCmds": {
"items": {
"type": "string"
},
"type": "array",
"description": "Commands used to display git log of all branches in the main window, they will be cycled in order of appearance (array of strings)"
"description": "Commands used to display git log of all branches in the main window, they will be cycled in order of appearance (array of strings)",
"default": [
"git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium"
]
},
"overrideGpg": {
"type": "boolean",
@@ -1313,6 +1310,10 @@
"type": "string",
"default": "\u003ca-enter\u003e"
},
"confirmInEditor-alt": {
"type": "string",
"default": "\u003cc-s\u003e"
},
"remove": {
"type": "string",
"default": "d"
+2 -2
View File
@@ -19,7 +19,7 @@ func Values[Key comparable, Value any](m map[Key]Value) []Value {
func TransformValues[Key comparable, Value any, NewValue any](
m map[Key]Value, fn func(Value) NewValue,
) map[Key]NewValue {
output := make(map[Key]NewValue)
output := make(map[Key]NewValue, len(m))
for key, value := range m {
output[key] = fn(value)
}
@@ -27,7 +27,7 @@ func TransformValues[Key comparable, Value any, NewValue any](
}
func TransformKeys[Key comparable, Value any, NewKey comparable](m map[Key]Value, fn func(Key) NewKey) map[NewKey]Value {
output := make(map[NewKey]Value)
output := make(map[NewKey]Value, len(m))
for key, value := range m {
output[fn(key)] = value
}
+65
View File
@@ -0,0 +1,65 @@
package orderedset
import (
orderedmap "github.com/wk8/go-ordered-map/v2"
)
type OrderedSet[T comparable] struct {
om *orderedmap.OrderedMap[T, bool]
}
func New[T comparable]() *OrderedSet[T] {
return &OrderedSet[T]{om: orderedmap.New[T, bool]()}
}
func NewFromSlice[T comparable](slice []T) *OrderedSet[T] {
result := &OrderedSet[T]{om: orderedmap.New[T, bool](len(slice))}
result.Add(slice...)
return result
}
func (os *OrderedSet[T]) Add(values ...T) {
for _, value := range values {
os.om.Set(value, true)
}
}
func (os *OrderedSet[T]) Remove(value T) {
os.om.Delete(value)
}
func (os *OrderedSet[T]) RemoveSlice(slice []T) {
for _, value := range slice {
os.Remove(value)
}
}
func (os *OrderedSet[T]) Includes(value T) bool {
return os.om.Value(value)
}
func (os *OrderedSet[T]) Len() int {
return os.om.Len()
}
func (os *OrderedSet[T]) ToSliceFromOldest() []T {
// TODO: can be simplified to
// return os.om.KeysFromOldest()
// when we update to a newer version of go-ordered-map
result := make([]T, 0, os.Len())
for pair := os.om.Oldest(); pair != nil; pair = pair.Next() {
result = append(result, pair.Key)
}
return result
}
func (os *OrderedSet[T]) ToSliceFromNewest() []T {
// TODO: can be simplified to
// return os.om.KeysFromNewest()
// when we update to a newer version of go-ordered-map
result := make([]T, 0, os.Len())
for pair := os.om.Newest(); pair != nil; pair = pair.Prev() {
result = append(result, pair.Key)
}
return result
}
+3 -6
View File
@@ -11,12 +11,9 @@ func New[T comparable]() *Set[T] {
}
func NewFromSlice[T comparable](slice []T) *Set[T] {
hashMap := make(map[T]bool)
for _, value := range slice {
hashMap[value] = true
}
return &Set[T]{hashMap: hashMap}
result := &Set[T]{hashMap: make(map[T]bool, len(slice))}
result.Add(slice...)
return result
}
func (s *Set[T]) Add(values ...T) {
+18 -2
View File
@@ -39,6 +39,9 @@ var (
// ErrQuit is used to decide if the MainLoop finished successfully.
ErrQuit = standardErrors.New("quit")
// ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further
ErrKeybindingNotHandled = standardErrors.New("keybinding not handled")
)
const (
@@ -1518,7 +1521,13 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
continue
}
if g.matchView(v, kb) {
return g.execKeybinding(v, kb)
err := g.execKeybinding(v, kb)
if IsKeybindingNotHandled(err) {
matchingParentViewKb = nil
break
} else {
return err
}
}
if v != nil && g.matchView(v.ParentView, kb) {
matchingParentViewKb = kb
@@ -1528,7 +1537,10 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
}
}
if matchingParentViewKb != nil {
return g.execKeybinding(v.ParentView, matchingParentViewKb)
err := g.execKeybinding(v.ParentView, matchingParentViewKb)
if !IsKeybindingNotHandled(err) {
return err
}
}
if g.currentView != nil && g.currentView.Editable && g.currentView.Editor != nil {
@@ -1615,6 +1627,10 @@ func IsQuit(err error) bool {
return err != nil && err.Error() == ErrQuit.Error()
}
func IsKeybindingNotHandled(err error) bool {
return err != nil && err.Error() == ErrKeybindingNotHandled.Error()
}
func (g *Gui) Suspend() error {
g.suspendedMutex.Lock()
defer g.suspendedMutex.Unlock()
+89 -18
View File
@@ -12,6 +12,8 @@ package errgroup
import (
"context"
"fmt"
"runtime"
"runtime/debug"
"sync"
)
@@ -31,6 +33,10 @@ type Group struct {
errOnce sync.Once
err error
mu sync.Mutex
panicValue any // = PanicError | PanicValue; non-nil if some Group.Go goroutine panicked.
abnormal bool // some Group.Go goroutine terminated abnormally (panic or goexit).
}
func (g *Group) done() {
@@ -50,13 +56,22 @@ func WithContext(ctx context.Context) (*Group, context.Context) {
return &Group{cancel: cancel}, ctx
}
// Wait blocks until all function calls from the Go method have returned, then
// returns the first non-nil error (if any) from them.
// Wait blocks until all function calls from the Go method have returned
// normally, then returns the first non-nil error (if any) from them.
//
// If any of the calls panics, Wait panics with a [PanicValue];
// and if any of them calls [runtime.Goexit], Wait calls runtime.Goexit.
func (g *Group) Wait() error {
g.wg.Wait()
if g.cancel != nil {
g.cancel(g.err)
}
if g.panicValue != nil {
panic(g.panicValue)
}
if g.abnormal {
runtime.Goexit()
}
return g.err
}
@@ -65,18 +80,56 @@ func (g *Group) Wait() error {
// It blocks until the new goroutine can be added without the number of
// active goroutines in the group exceeding the configured limit.
//
// The first call to return a non-nil error cancels the group's context, if the
// group was created by calling WithContext. The error will be returned by Wait.
// It blocks until the new goroutine can be added without the number of
// goroutines in the group exceeding the configured limit.
//
// The first goroutine in the group that returns a non-nil error, panics, or
// invokes [runtime.Goexit] will cancel the associated Context, if any.
func (g *Group) Go(f func() error) {
if g.sem != nil {
g.sem <- token{}
}
g.add(f)
}
func (g *Group) add(f func() error) {
g.wg.Add(1)
go func() {
defer g.done()
normalReturn := false
defer func() {
if normalReturn {
return
}
v := recover()
g.mu.Lock()
defer g.mu.Unlock()
if !g.abnormal {
if g.cancel != nil {
g.cancel(g.err)
}
g.abnormal = true
}
if v != nil && g.panicValue == nil {
switch v := v.(type) {
case error:
g.panicValue = PanicError{
Recovered: v,
Stack: debug.Stack(),
}
default:
g.panicValue = PanicValue{
Recovered: v,
Stack: debug.Stack(),
}
}
}
}()
if err := f(); err != nil {
err := f()
normalReturn = true
if err != nil {
g.errOnce.Do(func() {
g.err = err
if g.cancel != nil {
@@ -101,19 +154,7 @@ func (g *Group) TryGo(f func() error) bool {
}
}
g.wg.Add(1)
go func() {
defer g.done()
if err := f(); err != nil {
g.errOnce.Do(func() {
g.err = err
if g.cancel != nil {
g.cancel(g.err)
}
})
}
}()
g.add(f)
return true
}
@@ -135,3 +176,33 @@ func (g *Group) SetLimit(n int) {
}
g.sem = make(chan token, n)
}
// PanicError wraps an error recovered from an unhandled panic
// when calling a function passed to Go or TryGo.
type PanicError struct {
Recovered error
Stack []byte // result of call to [debug.Stack]
}
func (p PanicError) Error() string {
// A Go Error method conventionally does not include a stack dump, so omit it
// here. (Callers who care can extract it from the Stack field.)
return fmt.Sprintf("recovered from errgroup.Group: %v", p.Recovered)
}
func (p PanicError) Unwrap() error { return p.Recovered }
// PanicValue wraps a value that does not implement the error interface,
// recovered from an unhandled panic when calling a function passed to Go or
// TryGo.
type PanicValue struct {
Recovered any
Stack []byte // result of call to [debug.Stack]
}
func (p PanicValue) String() string {
if len(p.Stack) > 0 {
return fmt.Sprintf("recovered from errgroup.Group: %v\n%s", p.Recovered, p.Stack)
}
return fmt.Sprintf("recovered from errgroup.Group: %v", p.Recovered)
}

Some files were not shown because too many files have changed in this diff Show More