Show pull requests against branches (#2781)

- **PR Description**

If the user has `gh` installed and is logged in (`gh auth login`),
lazygit shows GitHub PR icons next to the names of branches that have an
associated PR, colored by the PR's status (green=open, red=closed,
purple=merged).

Selecting a branch and pressing `shift-G` opens the PR in the browser.
This commit is contained in:
Stefan Haller 2026-04-01 10:18:01 +02:00 committed by GitHub
commit a33f83cf76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 2648 additions and 878 deletions

View file

@ -228,6 +228,10 @@ If you press `shift+w` on a commit (or branch/ref) a menu will open that allows
![diff_commits](../assets/demo/diff_commits-compressed.gif)
### Show GitHub pull requests
In the branches panel, lazygit can show which of your branches have an associated GitHub pull request by showing a GitHub icon next to the branch name; its color shows the state of the PR (open, merged, etc.). For those that have one, you can press `shift-G` to open the PR in the browser. There is no configuration needed to enable this, but it requires the [`gh`](https://cli.github.com/) tool to be installed, and you need to do `gh auth login` once to allow lazygit to access GitHub.
## Tutorials
[<img src="https://i.imgur.com/sVEktDn.png">](https://youtu.be/CPLdltN7wgE)

View file

@ -707,6 +707,7 @@ keybinding:
branches:
createPullRequest: o
viewPullRequestOptions: O
openPullRequestInBrowser: G
copyPullRequestURL: <c-y>
checkoutBranchByName: c
forceCheckoutBranch: F
@ -749,6 +750,7 @@ keybinding:
copyCommitAttributeToClipboard: "y"
openLogMenu: <c-l>
openInBrowser: o
openPullRequestInBrowser: G
viewBisectOptions: b
startInteractiveRebase: i
selectCommitsOfCurrentBranch: '*'

View file

@ -107,6 +107,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <c-l> `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | Open pull request in browser | |
| `` <space> `` | Checkout | Checkout the selected commit as a detached HEAD. |
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Open commit in browser | |
@ -180,6 +181,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` o `` | Create pull request | |
| `` O `` | View create pull request options | |
| `` G `` | Open pull request in browser | |
| `` <c-y> `` | Copy pull request URL to clipboard | |
| `` c `` | Checkout by name | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |

View file

@ -87,6 +87,7 @@ _凡例`c-b` はctrl+b、`a-b` はalt+b、`B` はshift+bを意味
| `` t `` | リバート | 選択したコミットの変更を逆に適用する、リバートコミットを作成します。 |
| `` T `` | コミットにタグを付ける | 選択したコミットを指すタグを新規作成します。タグ名とオプションの説明を入力するよう促されます。 |
| `` <c-l> `` | ログオプションを表示 | コミットログのオプションを表示します並び順の変更、Gitグラフの非表示、Gitグラフ全体の表示。 |
| `` G `` | Open pull request in browser | |
| `` <space> `` | チェックアウト(ブランチの切り替え) | 選択したコミットをデタッチドヘッド(特定のブランチに属さない状態)としてチェックアウトします。 |
| `` y `` | コミット属性をクリップボードにコピー | コミット属性をクリップボードにコピーしますハッシュ、URL、差分、メッセージ、作者。 |
| `` o `` | ブラウザでコミットを開く | |
@ -379,6 +380,7 @@ _凡例`c-b` はctrl+b、`a-b` はalt+b、`B` はshift+bを意味
| `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` o `` | プルリクエストを作成 | |
| `` O `` | プルリクエスト作成オプションを表示 | |
| `` G `` | Open pull request in browser | |
| `` <c-y> `` | プルリクエストURLをクリップボードにコピー | |
| `` c `` | 名前でチェックアウト | 名前でチェックアウトします。入力ボックスに「-」を入力すると、最後のブランチをチェックアウトすることができます。 |
| `` - `` | 直前のブランチにチェックアウト | |

View file

@ -215,6 +215,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` o `` | 풀 리퀘스트 생성 | |
| `` O `` | 풀 리퀘스트 생성 옵션 | |
| `` G `` | Open pull request in browser | |
| `` <c-y> `` | 풀 리퀘스트 URL을 클립보드에 복사 | |
| `` c `` | 이름으로 체크아웃 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |
@ -318,6 +319,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <c-l> `` | 로그 메뉴 열기 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | Open pull request in browser | |
| `` <space> `` | 체크아웃 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 커밋 attribute 복사 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 브라우저에서 커밋 열기 | |

View file

@ -105,6 +105,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` o `` | Maak een pull-request | |
| `` O `` | Bekijk opties voor pull-aanvraag | |
| `` G `` | Open pull request in browser | |
| `` <c-y> `` | Kopieer de URL van het pull-verzoek naar het klembord | |
| `` c `` | Uitchecken bij naam | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |
@ -178,6 +179,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <c-l> `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | Open pull request in browser | |
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Open commit in browser | |

View file

@ -80,6 +80,7 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-b>` oznacza alt+b, `B` oznacza shift+b_
| `` t `` | Cofnij | Utwórz commit cofający dla wybranego commita, który stosuje zmiany wybranego commita w odwrotnej kolejności. |
| `` T `` | Otaguj commit | Utwórz nowy tag wskazujący na wybrany commit. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. |
| `` <c-l> `` | Zobacz opcje logów | Zobacz opcje dla logów commitów, np. zmiana kolejności sortowania, ukrywanie grafu gita, pokazywanie całego grafu gita. |
| `` G `` | Open pull request in browser | |
| `` <space> `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. |
| `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). |
| `` o `` | Otwórz commit w przeglądarce | |
@ -146,6 +147,7 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-b>` oznacza alt+b, `B` oznacza shift+b_
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` o `` | Utwórz żądanie ściągnięcia | |
| `` O `` | Zobacz opcje tworzenia pull requesta | |
| `` G `` | Open pull request in browser | |
| `` <c-y> `` | Kopiuj adres URL żądania ściągnięcia do schowka | |
| `` c `` | Przełącz według nazwy | Przełącz według nazwy. W polu wprowadzania możesz wpisać '-' aby przełączyć się na ostatnią gałąź. |
| `` - `` | Checkout previous branch | |

View file

@ -97,6 +97,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` o `` | Create pull request | |
| `` O `` | View create pull request options | |
| `` G `` | Open pull request in browser | |
| `` <c-y> `` | Copiar URL do pull request para área de transferência | |
| `` c `` | Checar por nome | Checar por nome. Na caixa de entrada você pode inserir '-' para trocar para a última branch |
| `` - `` | Checkout da branch anterior | |
@ -182,6 +183,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| `` t `` | Reverter | Crie um commit reverter para o commit selecionado, que aplica as alterações do commit selecionado em reverso. |
| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <c-l> `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | Open pull request in browser | |
| `` <space> `` | Verificar | Checkout the selected commit as a detached HEAD. |
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Open commit in browser | |

View file

@ -189,6 +189,7 @@ _Связки клавиш_
| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | Пометить коммит тегом | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <c-l> `` | Открыть меню журнала | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | Open pull request in browser | |
| `` <space> `` | Переключить | Checkout the selected commit as a detached HEAD. |
| `` y `` | Скопировать атрибут коммита | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Открыть коммит в браузере | |
@ -214,6 +215,7 @@ _Связки клавиш_
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` o `` | Создать запрос на принятие изменений | |
| `` O `` | Создать параметры запроса принятие изменений | |
| `` G `` | Open pull request in browser | |
| `` <c-y> `` | Скопировать URL запроса на принятие изменений в буфер обмена | |
| `` c `` | Переключить по названию | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |

View file

@ -144,6 +144,7 @@ _图例`<c-b>` 意味着ctrl+b, `<a-b>意味着Alt+b, `B` 意味着shift+b_
| `` t `` | 撤销(Revert) | 为所选提交创建还原提交,这会反向应用所选提交的更改。 |
| `` T `` | 标签提交 | 创建一个新标签指向所选提交。您可以在弹窗中输入标签名称和描述(可选)。 |
| `` <c-l> `` | 打开日志菜单 | 查看提交日志的选项,例如更改排序顺序、隐藏 git graph、显示整个 git graph。 |
| `` G `` | Open pull request in browser | |
| `` <space> `` | 检出 | 检出所选择的提交作为分离HEAD。 |
| `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(如hash、URL、diff、消息、作者)。 |
| `` o `` | 在浏览器中打开提交 | |
@ -229,6 +230,7 @@ _图例`<c-b>` 意味着ctrl+b, `<a-b>意味着Alt+b, `B` 意味着shift+b_
| `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。<br><br>请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 |
| `` o `` | 创建拉取请求 | |
| `` O `` | 创建拉取请求选项 | |
| `` G `` | Open pull request in browser | |
| `` <c-y> `` | 复制拉取请求 URL 到剪贴板 | |
| `` c `` | 按名称检出 | 按名称检出。在输入框中,您可以输入'-' 来切换到最后一个分支。 |
| `` - `` | 签出上一个分支 | |

View file

@ -203,6 +203,7 @@ _說明`<c-b>` 表示 CtrlB、`<a-b>` 表示 AltB`B`表示 ShiftB
| `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <c-l> `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | Open pull request in browser | |
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | |
@ -289,6 +290,7 @@ _說明`<c-b>` 表示 CtrlB、`<a-b>` 表示 AltB`B`表示 ShiftB
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` o `` | 建立拉取請求 | |
| `` O `` | 建立拉取請求選項 | |
| `` G `` | Open pull request in browser | |
| `` <c-y> `` | 複製拉取請求的 URL 到剪貼板 | |
| `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |

8
go.mod
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.13.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.24
github.com/gdamore/tcell/v2 v2.13.8
@ -35,7 +36,7 @@ require (
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad
github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304
github.com/stretchr/testify v1.11.1
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
@ -46,6 +47,7 @@ require (
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/cli/safeexec v1.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.9.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
@ -57,8 +59,8 @@ require (
github.com/kr/pretty v0.3.1 // 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/onsi/gomega v1.34.1 // indirect
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect

20
go.sum
View file

@ -11,6 +11,10 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys=
github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM=
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 h1:tuijfIjZyjZaHq9xDUh0tNitwXshJpbLkqMOJv4H3do=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@ -72,13 +76,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=
@ -125,8 +130,9 @@ github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xz
github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@ -152,13 +158,13 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View file

@ -16,27 +16,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
}
@ -122,6 +124,8 @@ func NewGitCommandAux(
bisectCommands := git_commands.NewBisectCommands(gitCommon)
worktreeCommands := git_commands.NewWorktreeCommands(gitCommon)
blameCommands := git_commands.NewBlameCommands(gitCommon)
gitHubCommands := git_commands.NewGitHubCommands(gitCommon)
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
@ -133,26 +137,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,

View file

@ -0,0 +1,380 @@
package git_commands
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/cli/go-gh/v2/pkg/auth"
"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 NewGitHubCommands(gitCommon *GitCommon) *GitHubCommands {
return &GitHubCommands{
GitCommon: gitCommon,
}
}
// https://github.com/cli/cli/issues/2300
func (self *GitHubCommands) ConfiguredBaseRemoteName() string {
// TODO: we only support the (common) case where the value of the config is "base", meaning that
// the remote's URL determines the GitHub repo. Since `gh repo set-default` on the command line
// sets the config this way, it's probably good enough in practice, but for completeness it
// would be nice to also support the case where the config value is a full remote name (e.g.
// "jesseduffield/lazygit").
cmdArgs := NewGitCmd("config").
Arg("--local", "--get-regexp", `remote\..*\.gh-resolved`).
ToArgv()
output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return ""
}
regex := regexp.MustCompile(`remote\.(.+)\.gh-resolved`)
matches := regex.FindStringSubmatch(output)
if len(matches) < 2 {
return ""
}
return matches[1]
}
func (self *GitHubCommands) SetConfiguredBaseRemoteName(remoteName string) error {
cmdArgs := NewGitCmd("config").
Arg("--local", "--add", fmt.Sprintf("remote.%s.gh-resolved", remoteName), "base").
ToArgv()
return self.cmd.New(cmdArgs).DontLog().Run()
}
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"`
}
type graphQLRequest struct {
Query string `json:"query"`
Variables map[string]string `json:"variables"`
}
func fetchPullRequestsQuery(branches []string, owner string, repo string) (string, map[string]string) {
variables := make(map[string]string, len(branches)+2)
variables["owner"] = owner
variables["repo"] = repo
varDecls := make([]string, 0, len(branches)+2)
varDecls = append(varDecls, "$owner: String!", "$repo: 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)
varName := fmt.Sprintf("branch%d", i+1)
variables[varName] = branch
varDecls = append(varDecls, fmt.Sprintf("$%s: String!", varName))
// 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, varName))
}
queryString := fmt.Sprintf(`query(%s) {
repository(owner: $owner, name: $repo) {
%s
}
}`, strings.Join(varDecls, ", "), strings.Join(queries, "\n"))
return queryString, variables
}
func (self *GitHubCommands) GetAuthToken() string {
defaultHost, _ := auth.DefaultHost()
token, _ := auth.TokenForHost(defaultHost)
return token
}
// FetchRecentPRs fetches recent pull requests using GraphQL.
func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models.Remote, token string) ([]*models.GithubPullRequest, error) {
repoOwner, repoName, err := self.GetBaseRepoOwnerAndName(baseRemote)
if err != nil {
return nil, err
}
t := time.Now()
var g errgroup.Group
// 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)
numChunks := (len(branches) + branchesPerRequest - 1) / branchesPerRequest
results := make(chan []*models.GithubPullRequest, numChunks)
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, token)
if err != nil {
return err
}
results <- prs
return nil
})
}
// Wait for all goroutines, then close the channel so the range loop exits
err = g.Wait()
close(results)
if err != nil {
return nil, err
}
// Collect results from all goroutines
var allPRs []*models.GithubPullRequest
for prs := range results {
allPRs = append(allPRs, prs...)
}
self.Log.Infof("Fetched %d PRs in %s", len(allPRs), time.Since(t))
return allPRs, nil
}
func (self *GitHubCommands) fetchRecentPRsAux(repoOwner string, repoName string, branches []string, token string) ([]*models.GithubPullRequest, error) {
queryString, variables := fetchPullRequestsQuery(branches, repoOwner, repoName)
bodyBytes, err := json.Marshal(graphQLRequest{Query: queryString, Variables: variables})
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}
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())
}
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result Response
err = json.Unmarshal(respBytes, &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
}
func (self *GitHubCommands) InGithubRepo(remotes []*models.Remote) bool {
if len(remotes) == 0 {
return false
}
remote := getMainRemote(remotes)
if len(remote.Urls) == 0 {
return false
}
url := remote.Urls[0]
return strings.Contains(url, "github.com")
}
func getMainRemote(remotes []*models.Remote) *models.Remote {
for _, remote := range remotes {
if remote.Name == "origin" {
return remote
}
}
// need to sort remotes by name so that this is deterministic
return lo.MinBy(remotes, func(a, b *models.Remote) bool {
return a.Name < b.Name
})
}
func (self *GitHubCommands) GetBaseRepoOwnerAndName(baseRemote *models.Remote) (string, string, error) {
if len(baseRemote.Urls) == 0 {
return "", "", fmt.Errorf("No URLs found for remote")
}
url := baseRemote.Urls[0]
repoInfo, err := hosting_service.GetRepoInfoFromURL(url)
if err != nil {
return "", "", err
}
return repoInfo.Owner, repoInfo.Repository, nil
}

View file

@ -0,0 +1,329 @@
package git_commands
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/stretchr/testify/assert"
)
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)
})
}
}

View file

@ -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)
}

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{
@ -51,7 +58,8 @@ var azdoServiceDef = ServiceDefinition{
`^https://.*@dev.azure.com/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`,
`^https://.*/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`,
},
repoURLTemplate: "https://{{.webDomain}}/{{.org}}/{{.project}}/_git/{{.repo}}",
repoURLTemplate: "https://{{.webDomain}}/{{.org}}/{{.project}}/_git/{{.repo}}",
repoNameTemplate: "{{.org}}/{{.project}}/{{.repo}}",
}
var bitbucketServerServiceDef = ServiceDefinition{
@ -63,7 +71,8 @@ var bitbucketServerServiceDef = ServiceDefinition{
`^ssh://git@.*/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
},
repoURLTemplate: "https://{{.webDomain}}/projects/{{.project}}/repos/{{.repo}}",
repoURLTemplate: "https://{{.webDomain}}/projects/{{.project}}/repos/{{.repo}}",
repoNameTemplate: "{{.project}}/{{.repo}}",
}
var giteaServiceDef = ServiceDefinition{

View file

@ -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
}

View file

@ -0,0 +1,24 @@
package models
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"`
}

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) {

View file

@ -531,24 +531,25 @@ type KeybindingFilesConfig struct {
}
type KeybindingBranchesConfig struct {
CreatePullRequest string `yaml:"createPullRequest"`
ViewPullRequestOptions string `yaml:"viewPullRequestOptions"`
CopyPullRequestURL string `yaml:"copyPullRequestURL"`
CheckoutBranchByName string `yaml:"checkoutBranchByName"`
ForceCheckoutBranch string `yaml:"forceCheckoutBranch"`
CheckoutPreviousBranch string `yaml:"checkoutPreviousBranch"`
RebaseBranch string `yaml:"rebaseBranch"`
RenameBranch string `yaml:"renameBranch"`
MergeIntoCurrentBranch string `yaml:"mergeIntoCurrentBranch"`
MoveCommitsToNewBranch string `yaml:"moveCommitsToNewBranch"`
ViewGitFlowOptions string `yaml:"viewGitFlowOptions"`
FastForward string `yaml:"fastForward"`
CreateTag string `yaml:"createTag"`
PushTag string `yaml:"pushTag"`
SetUpstream string `yaml:"setUpstream"`
FetchRemote string `yaml:"fetchRemote"`
AddForkRemote string `yaml:"addForkRemote"`
SortOrder string `yaml:"sortOrder"`
CreatePullRequest string `yaml:"createPullRequest"`
ViewPullRequestOptions string `yaml:"viewPullRequestOptions"`
OpenPullRequestInBrowser string `yaml:"openPullRequestInBrowser"`
CopyPullRequestURL string `yaml:"copyPullRequestURL"`
CheckoutBranchByName string `yaml:"checkoutBranchByName"`
ForceCheckoutBranch string `yaml:"forceCheckoutBranch"`
CheckoutPreviousBranch string `yaml:"checkoutPreviousBranch"`
RebaseBranch string `yaml:"rebaseBranch"`
RenameBranch string `yaml:"renameBranch"`
MergeIntoCurrentBranch string `yaml:"mergeIntoCurrentBranch"`
MoveCommitsToNewBranch string `yaml:"moveCommitsToNewBranch"`
ViewGitFlowOptions string `yaml:"viewGitFlowOptions"`
FastForward string `yaml:"fastForward"`
CreateTag string `yaml:"createTag"`
PushTag string `yaml:"pushTag"`
SetUpstream string `yaml:"setUpstream"`
FetchRemote string `yaml:"fetchRemote"`
AddForkRemote string `yaml:"addForkRemote"`
SortOrder string `yaml:"sortOrder"`
}
type KeybindingWorktreesConfig struct {
@ -579,6 +580,7 @@ type KeybindingCommitsConfig struct {
CopyCommitAttributeToClipboard string `yaml:"copyCommitAttributeToClipboard"`
OpenLogMenu string `yaml:"openLogMenu"`
OpenInBrowser string `yaml:"openInBrowser"`
OpenPullRequestInBrowser string `yaml:"openPullRequestInBrowser"`
ViewBisectOptions string `yaml:"viewBisectOptions"`
StartInteractiveRebase string `yaml:"startInteractiveRebase"`
SelectCommitsOfCurrentBranch string `yaml:"selectCommitsOfCurrentBranch"`
@ -996,24 +998,25 @@ func GetDefaultConfig() *UserConfig {
ExpandAll: "=",
},
Branches: KeybindingBranchesConfig{
CopyPullRequestURL: "<c-y>",
CreatePullRequest: "o",
ViewPullRequestOptions: "O",
CheckoutBranchByName: "c",
ForceCheckoutBranch: "F",
CheckoutPreviousBranch: "-",
RebaseBranch: "r",
RenameBranch: "R",
MergeIntoCurrentBranch: "M",
MoveCommitsToNewBranch: "N",
ViewGitFlowOptions: "i",
FastForward: "f",
CreateTag: "T",
PushTag: "P",
SetUpstream: "u",
FetchRemote: "f",
AddForkRemote: "F",
SortOrder: "s",
CopyPullRequestURL: "<c-y>",
CreatePullRequest: "o",
ViewPullRequestOptions: "O",
OpenPullRequestInBrowser: "G",
CheckoutBranchByName: "c",
ForceCheckoutBranch: "F",
CheckoutPreviousBranch: "-",
RebaseBranch: "r",
RenameBranch: "R",
MergeIntoCurrentBranch: "M",
MoveCommitsToNewBranch: "N",
ViewGitFlowOptions: "i",
FastForward: "f",
CreateTag: "T",
PushTag: "P",
SetUpstream: "u",
FetchRemote: "f",
AddForkRemote: "F",
SortOrder: "s",
},
Worktrees: KeybindingWorktreesConfig{
ViewWorktreeOptions: "w",
@ -1042,6 +1045,7 @@ func GetDefaultConfig() *UserConfig {
CopyCommitAttributeToClipboard: "y",
OpenLogMenu: "<c-l>",
OpenInBrowser: "o",
OpenPullRequestInBrowser: "G",
ViewBisectOptions: "b",
StartInteractiveRebase: "i",
SelectCommitsOfCurrentBranch: "*",

View file

@ -155,7 +155,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()

View file

@ -28,6 +28,7 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
return presentation.GetBranchListDisplayStrings(
viewModel.GetItems(),
c.State().GetItemOperation,
c.Model().PullRequestsMap,
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
c.Modes().Diffing.Ref,
c.Views().Branches.InnerWidth()+c.Views().Branches.OriginX(),

View file

@ -57,6 +57,7 @@ type MenuViewModel struct {
columnAlignment []utils.Alignment
allowFilteringKeybindings bool
keybindingsTakePrecedence bool
onCancel func() error
*FilteredListViewModel[*types.MenuItem]
}
@ -97,6 +98,10 @@ func (self *MenuViewModel) SetMenuItems(items []*types.MenuItem, columnAlignment
self.columnAlignment = columnAlignment
}
func (self *MenuViewModel) SetOnCancel(onCancel func() error) {
self.onCancel = onCancel
}
func (self *MenuViewModel) GetPrompt() string {
return self.prompt
}
@ -239,6 +244,9 @@ func (self *MenuContext) OnMenuPress(selectedItem *types.MenuItem) error {
self.c.Context().Pop()
if selectedItem == nil {
if self.onCancel != nil {
return self.onCancel()
}
return nil
}

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"
@ -77,6 +81,12 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Description: self.c.Tr.CreatePullRequestOptions,
OpensMenu: true,
},
{
Key: opts.GetKey(opts.Config.Branches.OpenPullRequestInBrowser),
Handler: self.withItem(self.openPRInBrowser),
GetDisabledReason: self.require(self.singleItemSelected(self.branchHasPR)),
Description: self.c.Tr.OpenPullRequestInBrowser,
},
{
Key: opts.GetKey(opts.Config.Branches.CopyPullRequestURL),
Handler: self.copyPullRequestURL,
@ -192,7 +202,19 @@ 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
if pr, ok := self.c.Model().PullRequestsMap[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 +228,67 @@ func (self *BranchesController) GetOnRenderToMain() func() {
}
}
func stateText(state string) string {
var icon, label string
switch state {
case "OPEN":
icon, label = " ", "Open"
case "CLOSED":
icon, label = " ", "Closed"
case "MERGED":
icon, label = " ", "Merged"
case "DRAFT":
icon, label = " ", "Draft"
default:
return ""
}
if icons.IsIconEnabled() {
return icon + label
}
return label
}
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(),
@ -442,16 +525,23 @@ func (self *BranchesController) handleCreatePullRequestMenu(selectedBranch *mode
return self.createPullRequestMenu(selectedBranch, checkedOutBranch)
}
func (self *BranchesController) copyPullRequestURL() error {
func (self *BranchesController) getPullRequestURL() (string, error) {
branch := self.context().GetSelected()
if pr, ok := self.c.Model().PullRequestsMap[branch.Name]; ok {
return pr.Url, nil
}
branchExistsOnRemote := self.c.Git().Remote.CheckRemoteBranchExists(branch.Name)
if !branchExistsOnRemote {
return errors.New(self.c.Tr.NoBranchOnRemote)
return "", errors.New(self.c.Tr.NoBranchOnRemote)
}
url, err := self.c.Helpers().Host.GetPullRequestURL(branch.Name, "")
return self.c.Helpers().Host.GetPullRequestURL(branch.Name, "")
}
func (self *BranchesController) copyPullRequestURL() error {
url, err := self.getPullRequestURL()
if err != nil {
return err
}
@ -853,6 +943,27 @@ func (self *BranchesController) branchIsReal(branch *models.Branch) *types.Disab
return nil
}
func (self *BranchesController) branchHasPR(branch *models.Branch) *types.DisabledReason {
if _, ok := self.c.Model().PullRequestsMap[branch.Name]; !ok {
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
}
return nil
}
func (self *BranchesController) openPRInBrowser(branch *models.Branch) error {
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
if !ok {
// Should be guarded against by the DisabledReason check, but be defensive in case
// PullRequestsMap was updated concurrently by a background refresh
return errors.New(self.c.Tr.NoPullRequestForBranch)
}
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
return self.c.OS().OpenLink(pr.Url)
}
func (self *BranchesController) branchesAreReal(selectedBranches []*models.Branch, startIdx int, endIdx int) *types.DisabledReason {
if !lo.EveryBy(selectedBranches, func(branch *models.Branch) bool {
return branch.IsRealBranch()

View file

@ -1,6 +1,7 @@
package helpers
import (
"fmt"
"strings"
"sync"
"time"
@ -9,10 +10,12 @@ 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"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
@ -27,6 +30,12 @@ type RefreshHelper struct {
mergeConflictsHelper *MergeConflictsHelper
worktreeHelper *WorktreeHelper
searchHelper *SearchHelper
// Tracks repos for which the user has dismissed the "select base GitHub remote"
// prompt, to avoid re-prompting on every subsequent refresh within the same session.
// Keyed by repo path so that switching to a different repo while lazygit is running
// still triggers the prompt there.
githubBaseRemotePromptDismissed map[string]bool
}
func NewRefreshHelper(
@ -91,6 +100,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 +127,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
}
}
branchesAndRemotesWg := sync.WaitGroup{}
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
@ -126,9 +137,17 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES)
if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" {
refresh("reflog and branches", func() { self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) })
branchesAndRemotesWg.Add(1)
refresh("reflog and branches", func() {
self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex)
branchesAndRemotesWg.Done()
})
} else {
refresh("branches", func() { self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true) })
branchesAndRemotesWg.Add(1)
refresh("branches", func() {
self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true)
branchesAndRemotesWg.Done()
})
refresh("reflog", func() { _ = self.refreshReflogCommits() })
}
} else if scopeSet.Includes(types.REBASE_COMMITS) {
@ -164,7 +183,18 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
}
if scopeSet.Includes(types.REMOTES) {
refresh("remotes", func() { _ = self.refreshRemotes() })
branchesAndRemotesWg.Add(1)
refresh("remotes", func() {
_ = self.refreshRemotes()
branchesAndRemotesWg.Done()
})
}
if scopeSet.Includes(types.PULL_REQUESTS) {
refresh("pull requests", func() {
branchesAndRemotesWg.Wait()
self.refreshGithubPullRequests()
})
}
if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches {
@ -209,6 +239,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
func getScopeNames(scopes []types.RefreshableView) []string {
scopeNameMap := map[types.RefreshableView]string{
types.COMMITS: "commits",
types.REBASE_COMMITS: "rebaseCommits",
types.BRANCHES: "branches",
types.FILES: "files",
types.SUBMODULES: "submodules",
@ -221,7 +252,10 @@ func getScopeNames(scopes []types.RefreshableView) []string {
types.STATUS: "status",
types.BISECT_INFO: "bisect",
types.STAGING: "staging",
types.PATCH_BUILDING: "patchBuilding",
types.MERGE_CONFLICTS: "mergeConflicts",
types.COMMIT_FILES: "commitFiles",
types.PULL_REQUESTS: "pullRequests",
}
return lo.Map(scopes, func(scope types.RefreshableView, _ int) string {
@ -477,6 +511,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele
prevSelectedBranch := self.c.Contexts().Branches.GetSelected()
self.c.Model().Branches = branches
self.rebuildPullRequestsMap()
if refreshWorktrees {
self.loadWorktrees()
@ -658,6 +693,13 @@ func (self *RefreshHelper) refreshRemotes() error {
self.c.Model().Remotes = remotes
hadPrs := len(self.c.Model().PullRequestsMap) != 0
self.rebuildPullRequestsMap()
if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 {
// if we didn't have PRs in the map before but now we do, we need to redraw the branches view
self.refreshView(self.c.Contexts().Branches)
}
// we need to ensure our selected remote branches aren't now outdated
if prevSelectedRemote != nil && self.c.Model().RemoteBranches != nil {
// find remote now
@ -757,3 +799,154 @@ func (self *RefreshHelper) refreshView(context types.Context) {
return nil
})
}
func (self *RefreshHelper) refreshGithubPullRequests() {
self.c.Mutexes().RefreshingPullRequestsMutex.Lock()
defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock()
if !self.c.Git().GitHub.InGithubRepo(self.c.Model().Remotes) {
self.c.Model().PullRequests = nil
self.c.Model().PullRequestsMap = nil
return
}
authToken := self.c.Git().GitHub.GetAuthToken()
if authToken == "" {
self.c.Model().PullRequests = nil
self.c.Model().PullRequestsMap = nil
return
}
baseRemote := self.getGithubBaseRemote()
if baseRemote == nil {
if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] {
self.promptForBaseGithubRepo(authToken)
}
return
}
if err := self.setGithubPullRequests(authToken, baseRemote); err != nil {
self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error()))
}
}
func (self *RefreshHelper) getGithubBaseRemote() *models.Remote {
remotes := self.c.Model().Remotes
findRemoteByName := func(name string) *models.Remote {
remote, _ := lo.Find(remotes, func(remote *models.Remote) bool {
return remote.Name == name
})
return remote
}
if configuredRemote := self.c.Git().GitHub.ConfiguredBaseRemoteName(); configuredRemote != "" {
return findRemoteByName(configuredRemote)
}
if len(remotes) == 1 {
return remotes[0]
}
for _, remoteName := range []string{"upstream", "origin"} {
if remote := findRemoteByName(remoteName); remote != nil {
return remote
}
}
return nil
}
func (self *RefreshHelper) promptForBaseGithubRepo(authToken string) {
menuItems := lo.FilterMap(self.c.Model().Remotes, func(remote *models.Remote, _ int) (*types.MenuItem, bool) {
if len(remote.Urls) == 0 {
return nil, false
}
repoName, err := self.c.Git().HostingService.GetRepoNameFromRemoteURL(remote.Urls[0])
if err != nil {
return nil, false
}
return &types.MenuItem{
LabelColumns: []string{remote.Name, style.FgCyan.Sprint(repoName)},
OnPress: func() error {
return self.c.WithWaitingStatus(self.c.Tr.FetchingPullRequests, func(gocui.Task) error {
if err := self.c.Git().GitHub.SetConfiguredBaseRemoteName(remote.Name); err != nil {
self.c.Log.Error(err)
}
if err := self.setGithubPullRequests(authToken, remote); err != nil {
self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error()))
}
return nil
})
},
}, true
})
_ = self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.SelectRemoteRepository,
Items: menuItems,
OnCancel: func() error {
if self.githubBaseRemotePromptDismissed == nil {
self.githubBaseRemotePromptDismissed = make(map[string]bool)
}
self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] = true
return nil
},
})
}
func (self *RefreshHelper) rebuildPullRequestsMap() {
self.c.Model().PullRequestsMap = git_commands.GenerateGithubPullRequestMap(
self.c.Model().PullRequests,
self.c.Model().Branches,
self.c.Model().Remotes,
)
}
func (self *RefreshHelper) setGithubPullRequests(authToken string, baseRemote *models.Remote) error {
if len(self.c.Model().Branches) == 0 {
return nil
}
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, baseRemote, authToken)
if err != nil {
return err
}
self.c.Model().PullRequests = prs
self.savePullRequestsToCache(prs)
self.rebuildPullRequestsMap()
self.c.PostRefreshUpdate(self.c.Contexts().Branches)
return nil
}
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()
}

View file

@ -54,7 +54,19 @@ 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})
scope := []types.RefreshableView{
types.COMMITS,
types.BRANCHES,
types.FILES,
types.REFLOG,
types.WORKTREES,
types.BISECT_INFO,
types.STAGING,
}
if options.RefreshPullRequests {
scope = append(scope, types.PULL_REQUESTS)
}
self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, Scope: scope, KeepBranchSelectionIndex: true})
}
localBranch, found := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool {
@ -120,8 +132,8 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
// Shows a prompt to choose between creating a new branch or checking out a detached head
func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchName string) error {
checkout := func(branchName string) error {
return self.CheckoutRef(branchName, types.CheckoutRefOptions{})
checkout := func(branchName string, refreshPullRequests bool) error {
return self.CheckoutRef(branchName, types.CheckoutRefOptions{RefreshPullRequests: refreshPullRequests})
}
// If a branch with this name already exists locally, just check it out. We
@ -130,7 +142,7 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN
if lo.ContainsBy(self.c.Model().Branches, func(branch *models.Branch) bool {
return branch.Name == localBranchName
}) {
return checkout(localBranchName)
return checkout(localBranchName, false)
}
return self.c.Menu(types.CreateMenuOptions{
@ -156,14 +168,14 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN
Mode: types.SYNC,
Scope: []types.RefreshableView{types.BRANCHES},
})
return checkout(localBranchName)
return checkout(localBranchName, true)
},
},
{
Label: self.c.Tr.CheckoutTypeDetachedHead,
Tooltip: self.c.Tr.CheckoutTypeDetachedHeadTooltip,
OnPress: func() error {
return checkout(fullBranchName)
return checkout(fullBranchName, false)
},
},
},

View file

@ -253,11 +253,38 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
Tooltip: self.c.Tr.OpenLogMenuTooltip,
OpensMenu: true,
},
{
Key: opts.GetKey(opts.Config.Commits.OpenPullRequestInBrowser),
Handler: self.openPRInBrowser,
GetDisabledReason: self.checkedOutBranchHasPR,
Description: self.c.Tr.OpenPullRequestInBrowser,
},
}
return bindings
}
func (self *LocalCommitsController) checkedOutBranchHasPR() *types.DisabledReason {
branch := self.c.Model().CheckedOutBranch
if _, ok := self.c.Model().PullRequestsMap[branch]; !ok {
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
}
return nil
}
func (self *LocalCommitsController) openPRInBrowser() error {
pr, ok := self.c.Model().PullRequestsMap[self.c.Model().CheckedOutBranch]
if !ok {
// Should be guarded against by the DisabledReason check, but be defensive in case
// PullRequestsMap was updated concurrently by a background refresh
return errors.New(self.c.Tr.NoPullRequestForBranch)
}
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
return self.c.OS().OpenLink(pr.Url)
}
func (self *LocalCommitsController) GetOnRenderToMain() func() {
return func() {
self.c.Helpers().Diff.WithDiffModeCheck(func() {

View file

@ -78,8 +78,7 @@ func (self *MenuController) close() error {
return nil
}
self.c.Context().Pop()
return nil
return self.context().OnMenuPress(nil)
}
func (self *MenuController) context() *context.MenuContext {

View file

@ -489,6 +489,8 @@ func (gui *Gui) onUserConfigLoaded() error {
icons.SetNerdFontsVersion(userConfig.Gui.NerdFontsVersion)
} else if userConfig.Gui.ShowIcons {
icons.SetNerdFontsVersion("2")
} else {
icons.SetNerdFontsVersion("")
}
if len(userConfig.Gui.BranchColorPatterns) > 0 {
@ -599,6 +601,8 @@ 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: gui.loadCachedPullRequests(),
PullRequestsMap: make(map[string]*models.GithubPullRequest),
},
Modes: &types.Modes{
Filtering: filtering.New(startArgs.FilterPath, ""),
@ -619,6 +623,24 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
return initialContext(contextTree, startArgs)
}
func (gui *Gui) loadCachedPullRequests() []*models.GithubPullRequest {
repoPath := gui.git.RepoPaths.RepoPath()
cachedPRs := gui.c.GetAppState().GithubPullRequests[repoPath]
return 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,
},
}
})
}
func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager {
manager, ok := gui.viewBufferManagerMap[view.Name()]
if !ok {

View file

@ -16,6 +16,9 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
opts.Items = append(opts.Items, &types.MenuItem{
LabelColumns: []string{gui.c.Tr.Cancel},
OnPress: func() error {
if opts.OnCancel != nil {
return opts.OnCancel()
}
return nil
},
})
@ -59,6 +62,7 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
gui.State.Contexts.Menu.SetPrompt(opts.Prompt)
gui.State.Contexts.Menu.SetAllowFilteringKeybindings(opts.AllowFilteringKeybindings)
gui.State.Contexts.Menu.SetKeybindingsTakePrecedence(!opts.KeepConflictingKeybindings)
gui.State.Contexts.Menu.SetOnCancel(opts.OnCancel)
gui.State.Contexts.Menu.SetSelection(0)
gui.Views.Menu.SetOriginY(0)

View file

@ -28,6 +28,7 @@ var colorPatterns *colorMatcher
func GetBranchListDisplayStrings(
branches []*models.Branch,
getItemOperation func(item types.HasUrn) types.ItemOperation,
prs map[string]*models.GithubPullRequest,
fullDescription bool,
diffName string,
viewWidth int,
@ -37,7 +38,7 @@ func GetBranchListDisplayStrings(
) [][]string {
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 +53,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
@ -63,12 +65,13 @@ func getBranchDisplayStrings(
if len(divergence) > 0 {
availableWidth -= utils.StringWidth(divergence) + 1
}
if icons.IsIconEnabled() {
availableWidth -= 2 // one for the icon, one for the space
}
if showCommitHash {
availableWidth -= utils.COMMIT_HASH_SHORT_SIZE + 1
}
if len(prs) > 0 {
// if we have PRs then we assume that at least one branch in the list has one
availableWidth -= 2
}
paddingNeededForDivergence := availableWidth
displayName := b.Name
@ -136,9 +139,18 @@ 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)))
var coloredPrIcon string
pr, hasPr := prs[b.Name]
if hasPr {
var prIcon string
if icons.IsIconEnabled() {
prIcon = icons.IconForRemoteUrl(pr.Url)
} else {
prIcon = "●"
}
coloredPrIcon = prColor(pr.State).Sprint(prIcon)
}
res = append(res, coloredPrIcon)
if showCommitHash {
res = append(res, utils.ShortHash(b.CommitHash))
@ -258,3 +270,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
}
}

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 other-worktree)"},
expected: []string{"1m", "", "branch_name (worktree other-worktree)"},
},
{
branch: &models.Branch{Name: "branch_name", Recency: "1m"},
@ -72,7 +72,7 @@ func Test_getBranchDisplayStrings(t *testing.T) {
useIcons: true,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "󰘬", "branch_name (󰌹 other-worktree)"},
expected: []string{"1m", "", "branch_name (󰌹 other-worktree)"},
},
{
branch: &models.Branch{
@ -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 other-worktree) ↓5↑3"},
expected: []string{"1m", "", "branch_name (worktree other-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", "", "12345678", "branch_name ✓", "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,17 +213,17 @@ 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"},
itemOperation: types.ItemOperationNone,
fullDescription: false,
viewWidth: 14,
viewWidth: 12,
useIcons: true,
checkedOutByWorktree: true,
showDivergenceCfg: "none",
expected: []string{"1m", "󰘬", "branc… 󰌹"},
expected: []string{"1m", "", "branc… 󰌹"},
},
{
branch: &models.Branch{
@ -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", "", "12345678", "bran… ✓", "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)
})
}

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
}

View file

@ -154,6 +154,7 @@ type CreateMenuOptions struct {
Prompt string // a message that will be displayed above the menu options
Items []*MenuItem
HideCancel bool
OnCancel func() error // called when the menu is dismissed without selecting an item
ColumnAlignment []utils.Alignment
AllowFilteringKeybindings bool
KeepConflictingKeybindings bool // if true, the keybindings that match essential bindings such as confirm or return will not be removed from menu items
@ -287,15 +288,17 @@ func (self *MenuItem) ID() string {
}
type Model struct {
CommitFiles []*models.CommitFile
Files []*models.File
Submodules []*models.SubmoduleConfig
Branches []*models.Branch
Commits []*models.Commit
StashEntries []*models.StashEntry
SubCommits []*models.Commit
Remotes []*models.Remote
Worktrees []*models.Worktree
CommitFiles []*models.CommitFile
Files []*models.File
Submodules []*models.SubmoduleConfig
Branches []*models.Branch
Commits []*models.Commit
StashEntries []*models.StashEntry
SubCommits []*models.Commit
Remotes []*models.Remote
Worktrees []*models.Worktree
PullRequests []*models.GithubPullRequest
PullRequestsMap map[string]*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 +329,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

View file

@ -4,4 +4,9 @@ type CheckoutRefOptions struct {
WaitingStatus string
EnvVars []string
OnRefNotFound func(ref string) error
// Refreshing pull requests is necessary when checking out a branch that doesn't exist locally
// (e.g. checking out a remote branch), but it not needed when checking out an existing local
// branch or a detached head (e.g. a tag).
RefreshPullRequests bool
}

View file

@ -20,8 +20,9 @@ const (
PATCH_BUILDING
MERGE_CONFLICTS
COMMIT_FILES
// not actually a view. Will refactor this later
// not actually views. Will refactor this later
BISECT_INFO
PULL_REQUESTS
)
type RefreshMode int

View file

@ -282,6 +282,8 @@ type TranslationSet struct {
AllBranchesLogGraphReverse string
UnsupportedGitService string
CopyPullRequestURL string
OpenPullRequestInBrowser string
NoPullRequestForBranch string
NoBranchOnRemote string
Fetch string
FetchTooltip string
@ -609,6 +611,8 @@ type TranslationSet struct {
CyclePagersDisabledReason string
StartSearch string
StartFilter string
SelectRemoteRepository string
FetchingPullRequests string
Keybindings string
KeybindingsLegend string
KeybindingsMenuSectionLocal string
@ -1395,6 +1399,8 @@ func EnglishTranslationSet() *TranslationSet {
UnsupportedGitService: `Unsupported git service`,
CreatePullRequest: `Create pull request`,
CopyPullRequestURL: `Copy pull request URL to clipboard`,
OpenPullRequestInBrowser: `Open pull request in browser`,
NoPullRequestForBranch: `No pull request found for this branch`,
NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`,
Fetch: `Fetch`,
FetchTooltip: "Fetch changes from remote.",
@ -1730,6 +1736,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",
FetchingPullRequests: "Fetching pull requests",
KeybindingsLegend: "Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b",
RenameBranch: "Rename branch",
BranchUpstreamOptionsTitle: "Upstream options",

View file

@ -861,6 +861,10 @@
"type": "string",
"default": "O"
},
"openPullRequestInBrowser": {
"type": "string",
"default": "G"
},
"copyPullRequestURL": {
"type": "string",
"default": "\u003cc-y\u003e"
@ -1043,6 +1047,10 @@
"type": "string",
"default": "o"
},
"openPullRequestInBrowser": {
"type": "string",
"default": "G"
},
"viewBisectOptions": {
"type": "string",
"default": "b"

21
vendor/github.com/cli/go-gh/v2/LICENSE generated vendored Normal file
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.

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
}

View file

@ -0,0 +1,210 @@
// 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 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)
}
// SetModified marks the map as modified.
//
// 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
}
}
// SetUnmodified traverses the map using BFS to set all nodes as unmodified.
func (m *Map) SetUnmodified() {
i := 0
queue := []*yaml.Node{m.Node}
for i < len(queue) {
q := queue[i]
i = i + 1
if q.Kind != yaml.MappingNode {
continue
}
q.Value = ""
queue = append(queue, q.Content...)
}
}
// IsModified traverses the map using BFS to search for any nodes that have been modified.
func (m *Map) IsModified() bool {
i := 0
queue := []*yaml.Node{m.Node}
for i < len(queue) {
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)
}

203
vendor/github.com/cli/go-gh/v2/pkg/auth/auth.go generated vendored Normal file
View file

@ -0,0 +1,203 @@
// Package auth is a set of functions for retrieving authentication tokens
// and authenticated hosts.
package auth
import (
"fmt"
"os"
"os/exec"
"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"
tenancyHost = "ghe.com" // TenancyHost is the domain suffix of a tenancy GitHub instance.
)
// 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) {
normalizedHost := NormalizeHostname(host)
// This code is currently the exact opposite of IsEnterprise. However, we have chosen
// to write it separately, directly in line, because it is much clearer in the exact
// scenarios that we expect to use GH_TOKEN and GITHUB_TOKEN.
if normalizedHost == github || IsTenancy(normalizedHost) || normalizedHost == localhost {
if token := os.Getenv(ghToken); token != "" {
return token, ghToken
}
if token := os.Getenv(githubToken); token != "" {
return token, githubToken
}
} else {
if token := os.Getenv(ghEnterpriseToken); token != "" {
return token, ghEnterpriseToken
}
if token := os.Getenv(githubEnterpriseToken); token != "" {
return token, githubEnterpriseToken
}
}
// If config is nil, something has failed much earlier and it's probably
// more correct to panic because we don't expect to support anything
// where the config isn't available, but that would be a breaking change,
// so it's worth thinking about carefully, if we wanted to rework this.
if cfg == nil {
return "", defaultSource
}
token, err := cfg.Get([]string{hostsKey, normalizedHost, oauthToken})
if err != nil {
return "", defaultSource
}
return token, oauthToken
}
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
}
// IsEnterprise determines if a provided host is a GitHub Enterprise Server instance,
// rather than GitHub.com, a tenancy GitHub instance, or github.localhost.
func IsEnterprise(host string) bool {
// Note that if you are making changes here, you should also consider making the equivalent
// in tokenForHost, which is the exact opposite of this function.
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)
}
// NormalizeHostname ensures the host matches the values used throughout
// the rest of the codebase with respect to hostnames. These are github,
// localhost, and 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
}

344
vendor/github.com/cli/go-gh/v2/pkg/config/config.go generated vendored Normal file
View file

@ -0,0 +1,344 @@
// 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))
}
// ConfigDir returns the path to the configuration directory.
//
// 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
}
// StateDir returns the path to the state directory.
//
// 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
}
// DataDir returns the path to the data directory.
//
// 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
}
// CacheDir returns the path to the cache directory.
//
// 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
vendor/github.com/cli/go-gh/v2/pkg/config/errors.go generated vendored Normal file
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
}
// Error allows InvalidConfigFileError to satisfy error interface.
func (e *InvalidConfigFileError) Error() string {
return fmt.Sprintf("invalid config file %s: %s", e.Path, e.Err)
}
// Unwrap allows 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
}
// Error allows KeyNotFoundError to satisfy error interface.
func (e *KeyNotFoundError) Error() string {
return fmt.Sprintf("could not find key %q", e.Key)
}

25
vendor/github.com/cli/safeexec/LICENSE generated vendored Normal file
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.

48
vendor/github.com/cli/safeexec/README.md generated vendored Normal file
View file

@ -0,0 +1,48 @@
# safeexec
A Go module that provides a stabler alternative to `exec.LookPath()` that:
- Avoids a Windows security risk of executing commands found in the current directory; and
- Allows executing commands found in PATH, even if they come from relative PATH entries.
This is an alternative to [`golang.org/x/sys/execabs`](https://pkg.go.dev/golang.org/x/sys/execabs).
## Usage
```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()
}
```
## Background
### Windows security vulnerability with Go <= 1.18
Go 1.18 (and older) standard library has a security vulnerability when executing programs:
```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()
}
```
For historic reasons, Go used to implicitly [include the current directory](https://github.com/golang/go/issues/38736) in the PATH resolution on Windows. The `safeexec` package avoids searching the current directory on Windows.
### Relative PATH entries with Go 1.19+
Go 1.19 (and newer) standard library [throws an error](https://github.com/golang/go/issues/43724) if `exec.LookPath("git")` resolved to an executable relative to the current directory. This can happen on other platforms if the PATH environment variable contains relative entries, e.g. `PATH=./bin:$PATH`. The `safeexec` package allows respecting relative PATH entries as it assumes that the responsibility for keeping PATH safe lies outside of the Go program.
## 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.

17
vendor/github.com/cli/safeexec/lookpath.go generated vendored Normal file
View file

@ -0,0 +1,17 @@
//go:build !windows && go1.19
// +build !windows,go1.19
package safeexec
import (
"errors"
"os/exec"
)
func LookPath(file string) (string, error) {
path, err := exec.LookPath(file)
if errors.Is(err, exec.ErrDot) {
return path, nil
}
return path, err
}

10
vendor/github.com/cli/safeexec/lookpath_1.18.go generated vendored Normal file
View file

@ -0,0 +1,10 @@
//go:build !windows && !go1.19
// +build !windows,!go1.19
package safeexec
import "os/exec"
func LookPath(file string) (string, error) {
return exec.LookPath(file)
}

120
vendor/github.com/cli/safeexec/lookpath_windows.go generated vendored Normal file
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}
}

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)))
}
}

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

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

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

View file

@ -1,7 +1,5 @@
package terminfo
//go:generate go run gen.go
// BoolCapName returns the bool capability name.
func BoolCapName(i int) string {
return boolCapNames[2*i]

File diff suppressed because it is too large Load diff

View file

@ -70,14 +70,12 @@ func ColorLevelFromEnv() (ColorLevel, error) {
}
return ColorLevelHundreds, nil
}
// otherwise determine from TERM's max_colors capability
if term := os.Getenv("TERM"); term != "" {
ti, err := Load(term)
if err != nil {
return ColorLevelNone, err
}
v, ok := ti.Nums[MaxColors]
switch {
case !ok || v <= 16:
@ -86,6 +84,5 @@ func ColorLevelFromEnv() (ColorLevel, error) {
return ColorLevelHundreds, nil
}
}
return ColorLevelBasic, nil
}

View file

@ -7,12 +7,11 @@ import (
const (
// maxFileLength is the max file length.
maxFileLength = 4096
// magic is the file magic for terminfo files.
magic = 0432
// magicExtended is the file magic for terminfo files with the extended number format.
magicExtended = 01036
magic = 0o432
// magicExtended is the file magic for terminfo files with the extended
// number format.
magicExtended = 0o1036
)
// header fields.
@ -99,12 +98,12 @@ func readStrings(idx []int, buf []byte, n int) (map[int][]byte, int, error) {
type decoder struct {
buf []byte
pos int
len int
n int
}
// readBytes reads the next n bytes of buf, incrementing pos by n.
func (d *decoder) readBytes(n int) ([]byte, error) {
if d.len < d.pos+n {
if d.n < d.pos+n {
return nil, ErrUnexpectedFileEnd
}
n, d.pos = d.pos, d.pos+n
@ -115,15 +114,12 @@ func (d *decoder) readBytes(n int) ([]byte, error) {
func (d *decoder) readInts(n, w int) ([]int, error) {
w /= 8
l := n * w
buf, err := d.readBytes(l)
if err != nil {
return nil, err
}
// align
d.pos += d.pos % 2
z := make([]int, n)
for i, j := 0, 0; i < l; i, j = i+w, j+1 {
switch w {
@ -135,7 +131,6 @@ func (d *decoder) readInts(n, w int) ([]int, error) {
z[j] = int(buf[i+3])<<24 | int(buf[i+2])<<16 | int(buf[i+1])<<8 | int(buf[i])
}
}
return z, nil
}
@ -145,7 +140,6 @@ func (d *decoder) readBools(n int) (map[int]bool, map[int]bool, error) {
if err != nil {
return nil, nil, err
}
// process
bools, boolsM := make(map[int]bool), make(map[int]bool)
for i, b := range buf {
@ -154,7 +148,6 @@ func (d *decoder) readBools(n int) (map[int]bool, map[int]bool, error) {
boolsM[i] = true
}
}
return bools, boolsM, nil
}
@ -164,7 +157,6 @@ func (d *decoder) readNums(n, w int) (map[int]int, map[int]bool, error) {
if err != nil {
return nil, nil, err
}
// process
nums, numsM := make(map[int]int), make(map[int]bool)
for i := 0; i < n; i++ {
@ -173,7 +165,6 @@ func (d *decoder) readNums(n, w int) (map[int]int, map[int]bool, error) {
numsM[i] = true
}
}
return nums, numsM, nil
}
@ -184,16 +175,13 @@ func (d *decoder) readStringTable(n, sz int) ([][]byte, []int, error) {
if err != nil {
return nil, nil, err
}
// read string data table
data, err := d.readBytes(sz)
if err != nil {
return nil, nil, err
}
// align
d.pos += d.pos % 2
// process
s := make([][]byte, n)
var m []int
@ -209,7 +197,6 @@ func (d *decoder) readStringTable(n, sz int) ([][]byte, []int, error) {
}
}
}
return s, m, nil
}
@ -220,7 +207,6 @@ func (d *decoder) readStrings(n, sz int) (map[int][]byte, map[int]bool, error) {
if err != nil {
return nil, nil, err
}
strs := make(map[int][]byte)
for k, v := range s {
if k == AcsChars {
@ -228,39 +214,32 @@ func (d *decoder) readStrings(n, sz int) (map[int][]byte, map[int]bool, error) {
}
strs[k] = v
}
strsM := make(map[int]bool, len(m))
for _, k := range m {
strsM[k] = true
}
return strs, strsM, nil
}
// canonicalizeAscChars reorders chars to be unique, in order.
//
// see repair_ascc in ncurses-6.0/progs/dump_entry.c
// see repair_ascc in ncurses-6.3/progs/dump_entry.c
func canonicalizeAscChars(z []byte) []byte {
var c chars
var c []byte
enc := make(map[byte]byte, len(z)/2)
for i := 0; i < len(z); i += 2 {
if _, ok := enc[z[i]]; !ok {
a, b := z[i], z[i+1]
//log.Printf(">>> a: %d %c, b: %d %c", a, a, b, b)
// log.Printf(">>> a: %d %c, b: %d %c", a, a, b, b)
c, enc[a] = append(c, b), b
}
}
sort.Sort(c)
sort.Slice(c, func(i, j int) bool {
return c[i] < c[j]
})
r := make([]byte, 2*len(c))
for i := 0; i < len(c); i++ {
r[i*2], r[i*2+1] = c[i], enc[c[i]]
}
return r
}
type chars []byte
func (c chars) Len() int { return len(c) }
func (c chars) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
func (c chars) Less(i, j int) bool { return c[i] < c[j] }

View file

@ -23,34 +23,27 @@ func Load(name string) (*Terminfo, error) {
if name == "" {
return nil, ErrEmptyTermName
}
termCache.RLock()
ti, ok := termCache.db[name]
termCache.RUnlock()
if ok {
return ti, nil
}
var checkDirs []string
// check $TERMINFO
if dir := os.Getenv("TERMINFO"); dir != "" {
checkDirs = append(checkDirs, dir)
}
// check $HOME/.terminfo
u, err := user.Current()
if err != nil {
return nil, err
}
checkDirs = append(checkDirs, path.Join(u.HomeDir, ".terminfo"))
// check $TERMINFO_DIRS
if dirs := os.Getenv("TERMINFO_DIRS"); dirs != "" {
checkDirs = append(checkDirs, strings.Split(dirs, ":")...)
}
// check fallback directories
checkDirs = append(checkDirs, "/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo")
for _, dir := range checkDirs {
@ -61,7 +54,6 @@ func Load(name string) (*Terminfo, error) {
return ti, nil
}
}
return nil, ErrDatabaseDirectoryNotFound
}

View file

@ -13,25 +13,18 @@ import (
type parametizer struct {
// z is the string to parameterize
z []byte
// pos is the current position in s.
pos int
// nest is the current nest level.
nest int
// s is the variable stack.
s stack
// skipElse keeps the state of skipping else.
skipElse bool
// buf is the result buffer.
buf *bytes.Buffer
// params are the parameters to interpolate.
params [9]interface{}
// vars are dynamic variables.
vars [26]interface{}
}
@ -54,19 +47,15 @@ var parametizerPool = sync.Pool{
func newParametizer(z []byte) *parametizer {
p := parametizerPool.Get().(*parametizer)
p.z = z
return p
}
// reset resets the parametizer.
func (p *parametizer) reset() {
p.pos, p.nest = 0, 0
p.s.reset()
p.buf.Reset()
p.params, p.vars = [9]interface{}{}, [26]interface{}{}
parametizerPool.Put(p)
}
@ -106,13 +95,11 @@ func (p *parametizer) scanTextFn() stateFn {
p.writeFrom(ppos)
return nil
}
if ch == '%' {
p.writeFrom(ppos)
p.pos++
return p.scanCodeFn
}
p.pos++
}
}
@ -122,11 +109,9 @@ func (p *parametizer) scanCodeFn() stateFn {
if err != nil {
return nil
}
switch ch {
case '%':
p.buf.WriteByte('%')
case ':':
// this character is used to avoid interpreting "%-" and "%+" as operators.
// the next character is where the format really begins.
@ -136,71 +121,52 @@ func (p *parametizer) scanCodeFn() stateFn {
return nil
}
return p.scanFormatFn
case '#', ' ', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.':
return p.scanFormatFn
case 'o':
p.buf.WriteString(strconv.FormatInt(int64(p.s.popInt()), 8))
case 'd':
p.buf.WriteString(strconv.Itoa(p.s.popInt()))
case 'x':
p.buf.WriteString(strconv.FormatInt(int64(p.s.popInt()), 16))
case 'X':
p.buf.WriteString(strings.ToUpper(strconv.FormatInt(int64(p.s.popInt()), 16)))
case 's':
p.buf.WriteString(p.s.popString())
case 'c':
p.buf.WriteByte(p.s.popByte())
case 'p':
p.pos++
return p.pushParamFn
case 'P':
p.pos++
return p.setDsVarFn
case 'g':
p.pos++
return p.getDsVarFn
case '\'':
p.pos++
ch, err = p.peek()
if err != nil {
return nil
}
p.s.push(ch)
// skip the '\''
p.pos++
case '{':
p.pos++
return p.pushIntfn
case 'l':
p.s.push(len(p.s.popString()))
case '+':
bi, ai := p.s.popInt(), p.s.popInt()
p.s.push(ai + bi)
case '-':
bi, ai := p.s.popInt(), p.s.popInt()
p.s.push(ai - bi)
case '*':
bi, ai := p.s.popInt(), p.s.popInt()
p.s.push(ai * bi)
case '/':
bi, ai := p.s.popInt(), p.s.popInt()
if bi != 0 {
@ -208,7 +174,6 @@ func (p *parametizer) scanCodeFn() stateFn {
} else {
p.s.push(0)
}
case 'm':
bi, ai := p.s.popInt(), p.s.popInt()
if bi != 0 {
@ -216,101 +181,77 @@ func (p *parametizer) scanCodeFn() stateFn {
} else {
p.s.push(0)
}
case '&':
bi, ai := p.s.popInt(), p.s.popInt()
p.s.push(ai & bi)
case '|':
bi, ai := p.s.popInt(), p.s.popInt()
p.s.push(ai | bi)
case '^':
bi, ai := p.s.popInt(), p.s.popInt()
p.s.push(ai ^ bi)
case '=':
bi, ai := p.s.popInt(), p.s.popInt()
p.s.push(ai == bi)
case '>':
bi, ai := p.s.popInt(), p.s.popInt()
p.s.push(ai > bi)
case '<':
bi, ai := p.s.popInt(), p.s.popInt()
p.s.push(ai < bi)
case 'A':
bi, ai := p.s.popBool(), p.s.popBool()
p.s.push(ai && bi)
case 'O':
bi, ai := p.s.popBool(), p.s.popBool()
p.s.push(ai || bi)
case '!':
p.s.push(!p.s.popBool())
case '~':
p.s.push(^p.s.popInt())
case 'i':
for i := range p.params[:2] {
if n, ok := p.params[i].(int); ok {
p.params[i] = n + 1
}
}
case '?', ';':
case 't':
return p.scanThenFn
case 'e':
p.skipElse = true
return p.skipTextFn
}
p.pos++
return p.scanTextFn
}
func (p *parametizer) scanFormatFn() stateFn {
// the character was already read, so no need to check the error.
ch, _ := p.peek()
// 6 should be the maximum length of a format string, for example "%:-9.9d".
f := []byte{'%', ch, 0, 0, 0, 0}
var err error
for {
p.pos++
ch, err = p.peek()
if err != nil {
return nil
}
f = append(f, ch)
switch ch {
case 'o', 'd', 'x', 'X':
fmt.Fprintf(p.buf, string(f), p.s.popInt())
break
case 's':
fmt.Fprintf(p.buf, string(f), p.s.popString())
break
case 'c':
fmt.Fprintf(p.buf, string(f), p.s.popByte())
break
}
}
p.pos++
return p.scanTextFn
}
@ -319,16 +260,13 @@ func (p *parametizer) pushParamFn() stateFn {
if err != nil {
return nil
}
if ai := int(ch - '1'); ai >= 0 && ai < len(p.params) {
p.s.push(p.params[ai])
} else {
p.s.push(0)
}
// skip the '}'
p.pos++
return p.scanTextFn
}
@ -337,7 +275,6 @@ func (p *parametizer) setDsVarFn() stateFn {
if err != nil {
return nil
}
if ch >= 'A' && ch <= 'Z' {
staticVars.Lock()
staticVars.vars[int(ch-'A')] = p.s.pop()
@ -345,7 +282,6 @@ func (p *parametizer) setDsVarFn() stateFn {
} else if ch >= 'a' && ch <= 'z' {
p.vars[int(ch-'a')] = p.s.pop()
}
p.pos++
return p.scanTextFn
}
@ -355,20 +291,16 @@ func (p *parametizer) getDsVarFn() stateFn {
if err != nil {
return nil
}
var a byte
if ch >= 'A' && ch <= 'Z' {
a = 'A'
} else if ch >= 'a' && ch <= 'z' {
a = 'a'
}
staticVars.Lock()
p.s.push(staticVars.vars[int(ch-a)])
staticVars.Unlock()
p.pos++
return p.scanTextFn
}
@ -379,26 +311,21 @@ func (p *parametizer) pushIntfn() stateFn {
if err != nil {
return nil
}
p.pos++
if ch < '0' || ch > '9' {
p.s.push(ai)
return p.scanTextFn
}
ai = (ai * 10) + int(ch-'0')
}
}
func (p *parametizer) scanThenFn() stateFn {
p.pos++
if p.s.popBool() {
return p.scanTextFn
}
p.skipElse = false
return p.skipTextFn
}
@ -408,17 +335,14 @@ func (p *parametizer) skipTextFn() stateFn {
if err != nil {
return nil
}
p.pos++
if ch == '%' {
break
}
}
if p.skipElse {
return p.skipElseFn
}
return p.skipThenFn
}
@ -427,7 +351,6 @@ func (p *parametizer) skipThenFn() stateFn {
if err != nil {
return nil
}
p.pos++
switch ch {
case ';':
@ -435,16 +358,13 @@ func (p *parametizer) skipThenFn() stateFn {
return p.scanTextFn
}
p.nest--
case '?':
p.nest++
case 'e':
if p.nest == 0 {
return p.scanTextFn
}
}
return p.skipTextFn
}
@ -453,7 +373,6 @@ func (p *parametizer) skipElseFn() stateFn {
if err != nil {
return nil
}
p.pos++
switch ch {
case ';':
@ -461,11 +380,9 @@ func (p *parametizer) skipElseFn() stateFn {
return p.scanTextFn
}
p.nest--
case '?':
p.nest++
}
return p.skipTextFn
}
@ -473,13 +390,11 @@ func (p *parametizer) skipElseFn() stateFn {
func Printf(z []byte, params ...interface{}) string {
p := newParametizer(z)
defer p.reset()
// make sure we always have 9 parameters -- makes it easier
// later to skip checks and its faster
for i := 0; i < len(p.params) && i < len(params); i++ {
p.params[i] = params[i]
}
return p.exec()
}

View file

@ -1,6 +1,8 @@
// Package terminfo implements reading terminfo files in pure go.
package terminfo
//go:generate go run gen.go
import (
"io"
"io/ioutil"
@ -20,34 +22,24 @@ func (err Error) Error() string {
const (
// ErrInvalidFileSize is the invalid file size error.
ErrInvalidFileSize Error = "invalid file size"
// ErrUnexpectedFileEnd is the unexpected file end error.
ErrUnexpectedFileEnd Error = "unexpected file end"
// ErrInvalidStringTable is the invalid string table error.
ErrInvalidStringTable Error = "invalid string table"
// ErrInvalidMagic is the invalid magic error.
ErrInvalidMagic Error = "invalid magic"
// ErrInvalidHeader is the invalid header error.
ErrInvalidHeader Error = "invalid header"
// ErrInvalidNames is the invalid names error.
ErrInvalidNames Error = "invalid names"
// ErrInvalidExtendedHeader is the invalid extended header error.
ErrInvalidExtendedHeader Error = "invalid extended header"
// ErrEmptyTermName is the empty term name error.
ErrEmptyTermName Error = "empty term name"
// ErrDatabaseDirectoryNotFound is the database directory not found error.
ErrDatabaseDirectoryNotFound Error = "database directory not found"
// ErrFileNotFound is the file not found error.
ErrFileNotFound Error = "file not found"
// ErrInvalidTermProgramVersion is the invalid TERM_PROGRAM_VERSION error.
ErrInvalidTermProgramVersion Error = "invalid TERM_PROGRAM_VERSION"
)
@ -56,43 +48,30 @@ const (
type Terminfo struct {
// File is the original source file.
File string
// Names are the provided cap names.
Names []string
// Bools are the bool capabilities.
Bools map[int]bool
// BoolsM are the missing bool capabilities.
BoolsM map[int]bool
// Nums are the num capabilities.
Nums map[int]int
// NumsM are the missing num capabilities.
NumsM map[int]bool
// Strings are the string capabilities.
Strings map[int][]byte
// StringsM are the missing string capabilities.
StringsM map[int]bool
// ExtBools are the extended bool capabilities.
ExtBools map[int]bool
// ExtBoolsNames is the map of extended bool capabilities to their index.
ExtBoolNames map[int][]byte
// ExtNums are the extended num capabilities.
ExtNums map[int]int
// ExtNumsNames is the map of extended num capabilities to their index.
ExtNumNames map[int][]byte
// ExtStrings are the extended string capabilities.
ExtStrings map[int][]byte
// ExtStringsNames is the map of extended string capabilities to their index.
ExtStringNames map[int][]byte
}
@ -100,75 +79,63 @@ type Terminfo struct {
// Decode decodes the terminfo data contained in buf.
func Decode(buf []byte) (*Terminfo, error) {
var err error
// check max file length
if len(buf) >= maxFileLength {
return nil, ErrInvalidFileSize
}
d := &decoder{
buf: buf,
len: len(buf),
n: len(buf),
}
// read header
h, err := d.readInts(6, 16)
if err != nil {
return nil, err
}
var numWidth int
// check magic
if h[fieldMagic] == magic {
switch {
case h[fieldMagic] == magic:
numWidth = 16
} else if h[fieldMagic] == magicExtended {
case h[fieldMagic] == magicExtended:
numWidth = 32
} else {
default:
return nil, ErrInvalidMagic
}
// check header
if hasInvalidCaps(h) {
return nil, ErrInvalidHeader
}
// check remaining length
if d.len-d.pos < capLength(h) {
if d.n-d.pos < capLength(h) {
return nil, ErrUnexpectedFileEnd
}
// read names
names, err := d.readBytes(h[fieldNameSize])
if err != nil {
return nil, err
}
// check name is terminated properly
i := findNull(names, 0)
if i == -1 {
return nil, ErrInvalidNames
}
names = names[:i]
// read bool caps
bools, boolsM, err := d.readBools(h[fieldBoolCount])
if err != nil {
return nil, err
}
// read num caps
nums, numsM, err := d.readNums(h[fieldNumCount], numWidth)
if err != nil {
return nil, err
}
// read string caps
strs, strsM, err := d.readStrings(h[fieldStringCount], h[fieldTableSize])
if err != nil {
return nil, err
}
ti := &Terminfo{
Names: strings.Split(string(names), "|"),
Bools: bools,
@ -178,57 +145,47 @@ func Decode(buf []byte) (*Terminfo, error) {
Strings: strs,
StringsM: strsM,
}
// at the end of file, so no extended caps
if d.pos >= d.len {
if d.pos >= d.n {
return ti, nil
}
// decode extended header
eh, err := d.readInts(5, 16)
if err != nil {
return nil, err
}
// check extended offset field
if hasInvalidExtOffset(eh) {
return nil, ErrInvalidExtendedHeader
}
// check extended cap lengths
if d.len-d.pos != extCapLength(eh, numWidth) {
if d.n-d.pos != extCapLength(eh, numWidth) {
return nil, ErrInvalidExtendedHeader
}
// read extended bool caps
ti.ExtBools, _, err = d.readBools(eh[fieldExtBoolCount])
if err != nil {
return nil, err
}
// read extended num caps
ti.ExtNums, _, err = d.readNums(eh[fieldExtNumCount], numWidth)
if err != nil {
return nil, err
}
// read extended string data table indexes
extIndexes, err := d.readInts(eh[fieldExtOffsetCount], 16)
if err != nil {
return nil, err
}
// read string data table
extData, err := d.readBytes(eh[fieldExtTableSize])
if err != nil {
return nil, err
}
// precautionary check that exactly at end of file
if d.pos != d.len {
if d.pos != d.n {
return nil, ErrUnexpectedFileEnd
}
var last int
// read extended string caps
ti.ExtStrings, last, err = readStrings(extIndexes, extData, eh[fieldExtStringCount])
@ -236,28 +193,24 @@ func Decode(buf []byte) (*Terminfo, error) {
return nil, err
}
extIndexes, extData = extIndexes[eh[fieldExtStringCount]:], extData[last:]
// read extended bool names
ti.ExtBoolNames, _, err = readStrings(extIndexes, extData, eh[fieldExtBoolCount])
if err != nil {
return nil, err
}
extIndexes = extIndexes[eh[fieldExtBoolCount]:]
// read extended num names
ti.ExtNumNames, _, err = readStrings(extIndexes, extData, eh[fieldExtNumCount])
if err != nil {
return nil, err
}
extIndexes = extIndexes[eh[fieldExtNumCount]:]
// read extended string names
ti.ExtStringNames, _, err = readStrings(extIndexes, extData, eh[fieldExtStringCount])
if err != nil {
return nil, err
}
//extIndexes = extIndexes[eh[fieldExtStringCount]:]
// extIndexes = extIndexes[eh[fieldExtStringCount]:]
return ti, nil
}
@ -279,23 +232,19 @@ func Open(dir, name string) (*Terminfo, error) {
if buf == nil {
return nil, ErrFileNotFound
}
// decode
ti, err := Decode(buf)
if err != nil {
return nil, err
}
// save original file name
ti.File = filename
// add to cache
termCache.Lock()
for _, n := range ti.Names {
termCache.db[n] = ti
}
termCache.Unlock()
return ti, nil
}
@ -441,7 +390,6 @@ func (ti *Terminfo) Fprintf(w io.Writer, i int, v ...interface{}) {
// them for this terminal.
func (ti *Terminfo) Colorf(fg, bg int, str string) string {
maxColors := int(ti.Nums[MaxColors])
// map bright colors to lower versions if the color table only holds 8.
if maxColors == 8 {
if fg > 7 && fg < 16 {
@ -451,7 +399,6 @@ func (ti *Terminfo) Colorf(fg, bg int, str string) string {
bg -= 8
}
}
var s string
if maxColors > fg && fg >= 0 {
s += ti.Printf(SetAForeground, fg)
@ -480,20 +427,17 @@ func (ti *Terminfo) Goto(row, col int) string {
// most strings don't need padding, which is good news!
return io.WriteString(w, s)
}
end := strings.Index(s, ">")
if end == -1 {
// unterminated... just emit bytes unadulterated.
return io.WriteString(w, "$<"+s)
}
var c int
c, err = io.WriteString(w, s[:start])
if err != nil {
return n + c, err
}
n += c
s = s[start+2:]
val := s[:end]
s = s[end+1:]
@ -518,13 +462,11 @@ func (ti *Terminfo) Goto(row, col int) string {
break
}
}
z, pad := ((baud/8)/unit)*ms, ti.Strings[PadChar]
b := make([]byte, len(pad)*z)
for bp := copy(b, pad); bp < len(b); bp *= 2 {
copy(b[bp:], b[:bp])
}
if (!ti.Bools[XonXoff] && baud > int(ti.Nums[PaddingBaudRate])) || mandatory {
c, err = w.Write(b)
if err != nil {
@ -533,6 +475,5 @@ func (ti *Terminfo) Goto(row, col int) string {
n += c
}
}
return n, nil
}*/

21
vendor/modules.txt vendored
View file

@ -17,6 +17,15 @@ github.com/bahlo/generic-list-go
# github.com/buger/jsonparser v1.1.2
## explicit; go 1.13
github.com/buger/jsonparser
# github.com/cli/go-gh/v2 v2.13.0
## explicit; go 1.25.0
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.1
## explicit; go 1.15
github.com/cli/safeexec
# github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
## explicit
github.com/cloudfoundry/jibber_jabber
@ -119,11 +128,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
@ -179,8 +188,8 @@ github.com/stretchr/testify/assert/yaml
# github.com/wk8/go-ordered-map/v2 v2.1.8
## explicit; go 1.18
github.com/wk8/go-ordered-map/v2
# github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778
## explicit; go 1.15
# github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
## explicit; go 1.19
github.com/xo/terminfo
# golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
## explicit; go 1.20