Backfill GitHub submodule targets
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
ddcd3b5e284ab0b9737dfd96ef267bc3425de0f8- Parents
-
44daa8e - Tree
1dd374c
ddcd3b5
ddcd3b5e284ab0b9737dfd96ef267bc3425de0f844daa8e
1dd374c| Status | File | + | - |
|---|---|---|---|
| M |
docs/internal/code-tab.md
|
11 | 4 |
| A |
internal/repos/git/remotes.go
|
38 | 0 |
| A |
internal/repos/git/remotes_test.go
|
90 | 0 |
| M |
internal/web/handlers/repo/code_tree_rows_test.go
|
60 | 0 |
| M |
internal/web/handlers/repo/repo.go
|
8 | 6 |
| M |
internal/web/handlers/repo/submodule_links.go
|
137 | 0 |
docs/internal/code-tab.mdmodified@@ -73,10 +73,17 @@ on the rendered ref, the Code tab parses it once, matches entries by | ||
| 73 | 73 | submodule path, and links GitHub or configured shithub clone remotes to |
| 74 | 74 | the local `/{owner}/{repo}/tree/{gitlink-oid}` route when the target |
| 75 | 75 | repo has that commit. If the target repo exists locally but does not |
| 76 | -have the pinned commit object, the row links to the target repo's | |
| 77 | -default Code tab so independently-created mirrors don't produce dead | |
| 78 | -links. Unknown, external, absent, or malformed remotes stay as plain | |
| 79 | -`name @ shortsha` rows. | |
| 76 | +have the pinned commit object, and `.gitmodules` points at GitHub, the | |
| 77 | +handler performs a bounded, non-forced fetch of heads/tags from that | |
| 78 | +GitHub remote, re-checks the object, and then links to the exact | |
| 79 | +detached-commit tree when it arrived. Successful backfills update the | |
| 80 | +target repo's default-branch OID when that ref moved, then enqueue the | |
| 81 | +same code-index and size-recalc maintenance used after pushes. Diverged | |
| 82 | +local refs are never force-updated; on fetch failure or still-missing | |
| 83 | +objects, the row links to the target repo's default Code tab so | |
| 84 | +independently-created mirrors don't produce dead links. Unknown, | |
| 85 | +external, absent, or malformed remotes stay as plain `name @ shortsha` | |
| 86 | +rows. | |
| 80 | 87 | |
| 81 | 88 | The S17 ship excludes the htmx-driven "last commit per entry" column |
| 82 | 89 | that the spec describes — an extra round-trip we can add later without |
internal/repos/git/remotes.goadded@@ -0,0 +1,38 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package git | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "os/exec" | |
| 10 | + "strings" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// FetchRemoteHeadsAndTags imports public heads and tags from remoteURL into | |
| 14 | +// gitDir without forcing local refs. It is intended for mirror/backfill flows: | |
| 15 | +// if a local branch or tag has diverged, git rejects the update instead of | |
| 16 | +// overwriting local history. | |
| 17 | +func FetchRemoteHeadsAndTags(ctx context.Context, gitDir, remoteURL string) error { | |
| 18 | + if gitDir == "" { | |
| 19 | + return errors.New("git fetch: gitDir is required") | |
| 20 | + } | |
| 21 | + if strings.TrimSpace(remoteURL) == "" { | |
| 22 | + return errors.New("git fetch: remoteURL is required") | |
| 23 | + } | |
| 24 | + //nolint:gosec // G204: gitDir is RepoFS-derived at call sites; remoteURL is caller-allowlisted and passed as argv, not shell. | |
| 25 | + cmd := exec.CommandContext(ctx, "git", "-C", gitDir, | |
| 26 | + "fetch", | |
| 27 | + "--quiet", | |
| 28 | + "--no-recurse-submodules", | |
| 29 | + remoteURL, | |
| 30 | + "refs/heads/*:refs/heads/*", | |
| 31 | + "refs/tags/*:refs/tags/*", | |
| 32 | + ) | |
| 33 | + out, err := cmd.CombinedOutput() | |
| 34 | + if err != nil { | |
| 35 | + return fmt.Errorf("git fetch remote refs: %w (%s)", err, strings.TrimSpace(string(out))) | |
| 36 | + } | |
| 37 | + return nil | |
| 38 | +} | |
internal/repos/git/remotes_test.goadded@@ -0,0 +1,90 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package git_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + repogit "github.com/tenseleyFlow/shithub/internal/repos/git" | |
| 12 | +) | |
| 13 | + | |
| 14 | +func TestFetchRemoteHeadsAndTags_ImportsReachableCommit(t *testing.T) { | |
| 15 | + t.Parallel() | |
| 16 | + ctx := context.Background() | |
| 17 | + source := initBare(t) | |
| 18 | + commit, err := repogit.InitialCommit{ | |
| 19 | + GitDir: source, | |
| 20 | + AuthorName: "Alice Anderson", | |
| 21 | + AuthorEmail: "alice@example.com", | |
| 22 | + Message: "source commit", | |
| 23 | + Branch: "trunk", | |
| 24 | + When: time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC), | |
| 25 | + Files: []repogit.FileEntry{{Path: "README.md", Body: []byte("# source\n")}}, | |
| 26 | + }.Build(ctx) | |
| 27 | + if err != nil { | |
| 28 | + t.Fatalf("Build source: %v", err) | |
| 29 | + } | |
| 30 | + dst := initBare(t) | |
| 31 | + | |
| 32 | + if err := repogit.FetchRemoteHeadsAndTags(ctx, dst, source); err != nil { | |
| 33 | + t.Fatalf("FetchRemoteHeadsAndTags: %v", err) | |
| 34 | + } | |
| 35 | + exists, err := repogit.CommitExists(ctx, dst, commit) | |
| 36 | + if err != nil { | |
| 37 | + t.Fatalf("CommitExists: %v", err) | |
| 38 | + } | |
| 39 | + if !exists { | |
| 40 | + t.Fatalf("fetched repo is missing commit %s", commit) | |
| 41 | + } | |
| 42 | + out, err := gitCmd("-C", dst, "rev-parse", "refs/heads/trunk").CombinedOutput() | |
| 43 | + if err != nil { | |
| 44 | + t.Fatalf("rev-parse dst trunk: %v\n%s", err, out) | |
| 45 | + } | |
| 46 | + if got := strings.TrimSpace(string(out)); got != commit { | |
| 47 | + t.Fatalf("dst trunk = %q, want %q", got, commit) | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +func TestFetchRemoteHeadsAndTags_DoesNotForceDivergedBranch(t *testing.T) { | |
| 52 | + t.Parallel() | |
| 53 | + ctx := context.Background() | |
| 54 | + source := initBare(t) | |
| 55 | + if _, err := (repogit.InitialCommit{ | |
| 56 | + GitDir: source, | |
| 57 | + AuthorName: "Alice Anderson", | |
| 58 | + AuthorEmail: "alice@example.com", | |
| 59 | + Message: "source commit", | |
| 60 | + Branch: "trunk", | |
| 61 | + When: time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC), | |
| 62 | + Files: []repogit.FileEntry{{Path: "README.md", Body: []byte("# source\n")}}, | |
| 63 | + }).Build(ctx); err != nil { | |
| 64 | + t.Fatalf("Build source: %v", err) | |
| 65 | + } | |
| 66 | + dst := initBare(t) | |
| 67 | + dstCommit, err := repogit.InitialCommit{ | |
| 68 | + GitDir: dst, | |
| 69 | + AuthorName: "Bob Brown", | |
| 70 | + AuthorEmail: "bob@example.com", | |
| 71 | + Message: "local commit", | |
| 72 | + Branch: "trunk", | |
| 73 | + When: time.Date(2026, 5, 10, 12, 1, 0, 0, time.UTC), | |
| 74 | + Files: []repogit.FileEntry{{Path: "README.md", Body: []byte("# local\n")}}, | |
| 75 | + }.Build(ctx) | |
| 76 | + if err != nil { | |
| 77 | + t.Fatalf("Build dst: %v", err) | |
| 78 | + } | |
| 79 | + | |
| 80 | + if err := repogit.FetchRemoteHeadsAndTags(ctx, dst, source); err == nil { | |
| 81 | + t.Fatal("FetchRemoteHeadsAndTags succeeded on a diverged branch; want rejection") | |
| 82 | + } | |
| 83 | + out, err := gitCmd("-C", dst, "rev-parse", "refs/heads/trunk").CombinedOutput() | |
| 84 | + if err != nil { | |
| 85 | + t.Fatalf("rev-parse dst trunk: %v\n%s", err, out) | |
| 86 | + } | |
| 87 | + if got := strings.TrimSpace(string(out)); got != dstCommit { | |
| 88 | + t.Fatalf("dst trunk changed to %q, want original %q", got, dstCommit) | |
| 89 | + } | |
| 90 | +} | |
internal/web/handlers/repo/code_tree_rows_test.gomodified@@ -124,3 +124,63 @@ func TestSubmoduleRouteURL_UnsupportedRemotesStayPlain(t *testing.T) { | ||
| 124 | 124 | }) |
| 125 | 125 | } |
| 126 | 126 | } |
| 127 | + | |
| 128 | +func TestGitHubSubmoduleFetchURL_CanonicalizesSupportedRemotes(t *testing.T) { | |
| 129 | + t.Parallel() | |
| 130 | + | |
| 131 | + for _, tt := range []struct { | |
| 132 | + name string | |
| 133 | + remote string | |
| 134 | + want string | |
| 135 | + }{ | |
| 136 | + { | |
| 137 | + name: "scp", | |
| 138 | + remote: "git@github.com:FortranGoingOnForty/afs-as.git", | |
| 139 | + want: "https://github.com/FortranGoingOnForty/afs-as.git", | |
| 140 | + }, | |
| 141 | + { | |
| 142 | + name: "https", | |
| 143 | + remote: "https://github.com/tenseleyFlow/bencch.git", | |
| 144 | + want: "https://github.com/tenseleyFlow/bencch.git", | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + name: "ssh url", | |
| 148 | + remote: "ssh://git@github.com/FortranGoingOnForty/afs-ld.git", | |
| 149 | + want: "https://github.com/FortranGoingOnForty/afs-ld.git", | |
| 150 | + }, | |
| 151 | + } { | |
| 152 | + tt := tt | |
| 153 | + t.Run(tt.name, func(t *testing.T) { | |
| 154 | + t.Parallel() | |
| 155 | + got, ok := githubSubmoduleFetchURL(tt.remote) | |
| 156 | + if !ok { | |
| 157 | + t.Fatalf("githubSubmoduleFetchURL(%q) ok = false", tt.remote) | |
| 158 | + } | |
| 159 | + if got != tt.want { | |
| 160 | + t.Fatalf("githubSubmoduleFetchURL(%q) = %q, want %q", tt.remote, got, tt.want) | |
| 161 | + } | |
| 162 | + }) | |
| 163 | + } | |
| 164 | +} | |
| 165 | + | |
| 166 | +func TestGitHubSubmoduleFetchURL_RejectsUnsupportedRemotes(t *testing.T) { | |
| 167 | + t.Parallel() | |
| 168 | + | |
| 169 | + for _, remote := range []string{ | |
| 170 | + "https://shithub.sh/tenseleyFlow/bencch.git", | |
| 171 | + "../afs-ld.git", | |
| 172 | + "https://example.com/octo/lib.git", | |
| 173 | + "https://github.com/octo/nested/lib.git", | |
| 174 | + "https://github.com/%2F/lib.git", | |
| 175 | + "javascript:alert(1)", | |
| 176 | + "", | |
| 177 | + } { | |
| 178 | + remote := remote | |
| 179 | + t.Run(remote, func(t *testing.T) { | |
| 180 | + t.Parallel() | |
| 181 | + if got, ok := githubSubmoduleFetchURL(remote); ok || got != "" { | |
| 182 | + t.Fatalf("githubSubmoduleFetchURL(%q) = %q, %v; want empty, false", remote, got, ok) | |
| 183 | + } | |
| 184 | + }) | |
| 185 | + } | |
| 186 | +} | |
internal/web/handlers/repo/repo.gomodified@@ -17,6 +17,7 @@ import ( | ||
| 17 | 17 | "github.com/jackc/pgx/v5" |
| 18 | 18 | "github.com/jackc/pgx/v5/pgtype" |
| 19 | 19 | "github.com/jackc/pgx/v5/pgxpool" |
| 20 | + "golang.org/x/sync/singleflight" | |
| 20 | 21 | |
| 21 | 22 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 22 | 23 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
@@ -78,12 +79,13 @@ type Deps struct { | ||
| 78 | 79 | |
| 79 | 80 | // Handlers is the registered handler set. Construct via New. |
| 80 | 81 | type Handlers struct { |
| 81 | - d Deps | |
| 82 | - rq *reposdb.Queries | |
| 83 | - uq *usersdb.Queries | |
| 84 | - iq *issuesdb.Queries | |
| 85 | - pq *pullsdb.Queries | |
| 86 | - cq *checksdb.Queries | |
| 82 | + d Deps | |
| 83 | + rq *reposdb.Queries | |
| 84 | + uq *usersdb.Queries | |
| 85 | + iq *issuesdb.Queries | |
| 86 | + pq *pullsdb.Queries | |
| 87 | + cq *checksdb.Queries | |
| 88 | + submoduleBackfills singleflight.Group | |
| 87 | 89 | } |
| 88 | 90 | |
| 89 | 91 | // New constructs the handler set, validating Deps. |
internal/web/handlers/repo/submodule_links.gomodified@@ -4,11 +4,19 @@ package repo | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "context" |
| 7 | + "errors" | |
| 7 | 8 | "net/url" |
| 8 | 9 | pathpkg "path" |
| 9 | 10 | "strings" |
| 11 | + "time" | |
| 10 | 12 | |
| 13 | + "github.com/jackc/pgx/v5" | |
| 14 | + "github.com/jackc/pgx/v5/pgtype" | |
| 15 | + | |
| 16 | + orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" | |
| 11 | 17 | repogit "github.com/tenseleyFlow/shithub/internal/repos/git" |
| 18 | + reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" | |
| 19 | + "github.com/tenseleyFlow/shithub/internal/worker" | |
| 12 | 20 | ) |
| 13 | 21 | |
| 14 | 22 | type submoduleRouteConfig struct { |
@@ -53,11 +61,107 @@ func (h *Handlers) submoduleTreeURL(ctx context.Context, cc *codeContext, remote | ||
| 53 | 61 | return route.RepoURL |
| 54 | 62 | } |
| 55 | 63 | if !existsAtCommit { |
| 64 | + if fetchURL, ok := githubSubmoduleFetchURL(remoteURL); ok { | |
| 65 | + backfilled, backfillErr := h.backfillSubmoduleCommit(ctx, gitDir, fetchURL, oid) | |
| 66 | + if backfillErr != nil { | |
| 67 | + if h.d.Logger != nil { | |
| 68 | + h.d.Logger.WarnContext(ctx, "code: submodule backfill fetch", "error", backfillErr, "owner", route.Owner, "repo", route.RepoName, "oid", oid, "remote", fetchURL) | |
| 69 | + } | |
| 70 | + } else if backfilled { | |
| 71 | + h.recordSubmoduleBackfill(ctx, route.Owner, route.RepoName, gitDir) | |
| 72 | + return route.TreeURL | |
| 73 | + } | |
| 74 | + } | |
| 56 | 75 | return route.RepoURL |
| 57 | 76 | } |
| 58 | 77 | return route.TreeURL |
| 59 | 78 | } |
| 60 | 79 | |
| 80 | +func (h *Handlers) backfillSubmoduleCommit(ctx context.Context, gitDir, fetchURL, oid string) (bool, error) { | |
| 81 | + key := gitDir + "\x00" + fetchURL + "\x00" + oid | |
| 82 | + v, err, _ := h.submoduleBackfills.Do(key, func() (any, error) { | |
| 83 | + if exists, err := repogit.CommitExists(ctx, gitDir, oid); err != nil || exists { | |
| 84 | + return exists, err | |
| 85 | + } | |
| 86 | + fetchCtx, cancel := context.WithTimeout(ctx, 15*time.Second) | |
| 87 | + defer cancel() | |
| 88 | + if err := repogit.FetchRemoteHeadsAndTags(fetchCtx, gitDir, fetchURL); err != nil { | |
| 89 | + if exists, existsErr := repogit.CommitExists(ctx, gitDir, oid); existsErr == nil && exists { | |
| 90 | + return true, nil | |
| 91 | + } | |
| 92 | + return false, err | |
| 93 | + } | |
| 94 | + return repogit.CommitExists(ctx, gitDir, oid) | |
| 95 | + }) | |
| 96 | + if err != nil { | |
| 97 | + return false, err | |
| 98 | + } | |
| 99 | + return v.(bool), nil | |
| 100 | +} | |
| 101 | + | |
| 102 | +func (h *Handlers) recordSubmoduleBackfill(ctx context.Context, owner, repoName, gitDir string) { | |
| 103 | + row, ok := h.lookupSubmoduleRepoRow(ctx, owner, repoName) | |
| 104 | + if !ok { | |
| 105 | + return | |
| 106 | + } | |
| 107 | + if head, found, err := repogit.HeadOf(ctx, gitDir, row.DefaultBranch); err != nil { | |
| 108 | + if h.d.Logger != nil { | |
| 109 | + h.d.Logger.WarnContext(ctx, "code: submodule backfill default ref", "error", err, "repo_id", row.ID) | |
| 110 | + } | |
| 111 | + } else if found && (!row.DefaultBranchOid.Valid || row.DefaultBranchOid.String != head.OID) { | |
| 112 | + if err := h.rq.UpdateRepoDefaultBranchOID(ctx, h.d.Pool, reposdb.UpdateRepoDefaultBranchOIDParams{ | |
| 113 | + ID: row.ID, | |
| 114 | + DefaultBranchOid: pgtype.Text{String: head.OID, Valid: true}, | |
| 115 | + }); err != nil && h.d.Logger != nil { | |
| 116 | + h.d.Logger.WarnContext(ctx, "code: submodule backfill default oid", "error", err, "repo_id", row.ID) | |
| 117 | + } | |
| 118 | + if _, err := worker.Enqueue(ctx, h.d.Pool, worker.KindRepoIndexCode, map[string]any{"repo_id": row.ID}, worker.EnqueueOptions{}); err != nil && h.d.Logger != nil { | |
| 119 | + h.d.Logger.WarnContext(ctx, "code: submodule backfill enqueue index", "error", err, "repo_id", row.ID) | |
| 120 | + } | |
| 121 | + } | |
| 122 | + if _, err := worker.Enqueue(ctx, h.d.Pool, worker.KindRepoSizeRecalc, map[string]any{"repo_id": row.ID}, worker.EnqueueOptions{}); err != nil && h.d.Logger != nil { | |
| 123 | + h.d.Logger.WarnContext(ctx, "code: submodule backfill enqueue size", "error", err, "repo_id", row.ID) | |
| 124 | + } | |
| 125 | + _ = worker.Notify(ctx, h.d.Pool) | |
| 126 | +} | |
| 127 | + | |
| 128 | +func (h *Handlers) lookupSubmoduleRepoRow(ctx context.Context, owner, repoName string) (reposdb.Repo, bool) { | |
| 129 | + repoName = strings.ToLower(strings.TrimSpace(repoName)) | |
| 130 | + if user, err := h.uq.GetUserByUsername(ctx, h.d.Pool, owner); err == nil { | |
| 131 | + row, repoErr := h.rq.GetRepoByOwnerUserAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{ | |
| 132 | + OwnerUserID: pgtype.Int8{Int64: user.ID, Valid: true}, | |
| 133 | + Name: repoName, | |
| 134 | + }) | |
| 135 | + if repoErr == nil { | |
| 136 | + return row, true | |
| 137 | + } | |
| 138 | + if !errors.Is(repoErr, pgx.ErrNoRows) && h.d.Logger != nil { | |
| 139 | + h.d.Logger.WarnContext(ctx, "code: submodule backfill user repo lookup", "error", repoErr, "owner", owner, "repo", repoName) | |
| 140 | + } | |
| 141 | + } else if !errors.Is(err, pgx.ErrNoRows) && h.d.Logger != nil { | |
| 142 | + h.d.Logger.WarnContext(ctx, "code: submodule backfill user lookup", "error", err, "owner", owner) | |
| 143 | + } | |
| 144 | + | |
| 145 | + org, err := orgsdb.New().GetOrgBySlug(ctx, h.d.Pool, owner) | |
| 146 | + if err != nil { | |
| 147 | + if !errors.Is(err, pgx.ErrNoRows) && h.d.Logger != nil { | |
| 148 | + h.d.Logger.WarnContext(ctx, "code: submodule backfill org lookup", "error", err, "owner", owner) | |
| 149 | + } | |
| 150 | + return reposdb.Repo{}, false | |
| 151 | + } | |
| 152 | + row, err := h.rq.GetRepoByOwnerOrgAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerOrgAndNameParams{ | |
| 153 | + OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true}, | |
| 154 | + Name: repoName, | |
| 155 | + }) | |
| 156 | + if err != nil { | |
| 157 | + if !errors.Is(err, pgx.ErrNoRows) && h.d.Logger != nil { | |
| 158 | + h.d.Logger.WarnContext(ctx, "code: submodule backfill org repo lookup", "error", err, "owner", owner, "repo", repoName) | |
| 159 | + } | |
| 160 | + return reposdb.Repo{}, false | |
| 161 | + } | |
| 162 | + return row, true | |
| 163 | +} | |
| 164 | + | |
| 61 | 165 | func submoduleRouteURL(cfg submoduleRouteConfig, remoteURL, oid string) string { |
| 62 | 166 | route, ok := submoduleRouteForRemote(cfg, remoteURL, oid) |
| 63 | 167 | if !ok { |
@@ -121,6 +225,39 @@ func submoduleRepoTarget(cfg submoduleRouteConfig, remoteURL string) (owner, rep | ||
| 121 | 225 | return ownerRepoFromRemotePath(strings.TrimPrefix(u.EscapedPath(), "/")) |
| 122 | 226 | } |
| 123 | 227 | |
| 228 | +func githubSubmoduleFetchURL(remoteURL string) (string, bool) { | |
| 229 | + remoteURL = strings.TrimSpace(remoteURL) | |
| 230 | + if remoteURL == "" { | |
| 231 | + return "", false | |
| 232 | + } | |
| 233 | + var repoPath string | |
| 234 | + if host, path, ok := scpLikeRemote(remoteURL); ok { | |
| 235 | + if normalizeRemoteHost(host) != "github.com" { | |
| 236 | + return "", false | |
| 237 | + } | |
| 238 | + repoPath = path | |
| 239 | + } else { | |
| 240 | + u, err := url.Parse(remoteURL) | |
| 241 | + if err != nil || u.Scheme == "" { | |
| 242 | + return "", false | |
| 243 | + } | |
| 244 | + switch strings.ToLower(u.Scheme) { | |
| 245 | + case "http", "https", "ssh", "git": | |
| 246 | + default: | |
| 247 | + return "", false | |
| 248 | + } | |
| 249 | + if normalizeRemoteHost(u.Hostname()) != "github.com" { | |
| 250 | + return "", false | |
| 251 | + } | |
| 252 | + repoPath = strings.TrimPrefix(u.EscapedPath(), "/") | |
| 253 | + } | |
| 254 | + owner, repoName, ok := ownerRepoFromRemotePath(repoPath) | |
| 255 | + if !ok { | |
| 256 | + return "", false | |
| 257 | + } | |
| 258 | + return "https://github.com/" + url.PathEscape(owner) + "/" + url.PathEscape(repoName) + ".git", true | |
| 259 | +} | |
| 260 | + | |
| 124 | 261 | func submoduleRepoFromRelativeURL(cfg submoduleRouteConfig, remoteURL string) (owner, repoName string, ok bool) { |
| 125 | 262 | if pathpkg.IsAbs(remoteURL) || strings.Contains(remoteURL, "://") { |
| 126 | 263 | return "", "", false |