fix(git): keep the first HostName for an SSH host alias

Description

When a git remote uses an SSH host alias that is not itself a configured glab host, such as git@gl:group/project.git, glab reads the SSH configuration to find the real host name. When the configuration sets HostName for the alias more than once, glab kept the last value and ssh keeps the first, so the two could reach different hosts: a second Host block for the same alias further down ~/.ssh/config, or a Host block for it in /etc/ssh/ssh_config, decided the host for glab but not for ssh and git. glab then reported that no remote points to a known GitLab host, or sent its requests to another instance than the one git push reaches.

ssh_config(5) says "Unless noted otherwise, for each parameter, the first obtained value will be used", and ssh reads the user's file before the system-wide one (ssh_config.5 at OpenSSH 10.0p2). ParseSSHConfig already reads the files in that order, and reads an Include where it appears, but each HostName line replaced the value stored before it.

This MR stores a HostName only when the alias has no value yet. ssh applies a Match block only when its criteria hold, which glab does not evaluate, so a Match line now ends the current Host block: its HostName is ignored until the next Host line, and an Include inside it is skipped. After each included file, the parser returns to the block the Include line is in, as ssh does.

Behavior change: a configuration that sets HostName for an alias more than once now resolves to the first value, the host ssh connects to, instead of the last, and a HostName inside a Match block, directly or through an Include, no longer resolves any alias.

Closes #8681 (closed)

!3585 (merged) fixed another problem in the same function (lines longer than 64 KiB); this change touches other lines.

How has this been tested?

Test_sshParser_readFirstHostNameWins reads stubbed files with sshParser.read and compares the whole alias map: two Host gl blocks in one file, gl listed again beside another alias (Host gl gh), and gl set in both the user and the system-wide file. All three fail without the fix.

Test_sshParser_readMatchAndInclude (second commit) covers a Match block between two Host gl blocks, the same with the HostName in a file that an Include inside the Match block reads, an Include before a later Host gl block, and a Host or Match line in a file included from a Host gl block. ssh -G (OpenSSH 9.2p1 and 10.0p2) resolves gl to the expected host in each, and four of the five fail on the first commit alone.

make test-race, golangci-lint, the comment gate, the generators and the commit lint pass, and glab built from this branch sends its requests to the host ssh -G resolves; details below.

This comes from maintaining gitlab-mcp-server; its upstream-bugs.md records everything done on its dependencies and sibling projects.

Checks, end-to-end runs and test output
  • With both commits, make test-race (the tests:unit job) passes: 6728 tests, 18 skipped, none failed.
  • golangci-lint run and golangci-lint fmt --diff (v2.14.0, the version of the lint job) report nothing for the whole repository. scripts/comment-overlap reports 0 findings, make generate and make gen-docs change no file, make check-embed and make check-args pass, and scripts/commit-lint/lint.js accepts the title and both commits.
  • Inverting the new presence check makes the new test and the two existing sshParser.read tests fail. Dropping any one of the three changes of the second commit makes at least one of the new cases fail.

End to end, in a Debian 13 container with OpenSSH 10.0p2, I built glab from main, from the first commit alone and from this branch, and compared what ssh -G gl resolves with the host glab mr list sends its request to, in a repository whose remote is git@gl:group/project.git. Every host is under the .invalid TLD, so each request stopped at the DNS lookup:

  • gl set to gitlab.invalid in ~/.ssh/config and to system.invalid in /etc/ssh/ssh_config, glab configured for gitlab.invalid only: ssh -G gl gives gitlab.invalid. glab from main maps gl to system.invalid and stops with "none of the git remotes configured for this repository point to a known GitLab host"; glab from this branch requests https://gitlab.invalid/api/v4/projects/group%2Fproject/merge_requests.
  • Host gl with HostName gitlab.invalid, then Host gl gh with HostName other.invalid, in one file, glab configured for both hosts: ssh -G gl gives gitlab.invalid and ssh -G gh gives other.invalid. glab from main requests https://other.invalid/...; glab from this branch requests https://gitlab.invalid/....
  • Host gl, Match user bob with HostName special.invalid, then Host gl with HostName normal.invalid: ssh -G gl gives normal.invalid; glab from the first commit alone requests https://special.invalid/... and glab from this branch https://normal.invalid/.... The same holds with the Match block in an included file, and with HostName special.invalid in a file that an Include inside the Match block reads.

Before (main at a6fd50ce, with the new test; each failure reduced to testify's expected and actual maps):

--- FAIL: Test_sshParser_readFirstHostNameWins/second_block_for_the_same_alias_in_one_file
    expected: git.SSHAliasMap{"gl":"first.example.com"}
    actual  : git.SSHAliasMap{"gl":"second.example.com"}
--- FAIL: Test_sshParser_readFirstHostNameWins/alias_listed_again_with_another_alias
    expected: git.SSHAliasMap{"gh":"shared.example.com", "gl":"gl.example.com"}
    actual  : git.SSHAliasMap{"gh":"shared.example.com", "gl":"shared.example.com"}
--- FAIL: Test_sshParser_readFirstHostNameWins/user_config_wins_over_system_config
    expected: git.SSHAliasMap{"gl":"user.example.com", "sys":"only-system.example.com"}
    actual  : git.SSHAliasMap{"gl":"system.example.com", "sys":"only-system.example.com"}
FAIL	gitlab.com/gitlab-org/cli/internal/git	0.015s

After:

ok  	gitlab.com/gitlab-org/cli/internal/git	1.065s
ok  	gitlab.com/gitlab-org/cli/internal/glrepo	0.565s
ok  	gitlab.com/gitlab-org/cli/internal/cmdutils	2.435s
--- PASS: Test_sshParser_readFirstHostNameWins (0.00s)
    --- PASS: Test_sshParser_readFirstHostNameWins/second_block_for_the_same_alias_in_one_file (0.00s)
    --- PASS: Test_sshParser_readFirstHostNameWins/alias_listed_again_with_another_alias (0.00s)
    --- PASS: Test_sshParser_readFirstHostNameWins/user_config_wins_over_system_config (0.00s)

Before the second commit (the first commit at b5cafa6f, with Test_sshParser_readMatchAndInclude):

--- FAIL: Test_sshParser_readMatchAndInclude/Match_line_ends_the_Host_block
    expected: git.SSHAliasMap{"gl":"normal.example.com"}
    actual  : git.SSHAliasMap{"gl":"special.example.com"}
--- FAIL: Test_sshParser_readMatchAndInclude/Include_in_a_Match_block_is_skipped
    expected: git.SSHAliasMap{"gl":"normal.example.com"}
    actual  : git.SSHAliasMap{"gl":"special.example.com"}
--- PASS: Test_sshParser_readMatchAndInclude/Include_is_read_where_it_appears
--- FAIL: Test_sshParser_readMatchAndInclude/Host_line_in_an_included_file_does_not_end_the_including_block
    expected: git.SSHAliasMap{"gl":"main.example.com"}
    actual  : git.SSHAliasMap{"*.internal":"main.example.com"}
--- FAIL: Test_sshParser_readMatchAndInclude/Match_line_in_an_included_file_does_not_end_the_including_block
    expected: git.SSHAliasMap{"gl":"main.example.com"}
    actual  : git.SSHAliasMap{"gl":"special.example.com"}
FAIL	gitlab.com/gitlab-org/cli/internal/git	0.059s

After the second commit, make test-race:

DONE 6728 tests, 18 skipped in 214.658s

ssh -G on OpenSSH 10.0p2 for the five layouts, written with absolute Include paths (-l bob shows that the Match blocks apply only to that user):

$ ssh -F match -G gl | grep '^hostname '
hostname normal.example.com
$ ssh -F match -l bob -G gl | grep '^hostname '
hostname special.example.com
$ ssh -F match-inc -G gl | grep '^hostname '
hostname normal.example.com
$ ssh -F match-inc -l bob -G gl | grep '^hostname '
hostname special.example.com
$ ssh -F order -G gl | grep '^hostname '
hostname included.example.com
$ ssh -F inc-host -G gl | grep '^hostname '
hostname main.example.com
$ ssh -F inc-match -G gl | grep '^hostname '
hostname main.example.com
$ ssh -F inc-match -l bob -G gl | grep '^hostname '
hostname special.example.com

End to end, the first configuration (the glab error boxes reduced to their message):

$ ssh -G gl | grep '^hostname '
hostname gitlab.invalid
$ glab mr list            # built from main
none of the git remotes configured for this repository point to a known GitLab host. [...] Configured remotes: system.invalid.
$ glab mr list            # built from this branch
Get "https://gitlab.invalid/api/v4/projects/group%2Fproject/merge_requests?page=1&per_page=30&state=opened": dial tcp: lookup gitlab.invalid [...]: no such host.

The second configuration:

$ ssh -G gl | grep '^hostname '
hostname gitlab.invalid
$ ssh -G gh | grep '^hostname '
hostname other.invalid
$ glab mr list            # built from main
Get "https://other.invalid/api/v4/projects/group%2Fproject/merge_requests?page=1&per_page=30&state=opened": dial tcp: lookup other.invalid [...]: no such host.
$ glab mr list            # built from this branch
Get "https://gitlab.invalid/api/v4/projects/group%2Fproject/merge_requests?page=1&per_page=30&state=opened": dial tcp: lookup gitlab.invalid [...]: no such host.

Environment: go1.27.3 linux/amd64, golangci-lint 2.14.0, OpenSSH 10.0p2 (and 9.2p1 for the second commit's ssh -G checks), Node.js 25 for the commit lint.

Screenshots (if appropriate):

Not applicable: no change to the output.

Edited by José M. Requena Plens

Merge request reports

Loading
Loading