diff --git a/go/cmd/dolt/commands/remote_test.go b/go/cmd/dolt/commands/remote_test.go index af0c626f1f4..bc1c8eafc9e 100644 --- a/go/cmd/dolt/commands/remote_test.go +++ b/go/cmd/dolt/commands/remote_test.go @@ -156,7 +156,7 @@ func TestRemoteAdd_StoresNormalizedGitUrl(t *testing.T) { normalized, ok, err := env.NormalizeGitRemoteUrl(original) assert.NoError(t, err) assert.True(t, ok) - assert.Equal(t, "git+ssh://git@github.com/timsehn/dolt-in-git.git", normalized) + assert.Equal(t, "git+ssh://git@github.com/./timsehn/dolt-in-git.git", normalized) urlToStore := original if strings.HasPrefix(strings.ToLower(scheme), "git+") { diff --git a/go/libraries/doltcore/dbfactory/git_remote.go b/go/libraries/doltcore/dbfactory/git_remote.go index 1fe1e73e3fd..deafd3ff484 100644 --- a/go/libraries/doltcore/dbfactory/git_remote.go +++ b/go/libraries/doltcore/dbfactory/git_remote.go @@ -134,10 +134,11 @@ func (fact GitRemoteFactory) CreateDB(ctx context.Context, nbf *types.NomsBinFor } // Ensure the configured git remote exists and points to the underlying git remote URL. - if err := ensureGitRemoteURL(ctx, cacheRepo, remoteName, remoteURL.String()); err != nil { + gitURL := gitRemoteURLString(remoteURL) + if err := ensureGitRemoteURL(ctx, cacheRepo, remoteName, gitURL); err != nil { return nil, nil, nil, err } - if err := ensureRemoteHasBranches(ctx, cacheRepo, remoteName, remoteURL.String()); err != nil { + if err := ensureRemoteHasBranches(ctx, cacheRepo, remoteName, gitURL); err != nil { return nil, nil, nil, err } @@ -186,6 +187,23 @@ func parseGitRemoteFactoryURL(urlObj *url.URL, params map[string]interface{}) (r return &cp, ref, nil } +// gitRemoteURLString converts a parsed remote URL back to a string suitable for +// passing to git commands. If the URL has an ssh scheme and the path starts with +// "/./", it was originally an SCP-style relative path (e.g. git@host:path.git). +// We reconstruct SCP-style format so git treats the path as relative to the SSH +// user's home directory. +func gitRemoteURLString(u *url.URL) string { + if strings.ToLower(u.Scheme) == "ssh" && strings.HasPrefix(u.Path, "/./") { + // Reconstruct SCP-style: [user@]host:relativePath + host := u.Host + if u.User != nil { + host = u.User.String() + "@" + u.Host + } + return host + ":" + strings.TrimPrefix(u.Path, "/./") + } + return u.String() +} + func resolveGitRemoteRef(params map[string]interface{}) string { // Prefer an explicit remote parameter (e.g. from `--ref`). if params != nil { diff --git a/go/libraries/doltcore/dbfactory/git_remote_test.go b/go/libraries/doltcore/dbfactory/git_remote_test.go index 618dbc02738..cd7c586ce73 100644 --- a/go/libraries/doltcore/dbfactory/git_remote_test.go +++ b/go/libraries/doltcore/dbfactory/git_remote_test.go @@ -18,6 +18,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "net/url" "os" "os/exec" "path/filepath" @@ -49,6 +50,43 @@ func shortTempDir(t *testing.T) string { return dir } +func TestGitRemoteURLString(t *testing.T) { + tests := []struct { + name string + rawURL string + expected string + }{ + { + name: "ssh relative path reconstructs SCP-style", + rawURL: "ssh://git@myhost/./relative/repo.git", + expected: "git@myhost:relative/repo.git", + }, + { + name: "ssh absolute path unchanged", + rawURL: "ssh://git@myhost/abs/repo.git", + expected: "ssh://git@myhost/abs/repo.git", + }, + { + name: "https unchanged", + rawURL: "https://example.com/org/repo.git", + expected: "https://example.com/org/repo.git", + }, + { + name: "ssh no user relative path", + rawURL: "ssh://myhost/./relative/repo.git", + expected: "myhost:relative/repo.git", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.rawURL) + require.NoError(t, err) + got := gitRemoteURLString(u) + require.Equal(t, tt.expected, got) + }) + } +} + func TestGitRemoteFactory_GitFile_RequiresGitCacheRootParam(t *testing.T) { ctx := context.Background() _, _, _, err := CreateDB(ctx, types.Format_Default, "git+file:///tmp/remote.git", map[string]interface{}{}) diff --git a/go/libraries/doltcore/env/git_remote_url.go b/go/libraries/doltcore/env/git_remote_url.go index 0cb588855e0..c014e5f5cb1 100644 --- a/go/libraries/doltcore/env/git_remote_url.go +++ b/go/libraries/doltcore/env/git_remote_url.go @@ -74,7 +74,15 @@ func NormalizeGitRemoteUrl(urlArg string) (normalized string, ok bool, err error // scp-like ssh: [user@]host:path/repo.git (no scheme, no ://) if isScpLikeGitRemote(urlArg) { host, p := splitScpLike(urlArg) - ssh := "git+ssh://" + host + "/" + strings.TrimPrefix(p, "/") + var pathPart string + if strings.HasPrefix(p, "/") { + // Absolute path: git@host:/abs/repo.git → /abs/repo.git + pathPart = p + } else { + // Relative path: git@host:path.git → /./path.git + pathPart = "/./" + p + } + ssh := "git+ssh://" + host + pathPart u, err := url.Parse(ssh) if err != nil { return "", false, err diff --git a/go/libraries/doltcore/env/git_remote_url_test.go b/go/libraries/doltcore/env/git_remote_url_test.go index 122451291ab..e764d64de17 100644 --- a/go/libraries/doltcore/env/git_remote_url_test.go +++ b/go/libraries/doltcore/env/git_remote_url_test.go @@ -43,11 +43,18 @@ func TestNormalizeGitRemoteUrl(t *testing.T) { require.Equal(t, "git+https://example.com/org/repo.git", got) }) - t.Run("scp-style becomes git+ssh", func(t *testing.T) { + t.Run("scp-style relative becomes git+ssh with dot marker", func(t *testing.T) { got, ok, err := NormalizeGitRemoteUrl("git@github.com:org/repo.git") require.NoError(t, err) require.True(t, ok) - require.Equal(t, "git+ssh://git@github.com/org/repo.git", got) + require.Equal(t, "git+ssh://git@github.com/./org/repo.git", got) + }) + + t.Run("scp-style absolute becomes git+ssh without dot marker", func(t *testing.T) { + got, ok, err := NormalizeGitRemoteUrl("git@host:/abs/repo.git") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "git+ssh://git@host/abs/repo.git", got) }) t.Run("schemeless host/path defaults to git+https", func(t *testing.T) { diff --git a/integration-tests/bats/remote-cmd.bats b/integration-tests/bats/remote-cmd.bats index fd3aad4ccda..f5955eb6c95 100755 --- a/integration-tests/bats/remote-cmd.bats +++ b/integration-tests/bats/remote-cmd.bats @@ -35,7 +35,7 @@ teardown() { run dolt remote -v [ "$status" -eq 0 ] - [[ "$output" =~ origin[[:blank:]]git[+]ssh://git@github.com/org/repo[.]git ]] || false + [[ "$output" =~ origin[[:blank:]]git[+]ssh://git@github.com/[.]/org/repo[.]git ]] || false } @test "remote-cmd: stores normalized git+https url for https .git input" { diff --git a/integration-tests/bats/remotes-git.bats b/integration-tests/bats/remotes-git.bats index f95bc0e4253..8a31a4dbacd 100644 --- a/integration-tests/bats/remotes-git.bats +++ b/integration-tests/bats/remotes-git.bats @@ -536,6 +536,125 @@ seed_git_remote_branch() { [[ "$output" =~ "terminal prompts disabled" ]] || false } +@test "remotes-git: scp-style relative path is not converted to absolute (#10564)" { + # Regression test for https://github.com/dolthub/dolt/issues/10564 + # When a user adds a remote as `git@host:relative/repo.git` (SCP-style, + # relative path), the path must NOT become absolute when passed to git. + # Bug: dolt was converting this to `ssh://git@host/relative/repo.git` + # which git interprets as absolute path `/relative/repo.git`. + + install_fake_git_url_recorder + + old_path="$PATH" + export PATH="$BATS_TMPDIR/fakebin:$PATH" + export DOLT_IGNORE_LOCK_FILE=1 + + mkdir repo1 + cd repo1 + dolt init + dolt commit --allow-empty -m "init" + dolt remote add origin git@myhost:relative/repo.git + + # Trigger CreateDB → ensureGitRemoteURL → git remote add (in internal cache repo). + # The push will ultimately fail (fake host), but we only need the remote-add to fire. + run dolt push origin main + + export PATH="$old_path" + + # The fake git recorded the URL it received for `git remote add`. + [ -f "$BATS_TMPDIR/recorded_remote_urls" ] + recorded_url=$(tail -1 "$BATS_TMPDIR/recorded_remote_urls") + + # The URL passed to git must NOT convert the relative SCP path into an + # absolute SSH path. `ssh://git@myhost/relative/repo.git` is wrong because + # the leading `/` makes git send `git-upload-pack '/relative/repo.git'` to + # the server — an absolute filesystem path. + # + # Acceptable forms: + # git@myhost:relative/repo.git (SCP-style, preserves relative) + # ssh://git@myhost/./relative/repo.git (explicit relative marker) + if [[ "$recorded_url" != "git@myhost:relative/repo.git" && \ + "$recorded_url" != "ssh://git@myhost/./relative/repo.git" ]]; then + echo "BUG: Unexpected URL passed to git remote add: $recorded_url" + echo "Expected one of:" + echo " git@myhost:relative/repo.git" + echo " ssh://git@myhost/./relative/repo.git" + echo "Got: $recorded_url" + false + fi +} + +install_fake_git_url_recorder() { + # Fake git that records the URL passed to `git remote add` so tests can + # inspect whether SCP-style relative paths survive normalization intact. + # Behaves like install_fake_git_auth_failure but also writes the remote-add + # URL to a well-known file for later assertion. + mkdir -p "$BATS_TMPDIR/fakebin" + cat > "$BATS_TMPDIR/fakebin/git" <<'FAKEGIT' +#!/usr/bin/env bash +set -euo pipefail + +git_dir="" +if [[ "${1:-}" == "--git-dir" ]]; then + git_dir="${2:-}" + shift 2 +fi + +cmd="${1:-}" +shift || true + +case "$cmd" in + init) + if [[ "${1:-}" == "--bare" ]]; then + mkdir -p "$git_dir" + exit 0 + fi + ;; + remote) + sub="${1:-}"; shift || true + case "$sub" in + get-url) + shift || true # consume -- + name="${1:-}" + f="${git_dir}/remote_${name}_url" + if [[ -f "$f" ]]; then + cat "$f" + exit 0 + fi + echo "fatal: No such remote '$name'" >&2 + exit 2 + ;; + add|set-url) + shift || true # consume -- + name="${1:-}"; url="${2:-}" + mkdir -p "$git_dir" + printf "%s" "$url" > "${git_dir}/remote_${name}_url" + # Record URL for test assertions. + printf "%s\n" "$url" >> "${BATS_TMPDIR}/recorded_remote_urls" + exit 0 + ;; + esac + ;; + ls-remote) + # Return a dummy branch so ensureRemoteHasBranches succeeds, but the + # subsequent fetch will fail. That's fine — we only need the remote-add + # URL to be recorded. + echo "0000000000000000000000000000000000000000 refs/heads/main" + exit 0 + ;; + fetch) + echo "fatal: could not connect to 'myhost'" >&2 + exit 128 + ;; +esac + +echo "fatal: unknown command" >&2 +exit 128 +FAKEGIT + chmod +x "$BATS_TMPDIR/fakebin/git" + rm -f "$BATS_TMPDIR/recorded_remote_urls" +} + install_fake_git_auth_failure() { mkdir -p "$BATS_TMPDIR/fakebin" cat > "$BATS_TMPDIR/fakebin/git" <<'EOF'