Compare commits

...
Author SHA1 Message Date
Jesse Duffield 7357c1daea Only apply directory-level actions to visible files 2026-02-09 08:12:50 +11:00
Jesse DuffieldandClaude Opus 4.6 75488f1041 Filter file views rather than search
Change working tree files and commit files panels to use filtering
(reducing the list) instead of search (highlighting matches). This
matches the behavior of other filterable views.

The text filter matches against the full file path, not just the
filename, which is more useful for navigating large directory trees.

When toggling a directory for a custom patch while a text filter is
active, all files in the directory are included (not just the visible
filtered ones), consistent with how staging a directory in the files
panel works.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 08:12:50 +11:00
Jesse DuffieldandClaude Opus 4.6 3db8175bb5 Rename GetFilter to GetStatusFilter on IFileTree
This avoids a naming collision with GetFilter from the
IFilterableContext interface, which will be implemented by
FileTreeViewModel in the next commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 08:12:50 +11:00
Jesse Duffield 5f8bca6508 Allow removing lines from patch directly
I always press 'd' in the patch building view, expecting that I can do
exactly what I can do in the staging view, to find out I need to go
space -> ctrl+p -> d and I think it's time to honour the muscle memory
and support this convenience keybinding.
2026-02-09 08:12:41 +11:00
Jesse Duffield 3b677cbcfe Fetch PRs after checkout (not blocking UI)
If you check out a new branch via the remote branches tab you'd want to
see its status
2026-02-08 10:14:14 +11:00
Jesse Duffield 658dbd2bdf Improve github repo picking UX
We now auto-select a remote if:
* it's the only remote
* it's named origin

Otherwise we prompt the user to specify the remote they want to use.
2026-02-08 09:53:39 +11:00
Jesse Duffield a9a13619fd Fetch PRs newest-first
Some branch names are re-used across PRs, so now we sort PRs by
newest-first when fetching so we don't get stale PRs.
2026-02-08 09:51:34 +11:00
Jesse Duffield 5d452122b3 Stop fetch github PRs when checking out a branch
They aren't going to change when you check out a branch, and they take a
long time to fetch.
2026-02-07 22:18:27 +11:00
Jesse Duffield f70b0a214b Cache Github PRs for snappier startup experience 2026-02-07 22:18:27 +11:00
Jesse DuffieldandClaude Opus 4.6 b81b6e3c2e Fix PR integration: nerd font guards, tests, generated files, typo
- Fix "if if" typo in enableGithubCli config description
- Guard nerd font icons in PR state badges and main view display
  behind icons.IsIconEnabled() so non-nerd-font users see plain text
- Add tests for GenerateGithubPullRequestMap and getRepoInfoFromURL
- Update branch display test expectations for new icon column
- Fix integration tests that matched "* branchname" patterns
  (now need to account for icon column between recency and name)
- Remove commented-out dead code in branches.go
- Regenerate docs and schema with corrected config description

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 22:18:27 +11:00
Stefan Haller 4bed4b8965 WIP Add draft state, iterate on UI design 2026-02-07 20:29:07 +11:00
Stefan Haller 4fab224ccd Show PR information in main view above the branch log when a branch is selected 2026-02-07 20:29:07 +11:00
Stefan Haller 95005999e6 Use github icon instead of just coloring the branch icon 2026-02-07 20:29:07 +11:00
Stefan Haller f84cff3ba5 WIP use origin remote
I don't know what I'm doing here, but this seems to be needed to make it work
for me.
2026-02-07 20:29:07 +11:00
Jesse Duffield 2ae87d3d0f WIP 2026-02-07 20:29:07 +11:00
Jesse Duffield a2b2fa7a5e Show github pull request status against branch 2026-02-07 20:29:07 +11:00
85 changed files with 3385 additions and 263 deletions
+4
View File
@@ -481,6 +481,10 @@ git:
# to 40 to disable truncation.
truncateCopiedCommitHashesTo: 12
# If true and if `gh` is installed and on version >=2, we will use `gh` to
# display pull requests against branches.
enableGithubCli: true
# Periodic update checks
update:
# One of: 'prompt' (default) | 'background' | 'never'
+3 -2
View File
@@ -71,7 +71,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | Search the current view by text | |
| `` / `` | Filter the current view by text | |
## Commit summary
@@ -160,7 +160,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | Search the current view by text | |
| `` / `` | Filter the current view by text | |
## Input prompt
@@ -237,6 +237,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` o `` | Open file | Open file in default application. |
| `` e `` | Edit file | Open file in external editor. |
| `` <space> `` | Toggle lines in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | Search the current view by text | |
+3 -2
View File
@@ -119,7 +119,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味
| `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます |
| `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します |
| `` 0 `` | メインビューにフォーカス | |
| `` / `` | 現在のビューをテキストで検索 | |
| `` / `` | 現在のビューをテキストでフィルタリング | |
## コミット概要
@@ -242,7 +242,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味
| `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます |
| `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します |
| `` 0 `` | メインビューにフォーカス | |
| `` / `` | 現在のビューをテキストで検索 | |
| `` / `` | 現在のビューをテキストでフィルタリング | |
## メインパネル(ステージング)
@@ -278,6 +278,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味
| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | パッチ内の行を切り替え | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | カスタムパッチビルダーを終了 | |
| `` / `` | 現在のビューをテキストで検索 | |
+3 -2
View File
@@ -178,6 +178,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` o `` | 파일 닫기 | Open file in default application. |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <space> `` | Line(s)을 패치에 추가/삭제 | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | 검색 시작 | |
@@ -348,7 +349,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | 검색 시작 | |
| `` / `` | Filter the current view by text | |
## 커밋메시지
@@ -404,7 +405,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | 검색 시작 | |
| `` / `` | Filter the current view by text | |
## 확인 패널
+3 -2
View File
@@ -84,7 +84,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | Start met zoeken | |
| `` / `` | Filter the current view by text | |
## Bevestigingspaneel
@@ -149,7 +149,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | Start met zoeken | |
| `` / `` | Filter the current view by text | |
## Commits
@@ -245,6 +245,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` o `` | Open bestand | Open file in default application. |
| `` e `` | Verander bestand | Open file in external editor. |
| `` <space> `` | Voeg toe/verwijder lijn(en) in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sluit lijn-bij-lijn modus | |
| `` / `` | Start met zoeken | |
+3 -2
View File
@@ -124,6 +124,7 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-b>` oznacza alt+b, `B` oznacza shift+b_
| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <space> `` | Przełącz linie w łatce | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Wyjdź z budowniczego niestandardowej łatki | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
@@ -259,7 +260,7 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-b>` oznacza alt+b, `B` oznacza shift+b_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
| `` / `` | Filtruj bieżący widok po tekście | |
## Pliki commita
@@ -279,7 +280,7 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-b>` oznacza alt+b, `B` oznacza shift+b_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
| `` / `` | Filtruj bieżący widok po tekście | |
## Podsumowanie commita
+3 -2
View File
@@ -84,7 +84,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos |
| `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo |
| `` 0 `` | Focus main view | |
| `` / `` | Search the current view by text | |
| `` / `` | Filter the current view by text | |
## Branches locais
@@ -153,7 +153,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos |
| `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo |
| `` 0 `` | Focus main view | |
| `` / `` | Search the current view by text | |
| `` / `` | Filter the current view by text | |
## Commits
@@ -295,6 +295,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <space> `` | Alternar linhas no caminho | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sair do construtor de patch personalizado | |
| `` / `` | Search the current view by text | |
+3 -2
View File
@@ -138,6 +138,7 @@ _Связки клавиш_
| `` o `` | Открыть файл | Open file in default application. |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Добавить/удалить строку(и) для патча | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Выйти из сборщика пользовательских патчей | |
| `` / `` | Найти | |
@@ -307,7 +308,7 @@ _Связки клавиш_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | Найти | |
| `` / `` | Filter the current view by text | |
## Статус
@@ -398,7 +399,7 @@ _Связки клавиш_
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | Найти | |
| `` / `` | Filter the current view by text | |
## Хранилище
+3 -2
View File
@@ -183,7 +183,7 @@ _图例:`<c-b>` 意味着ctrl+b, `<a-b>意味着Alt+b, `B` 意味着shift+b_
| `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 |
| `` = `` | 展开全部文件 | 展开文件树中的全部目录 |
| `` 0 `` | 聚焦主视图 | |
| `` / `` | 开始搜索 | |
| `` / `` | 通过文本过滤当前视图 | |
## 文件
@@ -216,7 +216,7 @@ _图例:`<c-b>` 意味着ctrl+b, `<a-b>意味着Alt+b, `B` 意味着shift+b_
| `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 |
| `` = `` | 展开全部文件 | 展开文件树中的全部目录 |
| `` 0 `` | 聚焦主视图 | |
| `` / `` | 开始搜索 | |
| `` / `` | 通过文本过滤当前视图 | |
## 本地分支
@@ -260,6 +260,7 @@ _图例:`<c-b>` 意味着ctrl+b, `<a-b>意味着Alt+b, `B` 意味着shift+b_
| `` o `` | 打开文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 添加/移除 行到补丁 | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | 退出逐行模式 | |
| `` / `` | 开始搜索 | |
@@ -72,6 +72,7 @@ _說明:`<c-b>` 表示 Ctrl+B、`<a-b>` 表示 Alt+B,`B`表示 Shift+B
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | 退出自訂補丁建立器 | |
| `` / `` | 搜尋 | |
+4
View File
@@ -481,6 +481,10 @@ git:
# to 40 to disable truncation.
truncateCopiedCommitHashesTo: 12
# If true and if if `gh` is installed and on version >=2, we will use `gh` to
# display pull requests against branches.
enableGithubCli: true
# Periodic update checks
update:
# One of: 'prompt' (default) | 'background' | 'never'
+4 -2
View File
@@ -10,6 +10,7 @@ require (
github.com/adrg/xdg v0.4.0
github.com/atotto/clipboard v0.1.4
github.com/aybabtme/humanlog v0.4.1
github.com/cli/go-gh/v2 v2.9.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.11
github.com/gdamore/tcell/v2 v2.13.8
@@ -49,6 +50,7 @@ require (
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cli/safeexec v1.0.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -66,8 +68,8 @@ require (
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.11 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/onsi/ginkgo v1.10.3 // indirect
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
+11 -6
View File
@@ -64,6 +64,10 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cli/go-gh/v2 v2.9.0 h1:D3lTjEneMYl54M+WjZ+kRPrR5CEJ5BHS05isBPOV3LI=
github.com/cli/go-gh/v2 v2.9.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE=
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
@@ -223,13 +227,14 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mgutz/str v1.2.0 h1:4IzWSdIz9qPQWLfKZ0rJcV0jcUDpxvP4JVZ4GXQyvSw=
github.com/mgutz/str v1.2.0/go.mod h1:w1v0ofgLaJdoD0HpQ3fycxKD1WtxpjSo151pK/31q6w=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
@@ -451,13 +456,13 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+47 -41
View File
@@ -18,27 +18,29 @@ import (
// GitCommand is our main git interface
type GitCommand struct {
Blame *git_commands.BlameCommands
Branch *git_commands.BranchCommands
Commit *git_commands.CommitCommands
Config *git_commands.ConfigCommands
Custom *git_commands.CustomCommands
Diff *git_commands.DiffCommands
File *git_commands.FileCommands
Flow *git_commands.FlowCommands
Patch *git_commands.PatchCommands
Rebase *git_commands.RebaseCommands
Remote *git_commands.RemoteCommands
Stash *git_commands.StashCommands
Status *git_commands.StatusCommands
Submodule *git_commands.SubmoduleCommands
Sync *git_commands.SyncCommands
Tag *git_commands.TagCommands
WorkingTree *git_commands.WorkingTreeCommands
Bisect *git_commands.BisectCommands
Worktree *git_commands.WorktreeCommands
Version *git_commands.GitVersion
RepoPaths *git_commands.RepoPaths
Blame *git_commands.BlameCommands
Branch *git_commands.BranchCommands
Commit *git_commands.CommitCommands
Config *git_commands.ConfigCommands
Custom *git_commands.CustomCommands
Diff *git_commands.DiffCommands
File *git_commands.FileCommands
Flow *git_commands.FlowCommands
Patch *git_commands.PatchCommands
Rebase *git_commands.RebaseCommands
Remote *git_commands.RemoteCommands
Stash *git_commands.StashCommands
Status *git_commands.StatusCommands
Submodule *git_commands.SubmoduleCommands
Sync *git_commands.SyncCommands
Tag *git_commands.TagCommands
WorkingTree *git_commands.WorkingTreeCommands
Bisect *git_commands.BisectCommands
Worktree *git_commands.WorktreeCommands
Version *git_commands.GitVersion
RepoPaths *git_commands.RepoPaths
GitHub *git_commands.GitHubCommands
HostingService *git_commands.HostingService
Loaders Loaders
}
@@ -137,6 +139,8 @@ func NewGitCommandAux(
bisectCommands := git_commands.NewBisectCommands(gitCommon)
worktreeCommands := git_commands.NewWorktreeCommands(gitCommon)
blameCommands := git_commands.NewBlameCommands(gitCommon)
gitHubCommands := git_commands.NewGitHubCommand(gitCommon)
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
@@ -148,26 +152,28 @@ func NewGitCommandAux(
tagLoader := git_commands.NewTagLoader(cmn, cmd)
return &GitCommand{
Blame: blameCommands,
Branch: branchCommands,
Commit: commitCommands,
Config: configCommands,
Custom: customCommands,
Diff: diffCommands,
File: fileCommands,
Flow: flowCommands,
Patch: patchCommands,
Rebase: rebaseCommands,
Remote: remoteCommands,
Stash: stashCommands,
Status: statusCommands,
Submodule: submoduleCommands,
Sync: syncCommands,
Tag: tagCommands,
Bisect: bisectCommands,
WorkingTree: workingTreeCommands,
Worktree: worktreeCommands,
Version: version,
Blame: blameCommands,
Branch: branchCommands,
Commit: commitCommands,
Config: configCommands,
Custom: customCommands,
Diff: diffCommands,
File: fileCommands,
Flow: flowCommands,
Patch: patchCommands,
Rebase: rebaseCommands,
Remote: remoteCommands,
Stash: stashCommands,
Status: statusCommands,
Submodule: submoduleCommands,
Sync: syncCommands,
Tag: tagCommands,
Bisect: bisectCommands,
WorkingTree: workingTreeCommands,
Worktree: worktreeCommands,
Version: version,
GitHub: gitHubCommands,
HostingService: hostingServiceCommands,
Loaders: Loaders{
BranchLoader: branchLoader,
CommitFileLoader: commitFileLoader,
+426
View File
@@ -0,0 +1,426 @@
package git_commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/cli/go-gh/v2/pkg/auth"
gogit "github.com/jesseduffield/go-git/v5"
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/samber/lo"
"golang.org/x/sync/errgroup"
)
type GitHubCommands struct {
*GitCommon
}
func NewGitHubCommand(gitCommon *GitCommon) *GitHubCommands {
return &GitHubCommands{
GitCommon: gitCommon,
}
}
// https://github.com/cli/cli/issues/2300
func (self *GitHubCommands) BaseRepo() error {
cmdArgs := NewGitCmd("config").
Arg("--local", "--get-regexp", ".gh-resolved").
ToArgv()
return self.cmd.New(cmdArgs).DontLog().Run()
}
// Ex: git config --local --add "remote.origin.gh-resolved" "jesseduffield/lazygit"
func (self *GitHubCommands) SetBaseRepo(repository string) (string, error) {
cmdArgs := NewGitCmd("config").
Arg("--local", "--add", "remote.origin.gh-resolved", repository).
ToArgv()
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
}
type Response struct {
Data RepositoryQuery `json:"data"`
}
type RepositoryQuery struct {
Repository map[string]PullRequest `json:"repository"`
}
type PullRequest struct {
Edges []PullRequestEdge `json:"edges"`
}
type PullRequestEdge struct {
Node PullRequestNode `json:"node"`
}
type PullRequestNode struct {
Title string `json:"title"`
HeadRefName string `json:"headRefName"`
Number int `json:"number"`
Url string `json:"url"`
HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
State string `json:"state"`
IsDraft bool `json:"isDraft"`
}
type GithubRepositoryOwner struct {
Login string `json:"login"`
}
func fetchPullRequestsQuery(branches []string, owner string, repo string) string {
queries := make([]string, 0, len(branches))
for i, branch := range branches {
// We're making a sub-query per branch, and arbitrarily labelling each subquery
// as a1, a2, etc.
fieldName := fmt.Sprintf("a%d", i+1)
// We fetch a few PRs per branch name because multiple forks may have PRs
// with the same head ref name. The mapping logic filters by owner later.
queries = append(queries, fmt.Sprintf(`%s: pullRequests(first: 5, headRefName: "%s", orderBy: {field: CREATED_AT, direction: DESC}) {
edges {
node {
title
headRefName
state
number
url
isDraft
headRepositoryOwner {
login
}
}
}
}`, fieldName, branch))
}
queryString := fmt.Sprintf(`{
repository(owner: "%s", name: "%s") {
%s
}
}`, owner, repo, strings.Join(queries, "\n"))
return queryString
}
// FetchRecentPRs fetches recent pull requests using GraphQL.
func (self *GitHubCommands) FetchRecentPRs(branches []string) ([]*models.GithubPullRequest, error) {
repoOwner, repoName, err := self.GetBaseRepoOwnerAndName()
if err != nil {
return nil, err
}
t := time.Now()
var g errgroup.Group
results := make(chan []*models.GithubPullRequest)
// We want at most 5 concurrent requests, but no less than 10 branches per request
concurrency := 5
minBranchesPerRequest := 10
branchesPerRequest := max(len(branches)/concurrency, minBranchesPerRequest)
for i := 0; i < len(branches); i += branchesPerRequest {
end := i + branchesPerRequest
if end > len(branches) {
end = len(branches)
}
branchChunk := branches[i:end]
// Launch a goroutine for each chunk of branches
g.Go(func() error {
prs, err := self.FetchRecentPRsAux(repoOwner, repoName, branchChunk)
if err != nil {
return err
}
results <- prs
return nil
})
}
// Close the results channel when all goroutines are done
go func() {
_ = g.Wait()
close(results)
}()
// Collect results from all goroutines
var allPRs []*models.GithubPullRequest
for prs := range results {
allPRs = append(allPRs, prs...)
}
if err := g.Wait(); err != nil {
return nil, err
}
self.Log.Warnf("Fetched PRs in %s", time.Since(t))
return allPRs, nil
}
func (self *GitHubCommands) FetchRecentPRsAux(repoOwner string, repoName string, branches []string) ([]*models.GithubPullRequest, error) {
queryString := fetchPullRequestsQuery(branches, repoOwner, repoName)
escapedQueryString := strconv.Quote(queryString)
body := fmt.Sprintf(`{"query": %s}`, escapedQueryString)
req, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewBuffer([]byte(body)))
if err != nil {
return nil, err
}
defaultHost, _ := auth.DefaultHost()
token, _ := auth.TokenForHost(defaultHost)
if token == "" {
return nil, fmt.Errorf("No token found for GitHub")
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyStr := new(bytes.Buffer)
_, _ = bodyStr.ReadFrom(resp.Body)
return nil, fmt.Errorf("GraphQL query failed with status: %s. Body: %s", resp.Status, bodyStr.String())
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result Response
err = json.Unmarshal(bodyBytes, &result)
if err != nil {
return nil, err
}
prs := []*models.GithubPullRequest{}
for _, repoQuery := range result.Data.Repository {
for _, edge := range repoQuery.Edges {
node := edge.Node
pr := &models.GithubPullRequest{
HeadRefName: node.HeadRefName,
Number: node.Number,
Title: node.Title,
State: lo.Ternary(node.IsDraft && node.State != "CLOSED", "DRAFT", node.State),
Url: node.Url,
HeadRepositoryOwner: models.GithubRepositoryOwner{
Login: node.HeadRepositoryOwner.Login,
},
}
prs = append(prs, pr)
}
}
return prs, nil
}
// returns a map from branch name to pull request
func GenerateGithubPullRequestMap(
prs []*models.GithubPullRequest,
branches []*models.Branch,
remotes []*models.Remote,
) map[string]*models.GithubPullRequest {
res := map[string]*models.GithubPullRequest{}
if len(prs) == 0 {
return res
}
remotesToOwnersMap := getRemotesToOwnersMap(remotes)
// A PR can be identified by two things: the owner e.g. 'jesseduffield' and the
// branch name e.g. 'feature/my-feature'. The owner might be different
// to the owner of the repo if the PR is from a fork of that repo.
type prKey struct {
owner string
branchName string
}
prByKey := map[prKey]models.GithubPullRequest{}
for _, pr := range prs {
key := prKey{owner: pr.UserName(), branchName: pr.BranchName()}
// PRs are returned newest-first from the API, so the first one we
// see for each key is the most recent and therefore the most relevant.
if _, exists := prByKey[key]; !exists {
prByKey[key] = *pr
}
}
for _, branch := range branches {
if !branch.IsTrackingRemote() {
continue
}
owner, foundRemoteOwner := remotesToOwnersMap[branch.UpstreamRemote]
if !foundRemoteOwner {
// UpstreamRemote may be a full URL rather than a remote name;
// try parsing the owner directly from it.
repoInfo, err := hosting_service.GetRepoInfoFromURL(branch.UpstreamRemote)
if err != nil {
continue
}
owner = repoInfo.Owner
}
pr, hasPr := prByKey[prKey{owner: owner, branchName: branch.UpstreamBranch}]
if !hasPr {
continue
}
res[branch.Name] = &pr
}
return res
}
func getRemotesToOwnersMap(remotes []*models.Remote) map[string]string {
res := map[string]string{}
for _, remote := range remotes {
if len(remote.Urls) == 0 {
continue
}
repoInfo, err := hosting_service.GetRepoInfoFromURL(remote.Urls[0])
if err != nil {
continue
}
res[remote.Name] = repoInfo.Owner
}
return res
}
// return <installed>, <valid version>
func (self *GitHubCommands) DetermineGitHubCliState() (bool, bool) {
output, err := self.cmd.New([]string{"gh", "--version"}).DontLog().RunWithOutput()
if err != nil {
// assuming a failure here means that it's not installed
return false, false
}
if !isGhVersionValid(output) {
return true, false
}
return true, true
}
func isGhVersionValid(versionStr string) bool {
// output should be something like:
// gh version 2.0.0 (2021-08-23)
// https://github.com/cli/cli/releases/tag/v2.0.0
re := regexp.MustCompile(`[^\d]+([\d\.]+)`)
matches := re.FindStringSubmatch(versionStr)
if len(matches) == 0 {
return false
}
ghVersion := matches[1]
majorVersion, err := strconv.Atoi(ghVersion[0:1])
if err != nil {
return false
}
if majorVersion < 2 {
return false
}
return true
}
func (self *GitHubCommands) InGithubRepo() bool {
remotes, err := self.repo.Remotes()
if err != nil {
self.Log.Error(err)
return false
}
if len(remotes) == 0 {
return false
}
remote := GetMainRemote(remotes)
if len(remote.Config().URLs) == 0 {
return false
}
url := remote.Config().URLs[0]
return strings.Contains(url, "github.com")
}
func GetMainRemote(remotes []*gogit.Remote) *gogit.Remote {
for _, remote := range remotes {
if remote.Config().Name == "origin" {
return remote
}
}
// need to sort remotes by name so that this is deterministic
return lo.MinBy(remotes, func(a, b *gogit.Remote) bool {
return a.Config().Name < b.Config().Name
})
}
func GetSuggestedRemoteName(remotes []*models.Remote) string {
if len(remotes) == 0 {
return "origin"
}
for _, remote := range remotes {
if remote.Name == "origin" {
return remote.Name
}
}
return remotes[0].Name
}
func (self *GitHubCommands) GetBaseRepoOwnerAndName() (string, string, error) {
remotes, err := self.repo.Remotes()
if err != nil {
return "", "", err
}
if len(remotes) == 0 {
return "", "", fmt.Errorf("No remotes found")
}
originRemote, ok := lo.Find(remotes, func(remote *gogit.Remote) bool {
return remote.Config().Name == "origin"
})
if !ok {
return "", "", fmt.Errorf("Origin remote not found")
}
if len(originRemote.Config().URLs) == 0 {
return "", "", fmt.Errorf("No URLs found for remote")
}
url := originRemote.Config().URLs[0]
repoInfo, err := hosting_service.GetRepoInfoFromURL(url)
if err != nil {
return "", "", err
}
return repoInfo.Owner, repoInfo.Repository, nil
}
+392
View File
@@ -0,0 +1,392 @@
package git_commands
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
func TestIsValidGhVersion(t *testing.T) {
type scenario struct {
versionStr string
expectedResult bool
}
scenarios := []scenario{
{
"",
false,
},
{
`gh version 1.0.0 (2020-08-23)
https://github.com/cli/cli/releases/tag/v1.0.0`,
false,
},
{
`gh version 2.0.0 (2021-08-23)
https://github.com/cli/cli/releases/tag/v2.0.0`,
true,
},
{
`gh version 1.1.0 (2021-10-14)
https://github.com/cli/cli/releases/tag/v1.1.0
A new release of gh is available: 1.1.0 → v2.2.0
To upgrade, run: brew update && brew upgrade gh
https://github.com/cli/cli/releases/tag/v2.2.0`,
false,
},
}
for _, s := range scenarios {
t.Run(s.versionStr, func(t *testing.T) {
result := isGhVersionValid(s.versionStr)
assert.Equal(t, result, s.expectedResult)
})
}
}
func TestGetSuggestedRemoteName(t *testing.T) {
cases := []struct {
remotes []*models.Remote
expected string
}{
{mkRemoteList(), "origin"},
{mkRemoteList("upstream", "origin", "foo"), "origin"},
{mkRemoteList("upstream", "foo", "bar"), "upstream"},
}
for _, c := range cases {
result := GetSuggestedRemoteName(c.remotes)
assert.EqualValues(t, c.expected, result)
}
}
func TestGetRepoInfoFromURL(t *testing.T) {
cases := []struct {
name string
url string
expected hosting_service.RepoInformation
}{
{
name: "SSH URL",
url: "git@github.com:jesseduffield/lazygit.git",
expected: hosting_service.RepoInformation{
Owner: "jesseduffield",
Repository: "lazygit",
},
},
{
name: "HTTPS URL",
url: "https://github.com/jesseduffield/lazygit.git",
expected: hosting_service.RepoInformation{
Owner: "jesseduffield",
Repository: "lazygit",
},
},
{
name: "HTTPS URL without .git",
url: "https://github.com/jesseduffield/lazygit",
expected: hosting_service.RepoInformation{
Owner: "jesseduffield",
Repository: "lazygit",
},
},
{
name: "SSH URL with org nesting",
url: "git@github.com:my-org/sub-group/lazygit.git",
expected: hosting_service.RepoInformation{
Owner: "my-org/sub-group",
Repository: "lazygit",
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
result, err := hosting_service.GetRepoInfoFromURL(c.url)
assert.NoError(t, err)
assert.Equal(t, c.expected, result)
})
}
}
func TestGenerateGithubPullRequestMap(t *testing.T) {
cases := []struct {
name string
prs []*models.GithubPullRequest
branches []*models.Branch
remotes []*models.Remote
expected map[string]*models.GithubPullRequest
}{
{
name: "empty inputs",
prs: []*models.GithubPullRequest{},
branches: []*models.Branch{},
remotes: []*models.Remote{},
expected: map[string]*models.GithubPullRequest{},
},
{
name: "matches PR to branch tracking origin",
prs: []*models.GithubPullRequest{
{
HeadRefName: "feature-branch",
Number: 42,
Title: "Add feature",
State: "OPEN",
Url: "https://github.com/jesseduffield/lazygit/pull/42",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
},
branches: []*models.Branch{
{
Name: "feature-branch",
UpstreamRemote: "origin",
UpstreamBranch: "feature-branch",
},
},
remotes: []*models.Remote{
{
Name: "origin",
Urls: []string{"git@github.com:jesseduffield/lazygit.git"},
},
},
expected: map[string]*models.GithubPullRequest{
"feature-branch": {
HeadRefName: "feature-branch",
Number: 42,
Title: "Add feature",
State: "OPEN",
Url: "https://github.com/jesseduffield/lazygit/pull/42",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
},
},
{
name: "does not match branch without upstream",
prs: []*models.GithubPullRequest{
{
HeadRefName: "feature-branch",
Number: 42,
Title: "Add feature",
State: "OPEN",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
},
branches: []*models.Branch{
{
Name: "feature-branch",
// no upstream set
},
},
remotes: []*models.Remote{
{
Name: "origin",
Urls: []string{"git@github.com:jesseduffield/lazygit.git"},
},
},
expected: map[string]*models.GithubPullRequest{},
},
{
name: "matches fork PR to branch tracking fork remote",
prs: []*models.GithubPullRequest{
{
HeadRefName: "fix-bug",
Number: 99,
Title: "Fix bug",
State: "OPEN",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
},
},
branches: []*models.Branch{
{
Name: "fix-bug",
UpstreamRemote: "contributor",
UpstreamBranch: "fix-bug",
},
},
remotes: []*models.Remote{
{
Name: "origin",
Urls: []string{"git@github.com:jesseduffield/lazygit.git"},
},
{
Name: "contributor",
Urls: []string{"git@github.com:contributor/lazygit.git"},
},
},
expected: map[string]*models.GithubPullRequest{
"fix-bug": {
HeadRefName: "fix-bug",
Number: 99,
Title: "Fix bug",
State: "OPEN",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
},
},
},
{
name: "does not match when owner differs",
prs: []*models.GithubPullRequest{
{
HeadRefName: "feature-branch",
Number: 42,
Title: "Add feature",
State: "OPEN",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "someone-else"},
},
},
branches: []*models.Branch{
{
Name: "feature-branch",
UpstreamRemote: "origin",
UpstreamBranch: "feature-branch",
},
},
remotes: []*models.Remote{
{
Name: "origin",
Urls: []string{"git@github.com:jesseduffield/lazygit.git"},
},
},
expected: map[string]*models.GithubPullRequest{},
},
{
name: "matches when UpstreamRemote is a full URL",
prs: []*models.GithubPullRequest{
{
HeadRefName: "my-branch",
Number: 55,
Title: "Full URL upstream",
State: "OPEN",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
},
},
branches: []*models.Branch{
{
Name: "my-branch",
UpstreamRemote: "git@github.com:contributor/lazygit.git",
UpstreamBranch: "my-branch",
},
},
remotes: []*models.Remote{
{
Name: "origin",
Urls: []string{"git@github.com:jesseduffield/lazygit.git"},
},
},
expected: map[string]*models.GithubPullRequest{
"my-branch": {
HeadRefName: "my-branch",
Number: 55,
Title: "Full URL upstream",
State: "OPEN",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
},
},
},
{
name: "uses first PR when branch name is reused (API returns newest first)",
prs: []*models.GithubPullRequest{
// API returns newest first (CREATED_AT DESC)
{
HeadRefName: "update-sponsors",
Number: 50,
Title: "Newest PR",
State: "CLOSED",
Url: "https://github.com/jesseduffield/lazygit/pull/50",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
{
HeadRefName: "update-sponsors",
Number: 30,
Title: "Middle PR",
State: "OPEN",
Url: "https://github.com/jesseduffield/lazygit/pull/30",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
{
HeadRefName: "update-sponsors",
Number: 10,
Title: "Oldest PR",
State: "CLOSED",
Url: "https://github.com/jesseduffield/lazygit/pull/10",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
},
branches: []*models.Branch{
{
Name: "update-sponsors",
UpstreamRemote: "origin",
UpstreamBranch: "update-sponsors",
},
},
remotes: []*models.Remote{
{
Name: "origin",
Urls: []string{"git@github.com:jesseduffield/lazygit.git"},
},
},
expected: map[string]*models.GithubPullRequest{
"update-sponsors": {
HeadRefName: "update-sponsors",
Number: 50,
Title: "Newest PR",
State: "CLOSED",
Url: "https://github.com/jesseduffield/lazygit/pull/50",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
},
},
{
name: "matches with HTTPS remote URL",
prs: []*models.GithubPullRequest{
{
HeadRefName: "my-pr",
Number: 10,
Title: "My PR",
State: "MERGED",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
},
branches: []*models.Branch{
{
Name: "my-pr",
UpstreamRemote: "origin",
UpstreamBranch: "my-pr",
},
},
remotes: []*models.Remote{
{
Name: "origin",
Urls: []string{"https://github.com/jesseduffield/lazygit.git"},
},
},
expected: map[string]*models.GithubPullRequest{
"my-pr": {
HeadRefName: "my-pr",
Number: 10,
Title: "My PR",
State: "MERGED",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
result := GenerateGithubPullRequestMap(c.prs, c.branches, c.remotes)
assert.Equal(t, c.expected, result)
})
}
}
func mkRemoteList(names ...string) []*models.Remote {
return lo.Map(names, func(name string, _ int) *models.Remote {
return &models.Remote{Name: name}
})
}
@@ -0,0 +1,34 @@
package git_commands
import "github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
// a hosting service is something like github, gitlab, bitbucket etc
type HostingService struct {
*GitCommon
}
func NewHostingServiceCommand(gitCommon *GitCommon) *HostingService {
return &HostingService{
GitCommon: gitCommon,
}
}
func (self *HostingService) GetPullRequestURL(from string, to string) (string, error) {
return self.getHostingServiceMgr(self.config.GetRemoteURL()).GetPullRequestURL(from, to)
}
func (self *HostingService) GetCommitURL(commitSha string) (string, error) {
return self.getHostingServiceMgr(self.config.GetRemoteURL()).GetCommitURL(commitSha)
}
func (self *HostingService) GetRepoNameFromRemoteURL(remoteURL string) (string, error) {
return self.getHostingServiceMgr(remoteURL).GetRepoName()
}
// getting this on every request rather than storing it in state in case our remoteURL changes
// from one invocation to the next. Note however that we're currently caching config
// results so we might want to invalidate the cache here if it becomes a problem.
func (self *HostingService) getHostingServiceMgr(remoteURL string) *hosting_service.HostingServiceMgr {
configServices := self.UserConfig().Services
return hosting_service.NewHostingServiceMgr(self.Log, self.Tr, remoteURL, configServices)
}
+14 -6
View File
@@ -253,11 +253,14 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
// WorktreeFileDiff returns the diff of a file
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
// for now we assume an error means the file was deleted
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached).RunWithOutput()
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
return s
}
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool) *oscommands.CmdObj {
// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
// the node's path (used to diff only filtered/visible files within a directory).
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
colorArg := self.pagerConfig.GetColorArg()
if plain {
colorArg = "never"
@@ -270,6 +273,11 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
useExtDiff := extDiffCmd != "" && !plain
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain
paths := pathOverrides
if len(paths) == 0 {
paths = []string{node.GetPath()}
}
cmdArgs := NewGitCmd("diff").
ConfigIf(useExtDiff, "diff.external="+extDiffCmd).
ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
@@ -282,7 +290,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
ArgIf(noIndex, "--no-index").
Arg("--").
ArgIf(noIndex, "/dev/null").
Arg(node.GetPath()).
Arg(paths...).
ArgIf(prevPath != "", prevPath).
Dir(self.repoPaths.worktreePath).
ToArgv()
@@ -293,10 +301,10 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
// ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc
// but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode.
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, plain bool) (string, error) {
return self.ShowFileDiffCmdObj(from, to, reverse, fileName, plain).RunWithOutput()
return self.ShowFileDiffCmdObj(from, to, reverse, []string{fileName}, plain).RunWithOutput()
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileName string, plain bool) *oscommands.CmdObj {
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
contextSize := self.UserConfig().Git.DiffContextSize
colorArg := self.pagerConfig.GetColorArg()
@@ -321,7 +329,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve
ArgIf(reverse, "-R").
ArgIf(!plain && self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg("--").
Arg(fileName).
Arg(fileNames...).
Dir(self.repoPaths.worktreePath).
ToArgv()
+13 -2
View File
@@ -6,7 +6,11 @@ var defaultUrlRegexStrings = []string{
`^(?:https?|ssh)://[^/]+/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
`^(.*?@)?.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
}
var defaultRepoURLTemplate = "https://{{.webDomain}}/{{.owner}}/{{.repo}}"
var (
defaultRepoURLTemplate = "https://{{.webDomain}}/{{.owner}}/{{.repo}}"
defaultRepoNameTemplate = "{{.owner}}/{{.repo}}"
)
// we've got less type safety using go templates but this lends itself better to
// users adding custom service definitions in their config
@@ -17,6 +21,7 @@ var githubServiceDef = ServiceDefinition{
commitURL: "/commit/{{.CommitHash}}",
regexStrings: defaultUrlRegexStrings,
repoURLTemplate: defaultRepoURLTemplate,
repoNameTemplate: defaultRepoNameTemplate,
}
var bitbucketServiceDef = ServiceDefinition{
@@ -28,7 +33,8 @@ var bitbucketServiceDef = ServiceDefinition{
`^(?:https?|ssh)://.*/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
`^.*@.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
},
repoURLTemplate: defaultRepoURLTemplate,
repoURLTemplate: defaultRepoURLTemplate,
repoNameTemplate: defaultRepoNameTemplate,
}
var gitLabServiceDef = ServiceDefinition{
@@ -38,6 +44,7 @@ var gitLabServiceDef = ServiceDefinition{
commitURL: "/-/commit/{{.CommitHash}}",
regexStrings: defaultUrlRegexStrings,
repoURLTemplate: defaultRepoURLTemplate,
repoNameTemplate: defaultRepoNameTemplate,
}
var azdoServiceDef = ServiceDefinition{
@@ -52,6 +59,8 @@ var azdoServiceDef = ServiceDefinition{
`^https://.*/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`,
},
repoURLTemplate: "https://{{.webDomain}}/{{.org}}/{{.project}}/_git/{{.repo}}",
// TODO: verify this is actually correct
repoNameTemplate: "{{.org}}/{{.project}}/{{.repo}}",
}
var bitbucketServerServiceDef = ServiceDefinition{
@@ -64,6 +73,8 @@ var bitbucketServerServiceDef = ServiceDefinition{
`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
},
repoURLTemplate: "https://{{.webDomain}}/projects/{{.project}}/repos/{{.repo}}",
// TODO: verify this is actually correct
repoNameTemplate: "{{.project}}/{{.repo}}",
}
var giteaServiceDef = ServiceDefinition{
@@ -61,6 +61,18 @@ func (self *HostingServiceMgr) GetCommitURL(commitHash string) (string, error) {
return pullRequestURL, nil
}
// e.g. 'jesseduffield/lazygit'
func (self *HostingServiceMgr) GetRepoName() (string, error) {
gitService, err := self.getService()
if err != nil {
return "", err
}
repoName := gitService.repoName
return repoName, nil
}
func (self *HostingServiceMgr) getService() (*Service, error) {
serviceDomain, err := self.getServiceDomain(self.remoteURL)
if err != nil {
@@ -72,8 +84,14 @@ func (self *HostingServiceMgr) getService() (*Service, error) {
return nil, err
}
repoName, err := serviceDomain.serviceDefinition.getRepoNameFromRemoteURL(self.remoteURL)
if err != nil {
return nil, err
}
return &Service{
repoURL: repoURL,
repoName: repoName,
ServiceDefinition: serviceDomain.serviceDefinition,
}, nil
}
@@ -144,24 +162,68 @@ type ServiceDefinition struct {
regexStrings []string
// can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex
repoURLTemplate string
repoURLTemplate string
repoNameTemplate string
}
func (self ServiceDefinition) getRepoURLFromRemoteURL(url string, webDomain string) (string, error) {
matches, err := self.parseRemoteUrl(url)
if err != nil {
return "", err
}
matches["webDomain"] = webDomain
return utils.ResolvePlaceholderString(self.repoURLTemplate, matches), nil
}
func (self ServiceDefinition) getRepoNameFromRemoteURL(url string) (string, error) {
matches, err := self.parseRemoteUrl(url)
if err != nil {
return "", err
}
return utils.ResolvePlaceholderString(self.repoNameTemplate, matches), nil
}
func (self ServiceDefinition) parseRemoteUrl(url string) (map[string]string, error) {
for _, regexStr := range self.regexStrings {
re := regexp.MustCompile(regexStr)
input := utils.FindNamedMatches(re, url)
if input != nil {
input["webDomain"] = webDomain
return utils.ResolvePlaceholderString(self.repoURLTemplate, input), nil
matches := utils.FindNamedMatches(re, url)
if matches != nil {
return matches, nil
}
}
return "", errors.New("Failed to parse repo information from url")
return nil, errors.New("Failed to parse repo information from url")
}
// RepoInformation holds the owner and repository name parsed from a remote URL.
type RepoInformation struct {
Owner string
Repository string
}
// GetRepoInfoFromURL parses a remote URL (SSH or HTTPS) and extracts the
// owner and repository name using the default URL regex patterns.
func GetRepoInfoFromURL(url string) (RepoInformation, error) {
for _, regexStr := range defaultUrlRegexStrings {
re := regexp.MustCompile(regexStr)
matches := utils.FindNamedMatches(re, url)
if matches != nil {
return RepoInformation{
Owner: matches["owner"],
Repository: matches["repo"],
}, nil
}
}
return RepoInformation{}, errors.New("Failed to parse repo information from url")
}
type Service struct {
repoURL string
// e.g. 'jesseduffield/lazygit'
repoName string
ServiceDefinition
}
+25
View File
@@ -0,0 +1,25 @@
package models
// TODO: see if I need to store the head repo name in case it differs from the base repo
type GithubPullRequest struct {
HeadRefName string `json:"headRefName"`
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"` // "MERGED", "OPEN", "CLOSED", "DRAFT"
Url string `json:"url"`
HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
}
func (pr *GithubPullRequest) UserName() string {
// e.g. 'jesseduffield'
return pr.HeadRepositoryOwner.Login
}
func (pr *GithubPullRequest) BranchName() string {
// e.g. 'feature/my-feature'
return pr.HeadRefName
}
type GithubRepositoryOwner struct {
Login string `json:"login"`
}
+18 -1
View File
@@ -704,10 +704,27 @@ type AppState struct {
ShellCommandsHistory []string `yaml:"customcommandshistory"`
HideCommandLog bool
// Cache of GitHub pull requests per repo path, so that PR info can be
// shown instantly on startup before the async refresh completes.
GithubPullRequests map[string][]CachedPullRequest `yaml:"githubPullRequests"`
}
// CachedPullRequest stores the essential fields of a GitHub pull request
// for persisting in the app state cache.
type CachedPullRequest struct {
HeadRefName string `yaml:"headRefName"`
Number int `yaml:"number"`
Title string `yaml:"title"`
State string `yaml:"state"`
Url string `yaml:"url"`
HeadRepositoryOwner string `yaml:"headRepositoryOwner"`
}
func getDefaultAppState() *AppState {
return &AppState{}
return &AppState{
GithubPullRequests: make(map[string][]CachedPullRequest),
}
}
func LogPath() (string, error) {
+3
View File
@@ -321,6 +321,8 @@ type GitConfig struct {
RemoteBranchSortOrder string `yaml:"remoteBranchSortOrder" jsonschema:"enum=date,enum=alphabetical"`
// When copying commit hashes to the clipboard, truncate them to this length. Set to 40 to disable truncation.
TruncateCopiedCommitHashesTo int `yaml:"truncateCopiedCommitHashesTo"`
// If true and if `gh` is installed and on version >=2, we will use `gh` to display pull requests against branches.
EnableGithubCli bool `yaml:"enableGithubCli"`
}
type PagerType string
@@ -857,6 +859,7 @@ func GetDefaultConfig() *UserConfig {
BranchPrefix: "",
ParseEmoji: false,
TruncateCopiedCommitHashesTo: 12,
EnableGithubCli: true,
},
Refresher: RefresherConfig{
RefreshInterval: 10,
+1 -1
View File
@@ -151,7 +151,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
err = self.gui.git.Sync.FetchBackground()
self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.SYNC})
self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS}, Mode: types.SYNC})
if err == nil {
err = self.gui.helpers.BranchesHelper.AutoForwardBranches()
+2
View File
@@ -28,6 +28,8 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
return presentation.GetBranchListDisplayStrings(
viewModel.GetItems(),
c.State().GetItemOperation,
c.Model().PullRequests,
c.Model().Remotes,
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
c.Modes().Diffing.Ref,
c.Views().Branches.InnerWidth()+c.Views().Branches.OriginX(),
+1 -11
View File
@@ -3,7 +3,6 @@ package context
import (
"fmt"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
@@ -17,13 +16,12 @@ type CommitFilesContext struct {
*filetree.CommitFileTreeViewModel
*ListContextTrait
*DynamicTitleBuilder
*SearchTrait
}
var (
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.ISearchableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
)
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
@@ -48,7 +46,6 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
ctx := &CommitFilesContext{
CommitFileTreeViewModel: viewModel,
DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.CommitFilesDynamicTitle),
SearchTrait: NewSearchTrait(c),
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
@@ -68,9 +65,6 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
},
}
ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus)
ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect)
return ctx
}
@@ -93,10 +87,6 @@ func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
return ref.ParentRefName(), ref.RefName()
}
func (self *CommitFilesContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return nil
}
func (self *CommitFilesContext) ReInit(ref models.Ref, refRange *types.RefRange) {
self.SetRef(ref)
self.SetRefRange(refRange)
+1 -11
View File
@@ -1,7 +1,6 @@
package context
import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
@@ -13,12 +12,11 @@ import (
type WorkingTreeContext struct {
*filetree.FileTreeViewModel
*ListContextTrait
*SearchTrait
}
var (
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.ISearchableContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
)
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
@@ -38,7 +36,6 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
}
ctx := &WorkingTreeContext{
SearchTrait: NewSearchTrait(c),
FileTreeViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
@@ -56,12 +53,5 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
},
}
ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus)
ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect)
return ctx
}
func (self *WorkingTreeContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return nil
}
+1
View File
@@ -68,6 +68,7 @@ func (gui *Gui) resetHelpersAndControllers() {
mergeConflictsHelper,
worktreeHelper,
searchHelper,
suggestionsHelper,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
cherryPickHelper := helpers.NewCherryPickHelper(
+96 -1
View File
@@ -3,12 +3,16 @@ package controllers
import (
"errors"
"fmt"
"strings"
"github.com/gookit/color"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
@@ -192,7 +196,27 @@ func (self *BranchesController) GetOnRenderToMain() func() {
} else {
cmdObj := self.c.Git().Branch.GetGraphCmdObj(branch.FullRefName())
task = types.NewRunPtyTask(cmdObj.GetCmd())
ptyTask := types.NewRunPtyTask(cmdObj.GetCmd())
task = ptyTask
// Shouldn't we hold on to the map for longer instead of generating it every time?
// It is also generated every time we render the branches list.
prs := git_commands.GenerateGithubPullRequestMap(
self.c.Model().PullRequests,
self.c.Model().Branches,
self.c.Model().Remotes,
)
if pr, ok := prs[branch.Name]; ok {
icon := lo.Ternary(icons.IsIconEnabled(), icons.IconForRemoteUrl(pr.Url)+" ", "")
ptyTask.Prefix = style.PrintHyperlink(fmt.Sprintf("%s%s %s %s\n",
icon,
coloredStateText(pr.State),
pr.Title,
style.FgCyan.Sprintf("#%d", pr.Number)),
pr.Url)
ptyTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
}
}
self.c.RenderToMainViews(types.RefreshMainOpts{
@@ -206,6 +230,77 @@ func (self *BranchesController) GetOnRenderToMain() func() {
}
}
func stateText(state string) string {
if icons.IsIconEnabled() {
switch state {
case "OPEN":
return " Open"
case "CLOSED":
return " Closed"
case "MERGED":
return " Merged"
case "DRAFT":
return " Draft"
default:
return ""
}
}
switch state {
case "OPEN":
return "Open"
case "CLOSED":
return "Closed"
case "MERGED":
return "Merged"
case "DRAFT":
return "Draft"
default:
return ""
}
}
func coloredStateText(state string) string {
if icons.IsIconEnabled() {
return fmt.Sprintf("%s%s%s",
withPrFgColor(state, ""),
withPrBgColor(state, style.FgWhite.Sprint(stateText(state))),
withPrFgColor(state, ""))
}
return withPrFgColor(state, stateText(state))
}
func withPrFgColor(state string, text string) string {
switch state {
case "OPEN":
return style.FgGreen.Sprint(text)
case "CLOSED":
return style.FgRed.Sprint(text)
case "MERGED":
return style.FgMagenta.Sprint(text)
case "DRAFT":
return color.RGB(0x66, 0x66, 0x66, false).Sprint(text)
default:
return style.FgDefault.Sprint(text)
}
}
func withPrBgColor(state string, text string) string {
switch state {
case "OPEN":
return style.BgGreen.Sprint(text)
case "CLOSED":
return style.BgRed.Sprint(text)
case "MERGED":
return style.BgMagenta.Sprint(text)
case "DRAFT":
return color.RGB(0x66, 0x66, 0x66, true).Sprint(text)
default:
return style.BgDefault.Sprint(text)
}
}
func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branch) error {
upstream := lo.Ternary(selectedBranch.RemoteBranchStoredLocally(),
selectedBranch.ShortUpstreamRefName(),
@@ -152,7 +152,8 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, node.GetPath(), false)
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
task := types.NewRunPtyTask(cmdObj.GetCmd())
self.c.RenderToMainViews(types.RefreshMainOpts{
@@ -171,7 +172,7 @@ func (self *CommitFilesController) copyDiffToClipboard(path string, toastMessage
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, path, true)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, []string{path}, true)
diff, err := cmdObj.RunWithOutput()
if err != nil {
return err
@@ -550,6 +551,21 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName
}
}
// pathsForDiff returns the file paths to use for a diff command. When a text
// filter is active and the node is a directory, only the visible (filtered)
// file paths are returned so the diff reflects what the user sees.
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
if !node.IsFile() && self.context().IsFiltering() {
var paths []string
node.ForEachFile(func(file *models.CommitFile) error {
paths = append(paths, file.Path)
return nil
})
return paths
}
return []string{node.GetPath()}
}
// NOTE: these functions are identical to those in files_controller.go (except for types) and
// could also be cleaned up with some generics
func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) []*filetree.CommitFileNode {
+104 -23
View File
@@ -294,7 +294,8 @@ func (self *FilesController) GetOnRenderToMain() func() {
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged := !split && node.GetHasStagedChanges()
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged)
pathOverrides := self.pathOverridesForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
title := self.c.Tr.UnstagedChanges
if mainShowsStaged {
title = self.c.Tr.StagedChanges
@@ -309,7 +310,7 @@ func (self *FilesController) GetOnRenderToMain() func() {
}
if split {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
title := self.c.Tr.StagedChanges
if mainShowsStaged {
@@ -434,7 +435,19 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e
}
}
// When filtering, expand directory nodes to individual visible file paths
// so that only filtered files are staged/unstaged.
toPaths := func(nodes []*filetree.FileNode) []string {
if self.context().IsFiltering() {
var paths []string
for _, node := range nodes {
node.ForEachFile(func(file *models.File) error {
paths = append(paths, file.Path)
return nil
})
}
return paths
}
return lo.Map(nodes, func(node *filetree.FileNode, _ int) string {
return node.GetPath()
})
@@ -449,7 +462,7 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e
if len(unstagedSelectedNodes) > 0 {
var extraArgs []string
if self.context().GetFilter() == filetree.DisplayTracked {
if self.context().GetStatusFilter() == filetree.DisplayTracked {
extraArgs = []string{"-u"}
}
@@ -469,22 +482,29 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e
return err
}
// need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately.
trackedNodes, untrackedNodes := utils.Partition(selectedNodes, func(node *filetree.FileNode) bool {
// We treat all directories as tracked. I'm not actually sure why we do this but
// it's been the existing behaviour for a while and nobody has complained
return !node.IsFile() || node.GetIsTracked()
})
if len(untrackedNodes) > 0 {
if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil {
if self.context().IsFiltering() {
// When filtering, only unstage visible files
if err := self.unstageFilteredFiles(selectedNodes); err != nil {
return err
}
}
} else {
// need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately.
trackedNodes, untrackedNodes := utils.Partition(selectedNodes, func(node *filetree.FileNode) bool {
// We treat all directories as tracked. I'm not actually sure why we do this but
// it's been the existing behaviour for a while and nobody has complained
return !node.IsFile() || node.GetIsTracked()
})
if len(trackedNodes) > 0 {
if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil {
return err
if len(untrackedNodes) > 0 {
if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil {
return err
}
}
if len(trackedNodes) > 0 {
if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil {
return err
}
}
}
}
@@ -503,6 +523,48 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error {
return nil
}
// pathOverridesForDiff returns file paths to override the node's path in diff
// commands when a text filter is active and the node is a directory. This
// ensures the diff only shows filtered/visible files.
func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []string {
if !node.IsFile() && self.context().IsFiltering() {
var paths []string
node.ForEachFile(func(file *models.File) error {
paths = append(paths, file.Path)
return nil
})
return paths
}
return nil
}
// unstageFilteredFiles unstages only the visible (filtered) files from the
// given nodes, correctly partitioning by tracked/untracked.
func (self *FilesController) unstageFilteredFiles(nodes []*filetree.FileNode) error {
var trackedPaths, untrackedPaths []string
for _, node := range nodes {
node.ForEachFile(func(file *models.File) error {
if file.Tracked || file.HasStagedChanges {
trackedPaths = append(trackedPaths, file.Path)
} else {
untrackedPaths = append(untrackedPaths, file.Path)
}
return nil
})
}
if len(untrackedPaths) > 0 {
if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(untrackedPaths); err != nil {
return err
}
}
if len(trackedPaths) > 0 {
if err := self.c.Git().WorkingTree.UnstageTrackedFiles(trackedPaths); err != nil {
return err
}
}
return nil
}
func (self *FilesController) Context() types.Context {
return self.context()
}
@@ -648,9 +710,21 @@ func (self *FilesController) toggleStagedAllWithLock() error {
return err
}
onlyTrackedFiles := self.context().GetFilter() == filetree.DisplayTracked
if err := self.c.Git().WorkingTree.StageAll(onlyTrackedFiles); err != nil {
return err
if self.context().IsFiltering() {
// When filtering, only stage visible files
var paths []string
root.ForEachFile(func(file *models.File) error {
paths = append(paths, file.Path)
return nil
})
if err := self.c.Git().WorkingTree.StageFiles(paths, nil); err != nil {
return err
}
} else {
onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked
if err := self.c.Git().WorkingTree.StageAll(onlyTrackedFiles); err != nil {
return err
}
}
} else {
self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles)
@@ -659,8 +733,15 @@ func (self *FilesController) toggleStagedAllWithLock() error {
return err
}
if err := self.c.Git().WorkingTree.UnstageAll(); err != nil {
return err
if self.context().IsFiltering() {
// When filtering, only unstage visible files
if err := self.unstageFilteredFiles([]*filetree.FileNode{root}); err != nil {
return err
}
} else {
if err := self.c.Git().WorkingTree.UnstageAll(); err != nil {
return err
}
}
}
@@ -836,7 +917,7 @@ func (self *FilesController) isResolvingConflicts() bool {
}
func (self *FilesController) handleStatusFilterPressed() error {
currentFilter := self.context().GetFilter()
currentFilter := self.context().GetStatusFilter()
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.FilteringMenuTitle,
Items: []*types.MenuItem{
@@ -904,7 +985,7 @@ func (self *FilesController) filteringLabel(filter filetree.FileTreeDisplayFilte
}
func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error {
previousFilter := self.context().GetFilter()
previousFilter := self.context().GetStatusFilter()
self.context().FileTreeViewModel.SetStatusFilter(filter)
self.c.Contexts().Files.GetView().Subtitle = self.filteringLabel(filter)
+1 -1
View File
@@ -27,7 +27,6 @@ type Helpers struct {
MergeAndRebase *MergeAndRebaseHelper
MergeConflicts *MergeConflictsHelper
CherryPick *CherryPickHelper
Host *HostHelper
PatchBuilding *PatchBuildingHelper
Staging *StagingHelper
GPG *GpgHelper
@@ -53,6 +52,7 @@ type Helpers struct {
Search *SearchHelper
Worktree *WorktreeHelper
SubCommits *SubCommitsHelper
Host *HostHelper
}
func NewStubHelpers() *Helpers {
+197 -2
View File
@@ -1,6 +1,7 @@
package helpers
import (
"fmt"
"strings"
"sync"
"time"
@@ -9,6 +10,7 @@ import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
@@ -27,6 +29,7 @@ type RefreshHelper struct {
mergeConflictsHelper *MergeConflictsHelper
worktreeHelper *WorktreeHelper
searchHelper *SearchHelper
suggestionsHelper *SuggestionsHelper
}
func NewRefreshHelper(
@@ -38,6 +41,7 @@ func NewRefreshHelper(
mergeConflictsHelper *MergeConflictsHelper,
worktreeHelper *WorktreeHelper,
searchHelper *SearchHelper,
suggestionsHelper *SuggestionsHelper,
) *RefreshHelper {
return &RefreshHelper{
c: c,
@@ -48,6 +52,7 @@ func NewRefreshHelper(
mergeConflictsHelper: mergeConflictsHelper,
worktreeHelper: worktreeHelper,
searchHelper: searchHelper,
suggestionsHelper: suggestionsHelper,
}
}
@@ -91,6 +96,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
types.STATUS,
types.BISECT_INFO,
types.STAGING,
types.PULL_REQUESTS,
})
} else {
scopeSet = set.NewFromSlice(options.Scope)
@@ -117,6 +123,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
}
}
if scopeSet.Includes(types.PULL_REQUESTS) {
refresh("pull requests", func() { _ = self.refreshGithubPullRequests() })
}
includeWorktreesWithBranches := false
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) || scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
// whenever we change commits, we should update branches because the upstream/downstream
@@ -584,11 +594,11 @@ func (self *RefreshHelper) refreshStateFiles() error {
// only taking over the filter if it hasn't already been set by the user.
if conflictFileCount > 0 && prevConflictFileCount == 0 {
if fileTreeViewModel.GetFilter() == filetree.DisplayAll {
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll {
fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted)
self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles
}
} else if conflictFileCount == 0 && fileTreeViewModel.GetFilter() == filetree.DisplayConflicted {
} else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
fileTreeViewModel.SetStatusFilter(filetree.DisplayAll)
self.c.Contexts().Files.GetView().Subtitle = ""
}
@@ -757,3 +767,188 @@ func (self *RefreshHelper) refreshView(context types.Context) {
return nil
})
}
func (self *RefreshHelper) refreshGithubPullRequests() error {
self.c.Mutexes().RefreshingPullRequestsMutex.Lock()
defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock()
if !self.c.UserConfig().Git.EnableGithubCli {
return nil
}
if !self.c.Git().GitHub.InGithubRepo() {
self.c.Model().PullRequests = []*models.GithubPullRequest{}
return nil
}
// Load cached PRs for instant display while we fetch fresh data
self.loadCachedPullRequests()
switch self.c.State().GetGitHubCliState() {
case types.UNKNOWN:
state := self.determineGithubCliState()
self.c.State().SetGitHubCliState(state)
if state != types.VALID {
if state == types.INVALID_VERSION {
// todo: i18n
self.c.LogAction("gh version is too old (must be version 2 or greater), so pull requests will not be shown against branches.")
}
return nil
}
case types.VALID:
// continue on
default:
return nil
}
if err := self.c.Git().GitHub.BaseRepo(); err != nil {
ok, err := self.promptForBaseGithubRepo()
if err != nil {
return err
}
if !ok {
return nil
}
}
if err := self.setGithubPullRequests(); err != nil {
self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error()))
}
return nil
}
func (self *RefreshHelper) promptForBaseGithubRepo() (bool, error) {
err := self.refreshRemotes()
if err != nil {
return false, err
}
remotes := self.c.Model().Remotes
if len(remotes) == 0 {
return false, nil
}
// If there's only one remote, or if 'origin' exists, use it automatically
remote, hasOrigin := lo.Find(remotes, func(r *models.Remote) bool {
return r.Name == "origin"
})
if !hasOrigin {
if len(remotes) == 1 {
remote = remotes[0]
} else {
// Multiple remotes with no 'origin': prompt the user
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.SelectRemoteRepository,
InitialContent: "",
FindSuggestionsFunc: self.suggestionsHelper.GetRemoteRepoSuggestionsFunc(),
HandleConfirm: func(repository string) error {
return self.c.WithWaitingStatus(self.c.Tr.LcSelectingRemote, func(gocui.Task) error {
// `repository` is something like 'jesseduffield/lazygit'
_, err := self.c.Git().GitHub.SetBaseRepo(repository)
if err != nil {
return err
}
return self.refreshGithubPullRequests()
})
},
})
return false, nil
}
}
if len(remote.Urls) == 0 {
return false, nil
}
repoName, err := self.c.Git().HostingService.GetRepoNameFromRemoteURL(remote.Urls[0])
if err != nil {
self.c.Log.Error(err)
return false, nil
}
_, err = self.c.Git().GitHub.SetBaseRepo(repoName)
if err != nil {
self.c.Log.Error(err)
}
return true, nil
}
func (self *RefreshHelper) determineGithubCliState() types.GitHubCliState {
installed, validVersion := self.c.Git().GitHub.DetermineGitHubCliState()
if validVersion {
return types.VALID
} else if installed {
return types.INVALID_VERSION
}
return types.NOT_INSTALLED
}
func (self *RefreshHelper) setGithubPullRequests() error {
branches := lo.Filter(self.c.Model().Branches, func(branch *models.Branch, _ int) bool {
return branch.IsTrackingRemote()
})
branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string {
return branch.UpstreamBranch
})
prs, err := self.c.Git().GitHub.FetchRecentPRs(branchNames)
if err != nil {
return err
}
self.c.Model().PullRequests = prs
self.savePullRequestsToCache(prs)
self.c.PostRefreshUpdate(self.c.Contexts().Branches)
return nil
}
func (self *RefreshHelper) loadCachedPullRequests() {
repoPath := self.c.Git().RepoPaths.RepoPath()
cachedPRs := self.c.GetAppState().GithubPullRequests[repoPath]
if len(cachedPRs) == 0 {
return
}
prs := lo.Map(cachedPRs, func(cached config.CachedPullRequest, _ int) *models.GithubPullRequest {
return &models.GithubPullRequest{
HeadRefName: cached.HeadRefName,
Number: cached.Number,
Title: cached.Title,
State: cached.State,
Url: cached.Url,
HeadRepositoryOwner: models.GithubRepositoryOwner{
Login: cached.HeadRepositoryOwner,
},
}
})
self.c.Model().PullRequests = prs
self.c.PostRefreshUpdate(self.c.Contexts().Branches)
}
func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullRequest) {
repoPath := self.c.Git().RepoPaths.RepoPath()
cached := lo.Map(prs, func(pr *models.GithubPullRequest, _ int) config.CachedPullRequest {
return config.CachedPullRequest{
HeadRefName: pr.HeadRefName,
Number: pr.Number,
Title: pr.Title,
State: pr.State,
Url: pr.Url,
HeadRepositoryOwner: pr.HeadRepositoryOwner.Login,
}
})
appState := self.c.GetAppState()
if appState.GithubPullRequests == nil {
appState.GithubPullRequests = make(map[string][]config.CachedPullRequest)
}
appState.GithubPullRequests[repoPath] = cached
self.c.SaveAppStateAndLogError()
}
+18 -1
View File
@@ -54,7 +54,24 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
// loading a heap of commits is slow so we limit them whenever doing a reset
self.c.Contexts().LocalCommits.SetLimitCommits(true)
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true})
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true, Scope: []types.RefreshableView{
types.COMMITS,
types.BRANCHES,
types.FILES,
types.STASH,
types.REFLOG,
types.TAGS,
types.REMOTES,
types.WORKTREES,
types.STATUS,
types.BISECT_INFO,
types.STAGING,
}, Then: func() {
// Fetch PRs in the background after checkout; the new branch may
// have a PR that we haven't fetched yet. We do this in Then so
// that branches are loaded first (PRs are matched against branches).
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.PULL_REQUESTS}})
}})
}
localBranch, found := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool {
+3 -1
View File
@@ -33,6 +33,8 @@ func NewSearchHelper(
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error {
state := self.searchState()
state.PrevSearchIndex = -1
state.Context = context
self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr))
@@ -238,7 +240,7 @@ func (self *SearchHelper) ReApplyFilter(context types.Context) {
filterableContext, ok := context.(types.IFilterableContext)
if ok {
state := self.searchState()
if context == state.Context {
if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() {
filterableContext.SetSelection(0)
filterableContext.GetView().SetOriginY(0)
}
@@ -64,6 +64,30 @@ func (self *SuggestionsHelper) getBranchNames() []string {
})
}
func (self *SuggestionsHelper) GetRemoteRepoSuggestionsFunc() func(string) []*types.Suggestion {
repoNames := self.getRemoteRepoNames()
return FilterFunc(repoNames, self.c.UserConfig().Gui.UseFuzzySearch())
}
func (self *SuggestionsHelper) getRemoteRepoNames() []string {
remotes := self.c.Model().Remotes
result := make([]string, 0, len(remotes))
for _, remote := range remotes {
if len(remote.Urls) == 0 {
continue
}
repoName, err := self.c.Git().HostingService.GetRepoNameFromRemoteURL(remote.Urls[0])
if err != nil {
self.c.Log.Error(err)
continue
}
result = append(result, repoName)
}
return result
}
func (self *SuggestionsHelper) GetBranchNameSuggestionsFunc() func(string) []*types.Suggestion {
branchNames := self.getBranchNames()
+2 -15
View File
@@ -4,6 +4,7 @@ import (
"errors"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -60,19 +61,5 @@ func (self *UpstreamHelper) PromptForUpstreamWithoutInitialContent(_ *models.Bra
}
func (self *UpstreamHelper) GetSuggestedRemote() string {
return getSuggestedRemote(self.c.Model().Remotes)
}
func getSuggestedRemote(remotes []*models.Remote) string {
if len(remotes) == 0 {
return "origin"
}
for _, remote := range remotes {
if remote.Name == "origin" {
return remote.Name
}
}
return remotes[0].Name
return git_commands.GetSuggestedRemoteName(self.c.Model().Remotes)
}
@@ -1,31 +0,0 @@
package helpers
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
func TestGetSuggestedRemote(t *testing.T) {
cases := []struct {
remotes []*models.Remote
expected string
}{
{mkRemoteList(), "origin"},
{mkRemoteList("upstream", "origin", "foo"), "origin"},
{mkRemoteList("upstream", "foo", "bar"), "upstream"},
}
for _, c := range cases {
result := getSuggestedRemote(c.remotes)
assert.EqualValues(t, c.expected, result)
}
}
func mkRemoteList(names ...string) []*models.Remote {
return lo.Map(names, func(name string, _ int) *models.Remote {
return &models.Remote{Name: name}
})
}
@@ -1,7 +1,10 @@
package controllers
import (
"fmt"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
@@ -42,6 +45,14 @@ func (self *PatchBuildingController) GetKeybindings(opts types.KeybindingsOpts)
Description: self.c.Tr.ToggleSelectionForPatch,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Remove),
Handler: self.DiscardSelection,
GetDisabledReason: self.getDisabledReasonForDiscard,
Description: self.c.Tr.RemoveSelectionFromPatch,
Tooltip: self.c.Tr.RemoveSelectionFromPatchTooltip,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Return),
Handler: self.Escape,
@@ -168,6 +179,83 @@ func (self *PatchBuildingController) toggleSelection() error {
return nil
}
func (self *PatchBuildingController) getDisabledReasonForDiscard() *types.DisabledReason {
if !self.c.Git().Patch.PatchBuilder.CanRebase {
return &types.DisabledReason{Text: self.c.Tr.CanOnlyRemoveLinesFromLocalCommits}
}
if !self.c.Git().Patch.PatchBuilder.IsEmpty() {
return &types.DisabledReason{Text: self.c.Tr.MustClearPatchBeforeRemovingLines}
}
return nil
}
func (self *PatchBuildingController) DiscardSelection() error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToRemoveLines,
keybindings.Label(self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView))
}
if ok, err := self.c.Helpers().PatchBuilding.ValidateNormalWorkingTreeState(); !ok {
return err
}
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.RemoveLinesFromCommitTitle,
Prompt: self.c.Tr.RemoveLinesFromCommitPrompt,
HandleConfirm: func() error {
return self.removeSelectionFromCommit()
},
})
return nil
}
func (self *PatchBuildingController) addSelectionToPatch() error {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
filename := self.c.Contexts().CommitFiles.GetSelectedPath()
if filename == "" {
return nil
}
state := self.context().GetState()
lineIndicesToToggle := state.LineIndicesOfAddedOrDeletedLinesInSelectedPatchRange()
if len(lineIndicesToToggle) == 0 {
return nil
}
return self.c.Git().Patch.PatchBuilder.AddFileLineRange(filename, lineIndicesToToggle)
}
func (self *PatchBuildingController) removeSelectionFromCommit() error {
if err := self.addSelectionToPatch(); err != nil {
return err
}
if self.c.Git().Patch.PatchBuilder.IsEmpty() {
return nil
}
self.c.Helpers().PatchBuilding.Escape()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
commitIndex := self.getPatchCommitIndex()
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
})
}
func (self *PatchBuildingController) getPatchCommitIndex() int {
for index, commit := range self.c.Model().Commits {
if commit.Hash() == self.c.Git().Patch.PatchBuilder.To {
return index
}
}
return -1
}
func (self *PatchBuildingController) Escape() error {
context := self.c.Contexts().CustomPatchBuilder
state := context.GetState()
+1 -1
View File
@@ -125,7 +125,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() {
if file == nil {
task = types.NewRenderStringTask(prefix)
} else {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, nil)
task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
}
}
@@ -80,12 +80,12 @@ func (self *SwitchToDiffFilesController) enter() error {
}
}
commitFilesContext.ClearFilter()
commitFilesContext.ReInit(ref, refsRange)
commitFilesContext.SetSelection(0)
commitFilesContext.SetCanRebase(canRebase)
commitFilesContext.SetParentContext(self.context)
commitFilesContext.SetWindowName(self.context.GetWindowName())
commitFilesContext.ClearSearchString()
commitFilesContext.GetView().TitlePrefix = self.context.GetView().TitlePrefix
self.c.Refresh(types.RefreshOptions{
+25 -2
View File
@@ -15,6 +15,8 @@ type ICommitFileTree interface {
GetAllItems() []*CommitFileNode
GetAllFiles() []*models.CommitFile
GetRoot() *CommitFileNode
SetTextFilter(filter string, useFuzzySearch bool)
GetTextFilter() string
}
type CommitFileTree struct {
@@ -23,6 +25,8 @@ type CommitFileTree struct {
showTree bool
common *common.Common
collapsedPaths *CollapsedPaths
textFilter string
useFuzzySearch bool
}
func (self *CommitFileTree) CollapseAll() {
@@ -93,15 +97,34 @@ func (self *CommitFileTree) GetAllFiles() []*models.CommitFile {
return self.getFiles()
}
func (self *CommitFileTree) getFilesForDisplay() []*models.CommitFile {
files := self.getFiles()
if self.textFilter != "" {
files = filterCommitFilesByText(files, self.textFilter, self.useFuzzySearch)
}
return files
}
func (self *CommitFileTree) SetTree() {
filesForDisplay := self.getFilesForDisplay()
showRootItem := self.common.UserConfig().Gui.ShowRootItemInFileTree
if self.showTree {
self.tree = BuildTreeFromCommitFiles(self.getFiles(), showRootItem)
self.tree = BuildTreeFromCommitFiles(filesForDisplay, showRootItem)
} else {
self.tree = BuildFlatTreeFromCommitFiles(self.getFiles(), showRootItem)
self.tree = BuildFlatTreeFromCommitFiles(filesForDisplay, showRootItem)
}
}
func (self *CommitFileTree) SetTextFilter(filter string, useFuzzySearch bool) {
self.textFilter = filter
self.useFuzzySearch = useFuzzySearch
self.SetTree()
}
func (self *CommitFileTree) GetTextFilter() string {
return self.textFilter
}
func (self *CommitFileTree) IsCollapsed(path string) bool {
return self.collapsedPaths.IsCollapsed(path)
}
@@ -8,6 +8,8 @@ import (
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -39,6 +41,8 @@ type CommitFileTreeViewModel struct {
// we set this to true when you're viewing the files within the checked-out branch's commits.
// If you're viewing the files of some random other branch we can't do any rebase stuff.
canRebase bool
searchHistory *utils.HistoryBuffer[string]
}
var _ ICommitFileTreeViewModel = &CommitFileTreeViewModel{}
@@ -52,6 +56,7 @@ func NewCommitFileTreeViewModel(getFiles func() []*models.CommitFile, common *co
ref: nil,
refRange: nil,
canRebase: false,
searchHistory: utils.NewHistoryBuffer[string](1000),
}
}
@@ -203,3 +208,51 @@ func (self *CommitFileTreeViewModel) SelectPath(filepath string, showRootItem bo
self.SetSelection(index)
}
}
// IFilterableContext methods
func (self *CommitFileTreeViewModel) SetFilter(filter string, useFuzzySearch bool) {
self.ICommitFileTree.SetTextFilter(filter, useFuzzySearch)
}
func (self *CommitFileTreeViewModel) GetFilter() string {
return self.ICommitFileTree.GetTextFilter()
}
func (self *CommitFileTreeViewModel) ClearFilter() {
selectedNode := self.GetSelected()
var selectedPath string
if selectedNode != nil {
selectedPath = selectedNode.GetInternalPath()
}
self.ICommitFileTree.SetTextFilter("", false)
if selectedPath != "" {
self.ExpandToPath(selectedPath)
if idx, found := self.GetIndexForPath(selectedPath); found {
self.SetSelection(idx)
return
}
}
self.ClampSelection()
}
func (self *CommitFileTreeViewModel) ReApplyFilter(useFuzzySearch bool) {
self.ICommitFileTree.SetTextFilter(self.ICommitFileTree.GetTextFilter(), useFuzzySearch)
}
func (self *CommitFileTreeViewModel) IsFiltering() bool {
return self.ICommitFileTree.GetTextFilter() != ""
}
// used for type switch
func (self *CommitFileTreeViewModel) IsFilterableContext() {}
func (self *CommitFileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string {
return tr.FilterPrefix
}
func (self *CommitFileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] {
return self.searchHistory
}
+48
View File
@@ -0,0 +1,48 @@
package filetree
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sahilm/fuzzy"
"github.com/samber/lo"
)
type filePathSource struct {
files []*models.File
}
func (s *filePathSource) String(i int) string {
return s.files[i].Path
}
func (s *filePathSource) Len() int {
return len(s.files)
}
func filterFilesByText(files []*models.File, filter string, useFuzzySearch bool) []*models.File {
source := &filePathSource{files: files}
matches := utils.FindFrom(filter, source, useFuzzySearch)
return lo.Map(matches, func(match fuzzy.Match, _ int) *models.File {
return files[match.Index]
})
}
type commitFilePathSource struct {
files []*models.CommitFile
}
func (s *commitFilePathSource) String(i int) string {
return s.files[i].Path
}
func (s *commitFilePathSource) Len() int {
return len(s.files)
}
func filterCommitFilesByText(files []*models.CommitFile, filter string, useFuzzySearch bool) []*models.CommitFile {
source := &commitFilePathSource{files: files}
matches := utils.FindFrom(filter, source, useFuzzySearch)
return lo.Map(matches, func(match fuzzy.Match, _ int) *models.CommitFile {
return files[match.Index]
})
}
+29 -8
View File
@@ -46,8 +46,10 @@ type IFileTree interface {
GetFile(path string) *models.File
GetAllItems() []*FileNode
GetAllFiles() []*models.File
GetFilter() FileTreeDisplayFilter
GetStatusFilter() FileTreeDisplayFilter
GetRoot() *FileNode
SetTextFilter(filter string, useFuzzySearch bool)
GetTextFilter() string
}
type FileTree struct {
@@ -57,6 +59,8 @@ type FileTree struct {
common *common.Common
filter FileTreeDisplayFilter
collapsedPaths *CollapsedPaths
textFilter string
useFuzzySearch bool
}
var _ IFileTree = &FileTree{}
@@ -80,24 +84,31 @@ func (self *FileTree) ExpandToPath(path string) {
}
func (self *FileTree) getFilesForDisplay() []*models.File {
var files []*models.File
switch self.filter {
case DisplayAll:
return self.getFiles()
files = self.getFiles()
case DisplayStaged:
return self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges })
files = self.FilterFiles(func(file *models.File) bool { return file.HasStagedChanges })
case DisplayUnstaged:
return self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges })
files = self.FilterFiles(func(file *models.File) bool { return file.HasUnstagedChanges })
case DisplayTracked:
// untracked but staged files are technically not tracked by git
// but including such files in the filtered mode helps see what files are getting committed
return self.FilterFiles(func(file *models.File) bool { return file.Tracked || file.HasStagedChanges })
files = self.FilterFiles(func(file *models.File) bool { return file.Tracked || file.HasStagedChanges })
case DisplayUntracked:
return self.FilterFiles(func(file *models.File) bool { return !(file.Tracked || file.HasStagedChanges) })
files = self.FilterFiles(func(file *models.File) bool { return !(file.Tracked || file.HasStagedChanges) })
case DisplayConflicted:
return self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts })
files = self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts })
default:
panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter))
}
if self.textFilter != "" {
files = filterFilesByText(files, self.textFilter, self.useFuzzySearch)
}
return files
}
func (self *FileTree) ForceShowUntracked() bool {
@@ -210,6 +221,16 @@ func (self *FileTree) CollapsedPaths() *CollapsedPaths {
return self.collapsedPaths
}
func (self *FileTree) GetFilter() FileTreeDisplayFilter {
func (self *FileTree) GetStatusFilter() FileTreeDisplayFilter {
return self.filter
}
func (self *FileTree) SetTextFilter(filter string, useFuzzySearch bool) {
self.textFilter = filter
self.useFuzzySearch = useFuzzySearch
self.SetTree()
}
func (self *FileTree) GetTextFilter() string {
return self.textFilter
}
+53 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -24,6 +25,7 @@ type FileTreeViewModel struct {
sync.RWMutex
types.IListCursor
IFileTree
searchHistory *utils.HistoryBuffer[string]
}
var _ IFileTreeViewModel = &FileTreeViewModel{}
@@ -32,8 +34,9 @@ func NewFileTreeViewModel(getFiles func() []*models.File, common *common.Common,
fileTree := NewFileTree(getFiles, common, showTree)
listCursor := traits.NewListCursor(fileTree.Len)
return &FileTreeViewModel{
IFileTree: fileTree,
IListCursor: listCursor,
IFileTree: fileTree,
IListCursor: listCursor,
searchHistory: utils.NewHistoryBuffer[string](1000),
}
}
@@ -220,3 +223,51 @@ func (self *FileTreeViewModel) ExpandAll() {
self.SetSelectedLineIdx(index)
}
}
// IFilterableContext methods
func (self *FileTreeViewModel) SetFilter(filter string, useFuzzySearch bool) {
self.IFileTree.SetTextFilter(filter, useFuzzySearch)
}
func (self *FileTreeViewModel) GetFilter() string {
return self.IFileTree.GetTextFilter()
}
func (self *FileTreeViewModel) ClearFilter() {
selectedNode := self.GetSelected()
var selectedPath string
if selectedNode != nil {
selectedPath = selectedNode.GetInternalPath()
}
self.IFileTree.SetTextFilter("", false)
if selectedPath != "" {
self.ExpandToPath(selectedPath)
if idx, found := self.GetIndexForPath(selectedPath); found {
self.SetSelection(idx)
return
}
}
self.ClampSelection()
}
func (self *FileTreeViewModel) ReApplyFilter(useFuzzySearch bool) {
self.IFileTree.SetTextFilter(self.IFileTree.GetTextFilter(), useFuzzySearch)
}
func (self *FileTreeViewModel) IsFiltering() bool {
return self.IFileTree.GetTextFilter() != ""
}
// used for type switch
func (self *FileTreeViewModel) IsFilterableContext() {}
func (self *FileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string {
return tr.FilterPrefix
}
func (self *FileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] {
return self.searchHistory
}
+10
View File
@@ -148,6 +148,7 @@ type Gui struct {
integrationTest integrationTypes.IntegrationTest
afterLayoutFuncs chan func() error
gitHubCliState types.GitHubCliState
}
type StateAccessor struct {
@@ -221,6 +222,14 @@ func (self *StateAccessor) ClearItemOperation(item types.HasUrn) {
delete(self.gui.itemOperations, item.URN())
}
func (self *StateAccessor) GetGitHubCliState() types.GitHubCliState {
return self.gui.gitHubCliState
}
func (self *StateAccessor) SetGitHubCliState(value types.GitHubCliState) {
self.gui.gitHubCliState = value
}
// we keep track of some stuff from one render to the next to see if certain
// things have changed
type PrevLayout struct {
@@ -581,6 +590,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
Authors: map[string]*models.Author{},
MainBranches: git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd),
HashPool: &utils.StringPool{},
PullRequests: make([]*models.GithubPullRequest, 0),
},
Modes: &types.Modes{
Filtering: filtering.New(startArgs.FilterPath, ""),
+45 -7
View File
@@ -28,6 +28,8 @@ var colorPatterns *colorMatcher
func GetBranchListDisplayStrings(
branches []*models.Branch,
getItemOperation func(item types.HasUrn) types.ItemOperation,
pullRequests []*models.GithubPullRequest,
remotes []*models.Remote,
fullDescription bool,
diffName string,
viewWidth int,
@@ -35,9 +37,15 @@ func GetBranchListDisplayStrings(
userConfig *config.UserConfig,
worktrees []*models.Worktree,
) [][]string {
prs := git_commands.GenerateGithubPullRequestMap(
pullRequests,
branches,
remotes,
)
return lo.Map(branches, func(branch *models.Branch, _ int) []string {
diffed := branch.Name == diffName
return getBranchDisplayStrings(branch, getItemOperation(branch), fullDescription, diffed, viewWidth, tr, userConfig, worktrees, time.Now())
return getBranchDisplayStrings(branch, getItemOperation(branch), fullDescription, diffed, viewWidth, tr, userConfig, worktrees, time.Now(), prs)
})
}
@@ -52,6 +60,7 @@ func getBranchDisplayStrings(
userConfig *config.UserConfig,
worktrees []*models.Worktree,
now time.Time,
prs map[string]*models.GithubPullRequest,
) []string {
checkedOutByWorkTree := git_commands.CheckedOutByOtherWorktree(b, worktrees)
showCommitHash := fullDescription || userConfig.Gui.ShowBranchCommitHash
@@ -100,6 +109,7 @@ func getBranchDisplayStrings(
if checkedOutByWorkTree {
coloredName = fmt.Sprintf("%s %s", coloredName, style.FgDefault.Sprint(worktreeIcon))
}
if len(branchStatus) > 0 {
coloredName = fmt.Sprintf("%s %s", coloredName, branchStatus)
}
@@ -110,14 +120,22 @@ func getBranchDisplayStrings(
}
res := make([]string, 0, 6)
res = append(res, recencyColor.Sprint(b.Recency))
if icons.IsIconEnabled() {
res = append(res, nameTextStyle.Sprint(icons.IconForBranch(b)))
}
if showCommitHash {
res = append(res, utils.ShortHash(b.CommitHash))
pr, hasPr := prs[b.Name]
if hasPr {
if icons.IsIconEnabled() {
res = append(res, prColor(pr.State).Sprint(icons.IconForRemoteUrl(pr.Url)))
} else {
res = append(res, prColor(pr.State).Sprint("●"))
}
} else {
if icons.IsIconEnabled() {
res = append(res, style.FgDefault.Sprint(icons.IconForBranch(b)))
} else {
res = append(res, style.FgDefault.Sprint("●"))
}
}
if divergence != "" {
@@ -127,8 +145,13 @@ func getBranchDisplayStrings(
coloredName += style.FgCyan.Sprint(divergence)
}
}
res = append(res, coloredName)
if showCommitHash {
res = append(res, utils.ShortHash(b.CommitHash))
}
if fullDescription {
res = append(
res,
@@ -228,3 +251,18 @@ func SetCustomBranches(customBranchColors map[string]string, isRegex bool) {
isRegex: isRegex,
}
}
func prColor(state string) style.TextStyle {
switch state {
case "OPEN":
return style.FgGreen
case "CLOSED":
return style.FgRed
case "MERGED":
return style.FgMagenta
case "DRAFT":
return style.FgBlackLighter
default:
return style.FgDefault
}
}
+22 -22
View File
@@ -42,7 +42,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name"},
expected: []string{"1m", "●", "branch_name"},
},
{
branch: &models.Branch{Name: "🍉_special_char", Recency: "1m"},
@@ -52,7 +52,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "🍉_special_char"},
expected: []string{"1m", "●", "🍉_special_char"},
},
{
branch: &models.Branch{Name: "branch_name", Recency: "1m"},
@@ -62,7 +62,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name (worktree)"},
expected: []string{"1m", "●", "branch_name (worktree)"},
},
{
branch: &models.Branch{Name: "branch_name", Recency: "1m"},
@@ -88,7 +88,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name ✓"},
expected: []string{"1m", "●", "branch_name ✓"},
},
{
branch: &models.Branch{
@@ -104,7 +104,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name (worktree) ↓5↑3"},
expected: []string{"1m", "●", "branch_name (worktree) ↓5↑3"},
},
{
branch: &models.Branch{
@@ -118,7 +118,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "onlyArrow",
expected: []string{"1m", "branch_name ↓"},
expected: []string{"1m", "●", "branch_name ↓"},
},
{
branch: &models.Branch{
@@ -135,7 +135,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "arrowAndNumber",
expected: []string{"1m", "branch_name ✓ ↓2"},
expected: []string{"1m", "●", "branch_name ✓ ↓2"},
},
{
branch: &models.Branch{
@@ -152,7 +152,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "arrowAndNumber",
expected: []string{"1m", "branch_name ↓5↑3 ↓2"},
expected: []string{"1m", "●", "branch_name ↓5↑3 ↓2"},
},
{
branch: &models.Branch{Name: "branch_name", Recency: "1m"},
@@ -162,7 +162,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_name Pushing |"},
expected: []string{"1m", "●", "branch_name Pushing |"},
},
{
branch: &models.Branch{
@@ -181,7 +181,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "12345678", "branch_name ✓", "origin branch_name", "commit title"},
expected: []string{"1m", "●", "branch_name ✓", "12345678", "origin branch_name", "commit title"},
},
// Now tests for how we truncate the branch name when there's not enough room:
@@ -193,7 +193,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_na…"},
expected: []string{"1m", "●", "branch_na…"},
},
{
branch: &models.Branch{Name: "🍉_special_char", Recency: "1m"},
@@ -203,7 +203,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "🍉_special_ch…"},
expected: []string{"1m", "●", "🍉_special_ch…"},
},
{
branch: &models.Branch{Name: "branch_name", Recency: "1m"},
@@ -213,7 +213,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "bra… (worktree)"},
expected: []string{"1m", "●", "bra… (worktree)"},
},
{
branch: &models.Branch{Name: "branch_name", Recency: "1m"},
@@ -239,7 +239,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_… ✓"},
expected: []string{"1m", "●", "branch_… ✓"},
},
{
branch: &models.Branch{
@@ -256,7 +256,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "arrowAndNumber",
expected: []string{"1m", "branch_n… ↓5↑3 ↓4"},
expected: []string{"1m", "●", "branch_n… ↓5↑3 ↓4"},
},
{
branch: &models.Branch{
@@ -272,7 +272,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "branch_na… (worktree) ↓5↑3"},
expected: []string{"1m", "●", "branch_na… (worktree) ↓5↑3"},
},
{
branch: &models.Branch{Name: "branch_name", Recency: "1m"},
@@ -282,7 +282,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "branc… Pushing |"},
expected: []string{"1m", "●", "branc… Pushing |"},
},
{
branch: &models.Branch{Name: "abc", Recency: "1m"},
@@ -292,7 +292,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "abc Pushing |"},
expected: []string{"1m", "●", "abc Pushing |"},
},
{
branch: &models.Branch{Name: "ab", Recency: "1m"},
@@ -302,7 +302,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "ab Pushing |"},
expected: []string{"1m", "●", "ab Pushing |"},
},
{
branch: &models.Branch{Name: "a", Recency: "1m"},
@@ -312,7 +312,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "a Pushing |"},
expected: []string{"1m", "●", "a Pushing |"},
},
{
branch: &models.Branch{
@@ -331,7 +331,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: false,
checkedOutByWorktree: false,
showDivergenceCfg: "none",
expected: []string{"1m", "12345678", "bran… ✓", "origin branch_name", "commit title"},
expected: []string{"1m", "●", "bran… ✓", "12345678", "origin branch_name", "commit title"},
},
}
@@ -351,7 +351,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
}
t.Run(fmt.Sprintf("getBranchDisplayStrings_%d", i), func(t *testing.T) {
strings := getBranchDisplayStrings(s.branch, s.itemOperation, s.fullDescription, false, s.viewWidth, c.Tr, c.UserConfig(), worktrees, time.Time{})
strings := getBranchDisplayStrings(s.branch, s.itemOperation, s.fullDescription, false, s.viewWidth, c.Tr, c.UserConfig(), worktrees, time.Time{}, map[string]*models.GithubPullRequest{})
assert.Equal(t, s.expected, strings)
})
}
+9
View File
@@ -79,6 +79,15 @@ func IconForRemote(remote *models.Remote) string {
return DEFAULT_REMOTE_ICON
}
func IconForRemoteUrl(url string) string {
for domain, icon := range remoteIcons {
if strings.Contains(url, domain) {
return icon
}
}
return DEFAULT_REMOTE_ICON
}
func IconForStash(stash *models.StashEntry) string {
return STASH_ICON
}
+23 -9
View File
@@ -296,6 +296,7 @@ type Model struct {
SubCommits []*models.Commit
Remotes []*models.Remote
Worktrees []*models.Worktree
PullRequests []*models.GithubPullRequest
// FilteredReflogCommits are the ones that appear in the reflog panel.
// When in filtering mode we only include the ones that match the given path
@@ -326,15 +327,16 @@ type Model struct {
}
type Mutexes struct {
RefreshingFilesMutex deadlock.Mutex
RefreshingBranchesMutex deadlock.Mutex
RefreshingStatusMutex deadlock.Mutex
LocalCommitsMutex deadlock.Mutex
SubCommitsMutex deadlock.Mutex
AuthorsMutex deadlock.Mutex
SubprocessMutex deadlock.Mutex
PopupMutex deadlock.Mutex
PtyMutex deadlock.Mutex
RefreshingFilesMutex deadlock.Mutex
RefreshingBranchesMutex deadlock.Mutex
RefreshingStatusMutex deadlock.Mutex
RefreshingPullRequestsMutex deadlock.Mutex
LocalCommitsMutex deadlock.Mutex
SubCommitsMutex deadlock.Mutex
AuthorsMutex deadlock.Mutex
SubprocessMutex deadlock.Mutex
PopupMutex deadlock.Mutex
PtyMutex deadlock.Mutex
}
// A long-running operation associated with an item. For example, we'll show
@@ -373,6 +375,8 @@ type IStateAccessor interface {
GetItemOperation(item HasUrn) ItemOperation
SetItemOperation(item HasUrn, operation ItemOperation)
ClearItemOperation(item HasUrn)
GetGitHubCliState() GitHubCliState
SetGitHubCliState(GitHubCliState)
}
type IRepoStateAccessor interface {
@@ -409,3 +413,13 @@ const (
SCREEN_HALF
SCREEN_FULL
)
// for keeping track of whether our github CLI is installed and on a valid version
type GitHubCliState int
const (
UNKNOWN GitHubCliState = iota
VALID
NOT_INSTALLED
INVALID_VERSION
)
+1
View File
@@ -22,6 +22,7 @@ const (
COMMIT_FILES
// not actually a view. Will refactor this later
BISECT_INFO
PULL_REQUESTS
)
type RefreshMode int
+22
View File
@@ -300,6 +300,8 @@ type TranslationSet struct {
ToggleSelectHunkTooltip string
HunkStagingHint string
ToggleSelectionForPatch string
RemoveSelectionFromPatch string
RemoveSelectionFromPatchTooltip string
EditHunk string
EditHunkTooltip string
ToggleStagingView string
@@ -434,6 +436,8 @@ type TranslationSet struct {
CheckoutCommitFileTooltip string
CannotCheckoutWithModifiedFilesErr string
CanOnlyDiscardFromLocalCommits string
CanOnlyRemoveLinesFromLocalCommits string
MustClearPatchBeforeRemovingLines string
Remove string
DiscardOldFileChangeTooltip string
DiscardFileChangesTitle string
@@ -604,6 +608,8 @@ type TranslationSet struct {
CyclePagersDisabledReason string
StartSearch string
StartFilter string
SelectRemoteRepository string
LcSelectingRemote string
Keybindings string
KeybindingsLegend string
KeybindingsMenuSectionLocal string
@@ -686,6 +692,8 @@ type TranslationSet struct {
BranchUnknown string
DiscardChangeTitle string
DiscardChangePrompt string
RemoveLinesFromCommitTitle string
RemoveLinesFromCommitPrompt string
CreateNewBranchFromCommit string
BuildingPatch string
ViewCommits string
@@ -697,6 +705,8 @@ type TranslationSet struct {
BackToParentRepo string
Enter string
CopySubmoduleNameToClipboard string
MinGhVersionError string
FailedToObtainGhVersionError string
RemoveSubmodule string
RemoveSubmoduleTooltip string
RemoveSubmodulePrompt string
@@ -1007,6 +1017,7 @@ type Actions struct {
ResolveConflictByDeletingFile string
NotEnoughContextToStage string
NotEnoughContextToDiscard string
NotEnoughContextToRemoveLines string
NotEnoughContextForCustomPatch string
IgnoreExcludeFile string
IgnoreFileErr string
@@ -1405,6 +1416,8 @@ func EnglishTranslationSet() *TranslationSet {
ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.",
HunkStagingHint: englishHunkStagingHint,
ToggleSelectionForPatch: `Toggle lines in patch`,
RemoveSelectionFromPatch: `Remove lines from commit`,
RemoveSelectionFromPatchTooltip: "Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines.",
EditHunk: `Edit hunk`,
EditHunkTooltip: "Edit selected hunk in external editor.",
ToggleStagingView: "Switch view",
@@ -1542,6 +1555,8 @@ func EnglishTranslationSet() *TranslationSet {
CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.",
CannotCheckoutWithModifiedFilesErr: "You have local modifications for the file(s) you are trying to check out. You need to stash or discard these first.",
CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits",
CanOnlyRemoveLinesFromLocalCommits: "Lines can only be removed from local commits",
MustClearPatchBeforeRemovingLines: "Clear the current custom patch first before removing lines from the commit",
Remove: "Remove",
DiscardOldFileChangeTooltip: "Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file.",
DiscardFileChangesTitle: "Discard file changes",
@@ -1715,6 +1730,8 @@ func EnglishTranslationSet() *TranslationSet {
CyclePagersDisabledReason: "No other pagers configured",
StartSearch: "Search the current view by text",
StartFilter: "Filter the current view by text",
SelectRemoteRepository: "Select base repository for pull requests",
LcSelectingRemote: "Selecting remote",
KeybindingsLegend: "Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b",
RenameBranch: "Rename branch",
BranchUpstreamOptionsTitle: "Upstream options",
@@ -1794,6 +1811,8 @@ func EnglishTranslationSet() *TranslationSet {
BranchUnknown: "Branch unknown",
DiscardChangeTitle: "Discard change",
DiscardChangePrompt: "Are you sure you want to discard this change (git reset)? It is irreversible.\nTo disable this dialogue set the config key of 'gui.skipDiscardChangeWarning' to true",
RemoveLinesFromCommitTitle: "Remove lines from commit",
RemoveLinesFromCommitPrompt: "Are you sure you want to remove the selected lines from this commit?",
CreateNewBranchFromCommit: "Create new branch off of commit",
BuildingPatch: "Building patch",
ViewCommits: "View commits",
@@ -1805,6 +1824,8 @@ func EnglishTranslationSet() *TranslationSet {
EnterSubmoduleTooltip: "Enter submodule. After entering the submodule, you can press `{{.escape}}` to escape back to the parent repo.",
BackToParentRepo: "Back to parent repo",
CopySubmoduleNameToClipboard: "Copy submodule name to clipboard",
MinGhVersionError: "GH version must be at least 2.0. Please upgrade your gh version. Alternatively raise an issue at https://github.com/jesseduffield/lazygit/issues for lazygit to be more backwards compatible.",
FailedToObtainGhVersionError: "Failed to obtain gh version. Output from running 'gh --version' was: %s",
RemoveSubmodule: "Remove submodule",
RemoveSubmodulePrompt: "Are you sure you want to remove submodule '%s' and its corresponding directory? This is irreversible.",
RemoveSubmoduleTooltip: "Remove the selected submodule and its corresponding directory.",
@@ -2075,6 +2096,7 @@ func EnglishTranslationSet() *TranslationSet {
ResolveConflictByDeletingFile: "Resolve by deleting file",
NotEnoughContextToStage: "Staging or unstaging changes is not possible with a diff context size of 0. Increase the context using '%s'.",
NotEnoughContextToDiscard: "Discarding changes is not possible with a diff context size of 0. Increase the context using '%s'.",
NotEnoughContextToRemoveLines: "Removing lines from a commit is not possible with a diff context size of 0. Increase the context using '%s'.",
NotEnoughContextForCustomPatch: "Creating custom patches is not possible with a diff context size of 0. Increase the context using '%s'.",
IgnoreExcludeFile: "Ignore or exclude file",
IgnoreFileErr: "Cannot ignore .gitignore",
+1 -1
View File
@@ -20,7 +20,7 @@ var CreateTag = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Branches().
Focus().
Lines(
MatchesRegexp(`\*\s*new-branch`).IsSelected(),
MatchesRegexp(`\*.*new-branch`).IsSelected(),
MatchesRegexp(`master`),
).
SelectNextItem().
@@ -31,7 +31,7 @@ var DetachedHead = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Branches().
Lines(
MatchesRegexp(`\* new-branch`).IsSelected(),
MatchesRegexp(`\*.*new-branch`).IsSelected(),
MatchesRegexp(`master`),
)
+1 -1
View File
@@ -45,7 +45,7 @@ var Checkout = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Branches().
IsFocused().
Lines(
Contains("* (HEAD detached at").IsSelected(),
MatchesRegexp(`\*.*\(HEAD detached at`).IsSelected(),
Contains("branch1"),
Contains("branch2"),
Contains("master"),
@@ -8,7 +8,7 @@ import (
var FilterCommitFiles = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Basic commit file filtering by text",
ExtraCmdArgs: []string{},
Skip: true, // skipping until we have implemented file view filtering
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("folder1")
@@ -0,0 +1,61 @@
package filter_and_search
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FilterCommitFilesToggleDirectory = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Toggle a filtered directory for a custom patch only adds visible files",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir1")
shell.CreateFileAndAdd("dir1/apple-grape", "apple-grape content\n")
shell.CreateFileAndAdd("dir1/apple-orange", "apple-orange content\n")
shell.CreateFileAndAdd("dir1/grape-orange", "grape-orange content\n")
shell.Commit("first commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("first commit").IsSelected(),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
Contains("grape-orange"),
).
// Filter to show only "apple" files (staying in tree view)
FilterOrSearch("apple").
Lines(
// first item is always selected after filtering
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
).
// dir1 is already selected; toggle for patch
PressPrimaryAction().
Lines(
Contains("dir1").IsSelected(),
Contains("● apple-grape"),
Contains("● apple-orange"),
)
t.Views().Information().Content(Contains("Building patch"))
// Verify only the filtered files are in the patch (not grape-orange)
t.Views().Secondary().Content(
Contains("apple-grape").
Contains("apple-orange").
DoesNotContain("grape-orange"),
)
},
})
@@ -8,7 +8,7 @@ import (
var FilterFiles = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Basic file filtering by text",
ExtraCmdArgs: []string{},
Skip: true, // Skipping until we have implemented file view filtering
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("folder1")
@@ -0,0 +1,50 @@
package filter_and_search
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FilterFilesStageAll = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Toggle all staging with a filter only stages visible files",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir1")
shell.CreateFile("dir1/apple-grape", "apple-grape content\n")
shell.CreateFile("dir1/apple-orange", "apple-orange content\n")
shell.CreateFile("dir1/grape-orange", "grape-orange content\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
Lines(
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
Contains("grape-orange"),
).
// Filter to show only "apple" files
FilterOrSearch("apple").
Lines(
// first item is always selected after filtering
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
).
// Stage all visible files
Press(keys.Files.ToggleStagedAll).
// Clear the filter and verify only apple files are staged
PressEscape()
t.Views().Files().
IsFocused().
Lines(
Contains("dir1").IsSelected(),
Contains("A apple-grape"),
Contains("A apple-orange"),
Contains("?? grape-orange"),
)
},
})
@@ -0,0 +1,50 @@
package filter_and_search
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FilterFilesStageDirectory = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Staging a filtered directory only stages visible files",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir1")
shell.CreateFile("dir1/apple-grape", "apple-grape content\n")
shell.CreateFile("dir1/apple-orange", "apple-orange content\n")
shell.CreateFile("dir1/grape-orange", "grape-orange content\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
Lines(
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
Contains("grape-orange"),
).
// Filter to show only "apple" files
FilterOrSearch("apple").
Lines(
// first item is always selected after filtering
Contains("dir1").IsSelected(),
Contains("apple-grape"),
Contains("apple-orange"),
).
// dir1 is already selected; stage it
PressPrimaryAction().
// Clear the filter to see all files and verify only apple files are staged
PressEscape()
t.Views().Files().
IsFocused().
Lines(
Contains("dir1"),
Contains("A apple-grape"),
Contains("A apple-orange"),
Contains("?? grape-orange"),
)
},
})
@@ -70,10 +70,7 @@ var NestedFilter = NewIntegrationTest(NewIntegrationTestArgs{
).
FilterOrSearch("grape").
Lines(
Equals("▼ /"),
Equals(" A apple"),
Equals(" A grape").IsSelected(),
Equals(" A orange"),
Contains("grape").IsSelected(),
).
PressEnter()
@@ -91,15 +88,12 @@ var NestedFilter = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().CommitFiles().
IsFocused().
Lines(
Equals("▼ /"),
Equals(" A apple"),
Equals(" A grape").IsSelected(),
Equals(" A orange"),
Contains("grape").IsSelected(),
).
Tap(func() {
t.Views().Search().IsVisible().Content(Contains("matches for 'grape'"))
}).
// cancel search
// cancel filter
PressEscape().
Tap(func() {
t.Views().Search().IsInvisible()
@@ -75,9 +75,7 @@ var NestedFilterTransient = NewIntegrationTest(NewIntegrationTestArgs{
).
FilterOrSearch("two").
Lines(
Equals("▼ /"),
Equals(" A file-one"),
Equals(" A file-two").IsSelected(),
Contains("file-two").IsSelected(),
)
t.Views().Branches().
@@ -96,7 +94,7 @@ var NestedFilterTransient = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().CommitFiles().
IsFocused().
// the search on the commit-files context has been cancelled
// the filter on the commit-files context has been cancelled
Lines(
Equals("▼ /").IsSelected(),
Equals(" A file-one"),
@@ -0,0 +1,63 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RemoveLinesFromCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Remove specific lines from a commit using the 'd' shortcut in the patch building view",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("first commit")
shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n")
shell.Commit("commit to remove from")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit to remove from").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("A file1").IsSelected(),
).
PressEnter()
// Select the second line (+2nd line) and press 'd' to remove it
t.Views().PatchBuilding().
IsFocused().
SelectNextItem().
SelectedLines(
Contains("+2nd line"),
).
Press(keys.Universal.Remove)
t.ExpectPopup().Confirmation().
Title(Equals("Remove lines from commit")).
Content(Equals("Are you sure you want to remove the selected lines from this commit?")).
Confirm()
// After the rebase, we should be back at the commit files view
// and the commit should now only contain the 1st and 3rd lines
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("A file1").IsSelected(),
).
PressEscape()
t.Views().Main().ContainsLines(
Equals("+1st line"),
Equals("+3rd line"),
)
},
})
@@ -0,0 +1,64 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ToggleDirectory = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Toggle a directory for a custom patch",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir1")
shell.CreateFileAndAdd("dir1/file1", "file1 content\n")
shell.CreateFileAndAdd("dir1/file2", "file2 content\n")
shell.CreateFileAndAdd("dir1/file3", "file3 content\n")
shell.CreateFileAndAdd("other-file", "other content\n")
shell.Commit("first commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("first commit").IsSelected(),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Equals("▼ /").IsSelected(),
Equals(" ▼ dir1"),
Equals(" A file1"),
Equals(" A file2"),
Equals(" A file3"),
Equals(" A other-file"),
).
NavigateToLine(Contains("dir1")).
PressPrimaryAction().
Lines(
Equals("▼ /"),
Equals(" ▼ dir1").IsSelected(),
Equals(" ● file1"),
Equals(" ● file2"),
Equals(" ● file3"),
Equals(" A other-file"),
)
t.Views().Information().Content(Contains("Building patch"))
// Toggle the directory again to remove all files from the patch
t.Views().CommitFiles().
PressPrimaryAction().
Lines(
Equals("▼ /"),
Equals(" ▼ dir1").IsSelected(),
Equals(" A file1"),
Equals(" A file2"),
Equals(" A file3"),
Equals(" A other-file"),
)
},
})
@@ -34,14 +34,14 @@ var FetchWhenSortedByDate = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Branches().
Lines(
Contains("* branch1").IsSelected(),
MatchesRegexp(`\*.*branch1`).IsSelected(),
Contains("branch2"),
Contains("master ↓1"),
).
NavigateToLine(Contains("master")).
Press(keys.Branches.FetchRemote).
Lines(
Contains("* branch1"),
MatchesRegexp(`\*.*branch1`),
Contains("master").IsSelected(),
Contains("branch2"),
)
+5
View File
@@ -232,7 +232,10 @@ var tests = []*components.IntegrationTest{
file.StageRangeSelect,
filter_and_search.FilterByFileStatus,
filter_and_search.FilterCommitFiles,
filter_and_search.FilterCommitFilesToggleDirectory,
filter_and_search.FilterFiles,
filter_and_search.FilterFilesStageAll,
filter_and_search.FilterFilesStageDirectory,
filter_and_search.FilterFuzzy,
filter_and_search.FilterMenu,
filter_and_search.FilterMenuByKeybinding,
@@ -348,11 +351,13 @@ var tests = []*components.IntegrationTest{
patch_building.MoveToNewCommitInLastCommitOfStackedBranch,
patch_building.MoveToNewCommitPartialHunk,
patch_building.RemoveFromCommit,
patch_building.RemoveLinesFromCommit,
patch_building.RemovePartsOfAddedFile,
patch_building.ResetWithEscape,
patch_building.SelectAllFiles,
patch_building.SpecificSelection,
patch_building.StartNewPatch,
patch_building.ToggleDirectory,
patch_building.ToggleRange,
reflog.Checkout,
reflog.CherryPick,
+5
View File
@@ -446,6 +446,11 @@
"type": "integer",
"description": "When copying commit hashes to the clipboard, truncate them to this length. Set to 40 to disable truncation.",
"default": 12
},
"enableGithubCli": {
"type": "boolean",
"description": "If true and if `gh` is installed and on version \u003e=2, we will use `gh` to display pull requests against branches.",
"default": true
}
},
"additionalProperties": false,
+5
View File
@@ -446,6 +446,11 @@
"type": "integer",
"description": "When copying commit hashes to the clipboard, truncate them to this length. Set to 40 to disable truncation.",
"default": 12
},
"enableGithubCli": {
"type": "boolean",
"description": "If true and if if `gh` is installed and on version \u003e=2, we will use `gh` to display pull requests against branches.",
"default": true
}
},
"additionalProperties": false,
+1
View File
@@ -20,3 +20,4 @@ git:
# TODO: add tests which explicitly test auto-refresh functionality
autoRefresh: false
autoFetch: false
enableGithubCli: false
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+70
View File
@@ -0,0 +1,70 @@
package set
var exists = struct{}{}
type stringSet struct {
v []string
m map[string]struct{}
}
func NewStringSet() *stringSet {
s := &stringSet{}
s.m = make(map[string]struct{})
s.v = []string{}
return s
}
func (s *stringSet) Add(value string) {
if s.Contains(value) {
return
}
s.m[value] = exists
s.v = append(s.v, value)
}
func (s *stringSet) AddValues(values []string) {
for _, v := range values {
s.Add(v)
}
}
func (s *stringSet) Remove(value string) {
if !s.Contains(value) {
return
}
delete(s.m, value)
s.v = sliceWithout(s.v, value)
}
func sliceWithout(s []string, v string) []string {
idx := -1
for i, item := range s {
if item == v {
idx = i
break
}
}
if idx < 0 {
return s
}
return append(s[:idx], s[idx+1:]...)
}
func (s *stringSet) RemoveValues(values []string) {
for _, v := range values {
s.Remove(v)
}
}
func (s *stringSet) Contains(value string) bool {
_, c := s.m[value]
return c
}
func (s *stringSet) Len() int {
return len(s.m)
}
func (s *stringSet) ToSlice() []string {
return s.v
}
+214
View File
@@ -0,0 +1,214 @@
// Package yamlmap is a wrapper of gopkg.in/yaml.v3 for interacting
// with yaml data as if it were a map.
package yamlmap
import (
"errors"
"gopkg.in/yaml.v3"
)
const (
modified = "modifed"
)
type Map struct {
*yaml.Node
}
var ErrNotFound = errors.New("not found")
var ErrInvalidYaml = errors.New("invalid yaml")
var ErrInvalidFormat = errors.New("invalid format")
func StringValue(value string) *Map {
return &Map{&yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: value,
}}
}
func MapValue() *Map {
return &Map{&yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
}}
}
func NullValue() *Map {
return &Map{&yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!null",
}}
}
func Unmarshal(data []byte) (*Map, error) {
var root yaml.Node
err := yaml.Unmarshal(data, &root)
if err != nil {
return nil, ErrInvalidYaml
}
if len(root.Content) == 0 {
return MapValue(), nil
}
if root.Content[0].Kind != yaml.MappingNode {
return nil, ErrInvalidFormat
}
return &Map{root.Content[0]}, nil
}
func Marshal(m *Map) ([]byte, error) {
return yaml.Marshal(m.Node)
}
func (m *Map) AddEntry(key string, value *Map) {
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: key,
}
m.Content = append(m.Content, keyNode, value.Node)
m.SetModified()
}
func (m *Map) Empty() bool {
return m.Content == nil || len(m.Content) == 0
}
func (m *Map) FindEntry(key string) (*Map, error) {
// Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...].
// When iterating over the content slice we only want to compare the keys of the yamlMap.
for i, v := range m.Content {
if i%2 != 0 {
continue
}
if v.Value == key {
if i+1 < len(m.Content) {
return &Map{m.Content[i+1]}, nil
}
}
}
return nil, ErrNotFound
}
func (m *Map) Keys() []string {
// Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...].
// When iterating over the content slice we only want to select the keys of the yamlMap.
keys := []string{}
for i, v := range m.Content {
if i%2 != 0 {
continue
}
keys = append(keys, v.Value)
}
return keys
}
func (m *Map) RemoveEntry(key string) error {
// Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...].
// When iterating over the content slice we only want to compare the keys of the yamlMap.
// If we find they key to remove, remove the key and its value from the content slice.
found, skipNext := false, false
newContent := []*yaml.Node{}
for i, v := range m.Content {
if skipNext {
skipNext = false
continue
}
if i%2 != 0 || v.Value != key {
newContent = append(newContent, v)
} else {
found = true
skipNext = true
m.SetModified()
}
}
if !found {
return ErrNotFound
}
m.Content = newContent
return nil
}
func (m *Map) SetEntry(key string, value *Map) {
// Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...].
// When iterating over the content slice we only want to compare the keys of the yamlMap.
// If we find they key to set, set the next item in the content slice to the new value.
m.SetModified()
for i, v := range m.Content {
if i%2 != 0 || v.Value != key {
continue
}
if v.Value == key {
if i+1 < len(m.Content) {
m.Content[i+1] = value.Node
return
}
}
}
m.AddEntry(key, value)
}
// Note: This is a hack to introduce the concept of modified/unmodified
// on top of gopkg.in/yaml.v3. This works by setting the Value property
// of a MappingNode to a specific value and then later checking if the
// node's Value property is that specific value. When a MappingNode gets
// output as a string the Value property is not used, thus changing it
// has no impact for our purposes.
func (m *Map) SetModified() {
// Can not mark a non-mapping node as modified
if m.Node.Kind != yaml.MappingNode && m.Node.Tag == "!!null" {
m.Node.Kind = yaml.MappingNode
m.Node.Tag = "!!map"
}
if m.Node.Kind == yaml.MappingNode {
m.Node.Value = modified
}
}
// Traverse map using BFS to set all nodes as unmodified.
func (m *Map) SetUnmodified() {
i := 0
queue := []*yaml.Node{m.Node}
for {
if i > (len(queue) - 1) {
break
}
q := queue[i]
i = i + 1
if q.Kind != yaml.MappingNode {
continue
}
q.Value = ""
queue = append(queue, q.Content...)
}
}
// Traverse map using BFS to searach for any nodes that have been modified.
func (m *Map) IsModified() bool {
i := 0
queue := []*yaml.Node{m.Node}
for {
if i > (len(queue) - 1) {
break
}
q := queue[i]
i = i + 1
if q.Kind != yaml.MappingNode {
continue
}
if q.Value == modified {
return true
}
queue = append(queue, q.Content...)
}
return false
}
func (m *Map) String() string {
data, err := Marshal(m)
if err != nil {
return ""
}
return string(data)
}
+194
View File
@@ -0,0 +1,194 @@
// Package auth is a set of functions for retrieving authentication tokens
// and authenticated hosts.
package auth
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"github.com/cli/go-gh/v2/internal/set"
"github.com/cli/go-gh/v2/pkg/config"
"github.com/cli/safeexec"
)
const (
codespaces = "CODESPACES"
defaultSource = "default"
ghEnterpriseToken = "GH_ENTERPRISE_TOKEN"
ghHost = "GH_HOST"
ghToken = "GH_TOKEN"
github = "github.com"
githubEnterpriseToken = "GITHUB_ENTERPRISE_TOKEN"
githubToken = "GITHUB_TOKEN"
hostsKey = "hosts"
localhost = "github.localhost"
oauthToken = "oauth_token"
)
// TokenForHost retrieves an authentication token and the source of that token for the specified
// host. The source can be either an environment variable, configuration file, or the system
// keyring. In the latter case, this shells out to "gh auth token" to obtain the token.
//
// Returns "", "default" if no applicable token is found.
func TokenForHost(host string) (string, string) {
if token, source := TokenFromEnvOrConfig(host); token != "" {
return token, source
}
ghExe := os.Getenv("GH_PATH")
if ghExe == "" {
ghExe, _ = safeexec.LookPath("gh")
}
if ghExe != "" {
if token, source := tokenFromGh(ghExe, host); token != "" {
return token, source
}
}
return "", defaultSource
}
// TokenFromEnvOrConfig retrieves an authentication token from environment variables or the config
// file as fallback, but does not support reading the token from system keyring. Most consumers
// should use TokenForHost.
func TokenFromEnvOrConfig(host string) (string, string) {
cfg, _ := config.Read(nil)
return tokenForHost(cfg, host)
}
func tokenForHost(cfg *config.Config, host string) (string, string) {
host = normalizeHostname(host)
if IsEnterprise(host) {
if token := os.Getenv(ghEnterpriseToken); token != "" {
return token, ghEnterpriseToken
}
if token := os.Getenv(githubEnterpriseToken); token != "" {
return token, githubEnterpriseToken
}
if isCodespaces, _ := strconv.ParseBool(os.Getenv(codespaces)); isCodespaces {
if token := os.Getenv(githubToken); token != "" {
return token, githubToken
}
}
if cfg != nil {
token, _ := cfg.Get([]string{hostsKey, host, oauthToken})
return token, oauthToken
}
}
if token := os.Getenv(ghToken); token != "" {
return token, ghToken
}
if token := os.Getenv(githubToken); token != "" {
return token, githubToken
}
if cfg != nil {
token, _ := cfg.Get([]string{hostsKey, host, oauthToken})
return token, oauthToken
}
return "", defaultSource
}
func tokenFromGh(path string, host string) (string, string) {
cmd := exec.Command(path, "auth", "token", "--secure-storage", "--hostname", host)
result, err := cmd.Output()
if err != nil {
return "", "gh"
}
return strings.TrimSpace(string(result)), "gh"
}
// KnownHosts retrieves a list of hosts that have corresponding
// authentication tokens, either from environment variables
// or from the configuration file.
// Returns an empty string slice if no hosts are found.
func KnownHosts() []string {
cfg, _ := config.Read(nil)
return knownHosts(cfg)
}
func knownHosts(cfg *config.Config) []string {
hosts := set.NewStringSet()
if host := os.Getenv(ghHost); host != "" {
hosts.Add(host)
}
if token, _ := tokenForHost(cfg, github); token != "" {
hosts.Add(github)
}
if cfg != nil {
keys, err := cfg.Keys([]string{hostsKey})
if err == nil {
hosts.AddValues(keys)
}
}
return hosts.ToSlice()
}
// DefaultHost retrieves an authenticated host and the source of host.
// The source can be either an environment variable or from the
// configuration file.
// Returns "github.com", "default" if no viable host is found.
func DefaultHost() (string, string) {
cfg, _ := config.Read(nil)
return defaultHost(cfg)
}
func defaultHost(cfg *config.Config) (string, string) {
if host := os.Getenv(ghHost); host != "" {
return host, ghHost
}
if cfg != nil {
keys, err := cfg.Keys([]string{hostsKey})
if err == nil && len(keys) == 1 {
return keys[0], hostsKey
}
}
return github, defaultSource
}
// TenancyHost is the domain name of a tenancy GitHub instance.
const tenancyHost = "ghe.com"
// IsEnterprise determines if a provided host is a GitHub Enterprise Server instance,
// rather than GitHub.com or a tenancy GitHub instance.
func IsEnterprise(host string) bool {
normalizedHost := normalizeHostname(host)
return normalizedHost != github && normalizedHost != localhost && !IsTenancy(normalizedHost)
}
// IsTenancy determines if a provided host is a tenancy GitHub instance,
// rather than GitHub.com or a GitHub Enterprise Server instance.
func IsTenancy(host string) bool {
normalizedHost := normalizeHostname(host)
return strings.HasSuffix(normalizedHost, "."+tenancyHost)
}
func normalizeHostname(host string) string {
hostname := strings.ToLower(host)
if strings.HasSuffix(hostname, "."+github) {
return github
}
if strings.HasSuffix(hostname, "."+localhost) {
return localhost
}
// This has been copied over from the cli/cli NormalizeHostname function
// to ensure compatible behaviour but we don't fully understand when or
// why it would be useful here. We can't see what harm will come of
// duplicating the logic.
if before, found := cutSuffix(hostname, "."+tenancyHost); found {
idx := strings.LastIndex(before, ".")
return fmt.Sprintf("%s.%s", before[idx+1:], tenancyHost)
}
return hostname
}
// Backport strings.CutSuffix from Go 1.20.
func cutSuffix(s, suffix string) (string, bool) {
if !strings.HasSuffix(s, suffix) {
return s, false
}
return s[:len(s)-len(suffix)], true
}
+336
View File
@@ -0,0 +1,336 @@
// Package config is a set of types for interacting with the gh configuration files.
// Note: This package is intended for use only in gh, any other use cases are subject
// to breakage and non-backwards compatible updates.
package config
import (
"errors"
"io"
"os"
"path/filepath"
"runtime"
"sync"
"github.com/cli/go-gh/v2/internal/yamlmap"
)
const (
appData = "AppData"
ghConfigDir = "GH_CONFIG_DIR"
localAppData = "LocalAppData"
xdgConfigHome = "XDG_CONFIG_HOME"
xdgDataHome = "XDG_DATA_HOME"
xdgStateHome = "XDG_STATE_HOME"
xdgCacheHome = "XDG_CACHE_HOME"
)
var (
cfg *Config
once sync.Once
loadErr error
)
// Config is a in memory representation of the gh configuration files.
// It can be thought of as map where entries consist of a key that
// correspond to either a string value or a map value, allowing for
// multi-level maps.
type Config struct {
entries *yamlmap.Map
mu sync.RWMutex
}
// Get a string value from a Config.
// The keys argument is a sequence of key values so that nested
// entries can be retrieved. A undefined string will be returned
// if trying to retrieve a key that corresponds to a map value.
// Returns "", KeyNotFoundError if any of the keys can not be found.
func (c *Config) Get(keys []string) (string, error) {
c.mu.RLock()
defer c.mu.RUnlock()
m := c.entries
for _, key := range keys {
var err error
m, err = m.FindEntry(key)
if err != nil {
return "", &KeyNotFoundError{key}
}
}
return m.Value, nil
}
// Keys enumerates a Config's keys.
// The keys argument is a sequence of key values so that nested
// map values can be have their keys enumerated.
// Returns nil, KeyNotFoundError if any of the keys can not be found.
func (c *Config) Keys(keys []string) ([]string, error) {
c.mu.RLock()
defer c.mu.RUnlock()
m := c.entries
for _, key := range keys {
var err error
m, err = m.FindEntry(key)
if err != nil {
return nil, &KeyNotFoundError{key}
}
}
return m.Keys(), nil
}
// Remove an entry from a Config.
// The keys argument is a sequence of key values so that nested
// entries can be removed. Removing an entry that has nested
// entries removes those also.
// Returns KeyNotFoundError if any of the keys can not be found.
func (c *Config) Remove(keys []string) error {
c.mu.Lock()
defer c.mu.Unlock()
m := c.entries
for i := 0; i < len(keys)-1; i++ {
var err error
key := keys[i]
m, err = m.FindEntry(key)
if err != nil {
return &KeyNotFoundError{key}
}
}
err := m.RemoveEntry(keys[len(keys)-1])
if err != nil {
return &KeyNotFoundError{keys[len(keys)-1]}
}
return nil
}
// Set a string value in a Config.
// The keys argument is a sequence of key values so that nested
// entries can be set. If any of the keys do not exist they will
// be created. If the string value to be set is empty it will be
// represented as null not an empty string when written.
//
// var c *Config
// c.Set([]string{"key"}, "")
// Write(c) // writes `key: ` not `key: ""`
func (c *Config) Set(keys []string, value string) {
c.mu.Lock()
defer c.mu.Unlock()
m := c.entries
for i := 0; i < len(keys)-1; i++ {
key := keys[i]
entry, err := m.FindEntry(key)
if err != nil {
entry = yamlmap.MapValue()
m.AddEntry(key, entry)
}
m = entry
}
val := yamlmap.StringValue(value)
if value == "" {
val = yamlmap.NullValue()
}
m.SetEntry(keys[len(keys)-1], val)
}
func (c *Config) deepCopy() *Config {
return ReadFromString(c.entries.String())
}
// Read gh configuration files from the local file system and
// returns a Config. A copy of the fallback configuration will
// be returned when there are no configuration files to load.
// If there are no configuration files and no fallback configuration
// an empty configuration will be returned.
var Read = func(fallback *Config) (*Config, error) {
once.Do(func() {
cfg, loadErr = load(generalConfigFile(), hostsConfigFile(), fallback)
})
return cfg, loadErr
}
// ReadFromString takes a yaml string and returns a Config.
func ReadFromString(str string) *Config {
m, _ := mapFromString(str)
if m == nil {
m = yamlmap.MapValue()
}
return &Config{entries: m}
}
// Write gh configuration files to the local file system.
// It will only write gh configuration files that have been modified
// since last being read.
func Write(c *Config) error {
c.mu.Lock()
defer c.mu.Unlock()
hosts, err := c.entries.FindEntry("hosts")
if err == nil && hosts.IsModified() {
err := writeFile(hostsConfigFile(), []byte(hosts.String()))
if err != nil {
return err
}
hosts.SetUnmodified()
}
if c.entries.IsModified() {
// Hosts gets written to a different file above so remove it
// before writing and add it back in after writing.
hostsMap, hostsErr := c.entries.FindEntry("hosts")
if hostsErr == nil {
_ = c.entries.RemoveEntry("hosts")
}
err := writeFile(generalConfigFile(), []byte(c.entries.String()))
if err != nil {
return err
}
c.entries.SetUnmodified()
if hostsErr == nil {
c.entries.AddEntry("hosts", hostsMap)
}
}
return nil
}
func load(generalFilePath, hostsFilePath string, fallback *Config) (*Config, error) {
generalMap, err := mapFromFile(generalFilePath)
if err != nil && !os.IsNotExist(err) {
if errors.Is(err, yamlmap.ErrInvalidYaml) ||
errors.Is(err, yamlmap.ErrInvalidFormat) {
return nil, &InvalidConfigFileError{Path: generalFilePath, Err: err}
}
return nil, err
}
if generalMap == nil {
generalMap = yamlmap.MapValue()
}
hostsMap, err := mapFromFile(hostsFilePath)
if err != nil && !os.IsNotExist(err) {
if errors.Is(err, yamlmap.ErrInvalidYaml) ||
errors.Is(err, yamlmap.ErrInvalidFormat) {
return nil, &InvalidConfigFileError{Path: hostsFilePath, Err: err}
}
return nil, err
}
if hostsMap != nil && !hostsMap.Empty() {
generalMap.AddEntry("hosts", hostsMap)
generalMap.SetUnmodified()
}
if generalMap.Empty() && fallback != nil {
return fallback.deepCopy(), nil
}
return &Config{entries: generalMap}, nil
}
func generalConfigFile() string {
return filepath.Join(ConfigDir(), "config.yml")
}
func hostsConfigFile() string {
return filepath.Join(ConfigDir(), "hosts.yml")
}
func mapFromFile(filename string) (*yamlmap.Map, error) {
data, err := readFile(filename)
if err != nil {
return nil, err
}
return yamlmap.Unmarshal(data)
}
func mapFromString(str string) (*yamlmap.Map, error) {
return yamlmap.Unmarshal([]byte(str))
}
// Config path precedence: GH_CONFIG_DIR, XDG_CONFIG_HOME, AppData (windows only), HOME.
func ConfigDir() string {
var path string
if a := os.Getenv(ghConfigDir); a != "" {
path = a
} else if b := os.Getenv(xdgConfigHome); b != "" {
path = filepath.Join(b, "gh")
} else if c := os.Getenv(appData); runtime.GOOS == "windows" && c != "" {
path = filepath.Join(c, "GitHub CLI")
} else {
d, _ := os.UserHomeDir()
path = filepath.Join(d, ".config", "gh")
}
return path
}
// State path precedence: XDG_STATE_HOME, LocalAppData (windows only), HOME.
func StateDir() string {
var path string
if a := os.Getenv(xdgStateHome); a != "" {
path = filepath.Join(a, "gh")
} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
path = filepath.Join(b, "GitHub CLI")
} else {
c, _ := os.UserHomeDir()
path = filepath.Join(c, ".local", "state", "gh")
}
return path
}
// Data path precedence: XDG_DATA_HOME, LocalAppData (windows only), HOME.
func DataDir() string {
var path string
if a := os.Getenv(xdgDataHome); a != "" {
path = filepath.Join(a, "gh")
} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
path = filepath.Join(b, "GitHub CLI")
} else {
c, _ := os.UserHomeDir()
path = filepath.Join(c, ".local", "share", "gh")
}
return path
}
// Cache path precedence: XDG_CACHE_HOME, LocalAppData (windows only), HOME, legacy gh-cli-cache.
func CacheDir() string {
if a := os.Getenv(xdgCacheHome); a != "" {
return filepath.Join(a, "gh")
} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
return filepath.Join(b, "GitHub CLI")
} else if c, err := os.UserHomeDir(); err == nil {
return filepath.Join(c, ".cache", "gh")
} else {
// Note that this has a minor security issue because /tmp is world-writeable.
// As such, it is possible for other users on a shared system to overwrite cached data.
// The practical risk of this is low, but it's worth calling out as a risk.
// I've included this here for backwards compatibility but we should consider removing it.
return filepath.Join(os.TempDir(), "gh-cli-cache")
}
}
func readFile(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
return data, nil
}
func writeFile(filename string, data []byte) (writeErr error) {
if writeErr = os.MkdirAll(filepath.Dir(filename), 0771); writeErr != nil {
return
}
var file *os.File
if file, writeErr = os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600); writeErr != nil {
return
}
defer func() {
if err := file.Close(); writeErr == nil && err != nil {
writeErr = err
}
}()
_, writeErr = file.Write(data)
return
}
+32
View File
@@ -0,0 +1,32 @@
package config
import (
"fmt"
)
// InvalidConfigFileError represents an error when trying to read a config file.
type InvalidConfigFileError struct {
Path string
Err error
}
// Allow InvalidConfigFileError to satisfy error interface.
func (e *InvalidConfigFileError) Error() string {
return fmt.Sprintf("invalid config file %s: %s", e.Path, e.Err)
}
// Allow InvalidConfigFileError to be unwrapped.
func (e *InvalidConfigFileError) Unwrap() error {
return e.Err
}
// KeyNotFoundError represents an error when trying to find a config key
// that does not exist.
type KeyNotFoundError struct {
Key string
}
// Allow KeyNotFoundError to satisfy error interface.
func (e *KeyNotFoundError) Error() string {
return fmt.Sprintf("could not find key %q", e.Key)
}
+25
View File
@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2020, GitHub Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+40
View File
@@ -0,0 +1,40 @@
# safeexec
A Go module that provides a safer alternative to `exec.LookPath()` on Windows.
The following, relatively common approach to running external commands has a subtle vulnerability on Windows:
```go
import "os/exec"
func gitStatus() error {
// On Windows, this will result in `.\git.exe` or `.\git.bat` being executed
// if either were found in the current working directory.
cmd := exec.Command("git", "status")
return cmd.Run()
}
```
Searching the current directory (surprising behavior) before searching folders listed in the PATH environment variable (expected behavior) seems to be intended in Go and unlikely to be changed: https://github.com/golang/go/issues/38736
Since Go does not provide a version of [`exec.LookPath()`](https://golang.org/pkg/os/exec/#LookPath) that only searches PATH and does not search the current working directory, this module provides a `LookPath` function that works consistently across platforms.
Example use:
```go
import (
"os/exec"
"github.com/cli/safeexec"
)
func gitStatus() error {
gitBin, err := safeexec.LookPath("git")
if err != nil {
return err
}
cmd := exec.Command(gitBin, "status")
return cmd.Run()
}
```
## TODO
Ideally, this module would also provide `exec.Command()` and `exec.CommandContext()` equivalents that delegate to the patched version of `LookPath`. However, this doesn't seem possible since `LookPath` may return an error, while `exec.Command/CommandContext()` themselves do not return an error. In the standard library, the resulting `exec.Cmd` struct stores the LookPath error in a private field, but that functionality isn't available to us.
+9
View File
@@ -0,0 +1,9 @@
// +build !windows
package safeexec
import "os/exec"
func LookPath(file string) (string, error) {
return exec.LookPath(file)
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright (c) 2009 The Go Authors. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
// Package safeexec provides alternatives for exec package functions to avoid
// accidentally executing binaries found in the current working directory on
// Windows.
package safeexec
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
func chkStat(file string) error {
d, err := os.Stat(file)
if err != nil {
return err
}
if d.IsDir() {
return os.ErrPermission
}
return nil
}
func hasExt(file string) bool {
i := strings.LastIndex(file, ".")
if i < 0 {
return false
}
return strings.LastIndexAny(file, `:\/`) < i
}
func findExecutable(file string, exts []string) (string, error) {
if len(exts) == 0 {
return file, chkStat(file)
}
if hasExt(file) {
if chkStat(file) == nil {
return file, nil
}
}
for _, e := range exts {
if f := file + e; chkStat(f) == nil {
return f, nil
}
}
return "", os.ErrNotExist
}
// LookPath searches for an executable named file in the
// directories named by the PATH environment variable.
// If file contains a slash, it is tried directly and the PATH is not consulted.
// LookPath also uses PATHEXT environment variable to match
// a suitable candidate.
// The result may be an absolute path or a path relative to the current directory.
func LookPath(file string) (string, error) {
var exts []string
x := os.Getenv(`PATHEXT`)
if x != "" {
for _, e := range strings.Split(strings.ToLower(x), `;`) {
if e == "" {
continue
}
if e[0] != '.' {
e = "." + e
}
exts = append(exts, e)
}
} else {
exts = []string{".com", ".exe", ".bat", ".cmd"}
}
if strings.ContainsAny(file, `:\/`) {
if f, err := findExecutable(file, exts); err == nil {
return f, nil
} else {
return "", &exec.Error{file, err}
}
}
// https://github.com/golang/go/issues/38736
// if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
// return f, nil
// }
path := os.Getenv("path")
for _, dir := range filepath.SplitList(path) {
if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil {
return f, nil
}
}
return "", &exec.Error{file, exec.ErrNotFound}
}
-2
View File
@@ -42,7 +42,6 @@ loop:
continue
}
var buf bytes.Buffer
for {
c, err := er.ReadByte()
if err != nil {
@@ -51,7 +50,6 @@ loop:
if ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || c == '@' {
break
}
buf.Write([]byte(string(c)))
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
//go:build (darwin || freebsd || openbsd || netbsd || dragonfly) && !appengine
// +build darwin freebsd openbsd netbsd dragonfly
//go:build (darwin || freebsd || openbsd || netbsd || dragonfly || hurd) && !appengine && !tinygo
// +build darwin freebsd openbsd netbsd dragonfly hurd
// +build !appengine
// +build !tinygo
package isatty
+3 -2
View File
@@ -1,5 +1,6 @@
//go:build appengine || js || nacl || wasm
// +build appengine js nacl wasm
//go:build (appengine || js || nacl || tinygo || wasm) && !windows
// +build appengine js nacl tinygo wasm
// +build !windows
package isatty
+2 -1
View File
@@ -1,6 +1,7 @@
//go:build (linux || aix || zos) && !appengine
//go:build (linux || aix || zos) && !appengine && !tinygo
// +build linux aix zos
// +build !appengine
// +build !tinygo
package isatty
+13 -4
View File
@@ -48,6 +48,15 @@ github.com/bahlo/generic-list-go
# github.com/buger/jsonparser v1.1.1
## explicit; go 1.13
github.com/buger/jsonparser
# github.com/cli/go-gh/v2 v2.9.0
## explicit; go 1.21
github.com/cli/go-gh/v2/internal/set
github.com/cli/go-gh/v2/internal/yamlmap
github.com/cli/go-gh/v2/pkg/auth
github.com/cli/go-gh/v2/pkg/config
# github.com/cli/safeexec v1.0.0
## explicit; go 1.15
github.com/cli/safeexec
# github.com/cloudflare/circl v1.6.1
## explicit; go 1.22.0
github.com/cloudflare/circl/dh/x25519
@@ -242,11 +251,11 @@ github.com/lucasb-eyer/go-colorful
## explicit; go 1.12
github.com/mailru/easyjson/buffer
github.com/mailru/easyjson/jwriter
# github.com/mattn/go-colorable v0.1.11
## explicit; go 1.13
# github.com/mattn/go-colorable v0.1.13
## explicit; go 1.15
github.com/mattn/go-colorable
# github.com/mattn/go-isatty v0.0.14
## explicit; go 1.12
# github.com/mattn/go-isatty v0.0.20
## explicit; go 1.15
github.com/mattn/go-isatty
# github.com/mgutz/str v1.2.0
## explicit