fix(trail): reject host-like --repo forge in the bare path form · Entire

fix(trail): reject host-like --repo forge in the bare path form

4a109f1·

toothbrush·3w ago·4 files·+22 added/-4 removed

The bare forge/owner/repo form accepted parts[0] as the forge without checking it is a known short forge id, so --repo github.com/acme/app (or gitlab.com/...) slipped through and produced a malformed /api/v1/trails//... path that failed with an opaque server error.

Validate the forge segment against gitremote.IsSupportedForge and fail with a clear message pointing at a forge id ("gh") or a clone URL.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

Sessions

fb56d44a31fdView transcript

?\Investigate CLI Repo and Branch FlagsClaude Code·Opus 4.8[1m]·2 steps

Changes

4

69 unmodified lines

70
71
72
73
73
74
75
76

69 unmodified lines

// Get returns the importer registered under name.
//
//nolint:ireturn // Importer is the intended polymorphic seam returned to callers.

func Get(name string) (Importer, bool) {
    for _, imp := range importers {
        if imp.Name() == name {

Mcmd/entire/cli/agentimport/agentimport.go+1/-1

55 unmodified lines

56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

55 unmodified lines

return m
    }()

// IsSupportedForge reports whether forge is a known short forge id (e.g. "gh")
// understood by the trails API. It rejects forge hostnames ("github.com") and
// any other unrecognized value, so callers parsing a bare forge/owner/repo
// triple can fail clearly instead of forwarding a malformed forge to the API.
func IsSupportedForge(forge string) bool {
    _, ok := forgeToHost[forge]
    return ok
}

// CanonicalHost returns the canonical public host of the upstream forge.
//
// For direct git URLs this is just Host. For entire:// remotes — whose Host is

Mcmd/entire/cli/gitremote/gitremote.go+9

1721 unmodified lines

1722
1723
1724
1725
1726
1725
1726
1727
1728
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737

1721 unmodified lines

// fall through to the URL parser, which understands hosts and schemes.
    if !strings.Contains(raw, "://") && !strings.Contains(raw, "@"){ 
        parts := strings.Split(strings.Trim(raw, "/"), "/")
        if len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != "" {
            return parts[0], parts[1], strings.TrimSuffix(parts[2], ".git"), nil
        }
        if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
            return "", "", "", fmt.Errorf("invalid --repo %q: expected forge/owner/repo (e.g. gh/acme/app) or a clone URL", raw)
        }
        return "", "", "", fmt.Errorf("invalid --repo %q: expected forge/owner/repo (e.g. gh/acme/app) or a clone URL", raw)
        // parts[0] must be a short forge id ("gh"), not a hostname. A host-like
        // value (github.com/acme/app) would otherwise be forwarded verbatim and
        // the server would reject the malformed path with an opaque error.
        if !gitremote.IsSupportedForge(parts[0]) {
            return "", "", "", fmt.Errorf("invalid --repo %q: %q is not a supported forge id (use a forge id like \"gh\", or pass a clone URL such as https://github.com/%s/%s)", raw, parts[0], parts[1], parts[2])
        }
        return parts[0], parts[1], strings.TrimSuffix(parts[2], ".git"), nil
    }
    info, perr := gitremote.ParseURL(raw)
    if perr != nil {

Mcmd/entire/cli/trail_cmd.go+9/-3

28 unmodified lines

29
30
31
32
33
34
35
36
37

28 unmodified lines

{name: "forge plus owner only", raw: "gh/acme", wantErr: true},
        {name: "four segments", raw: "gh/acme/app/extra", wantErr: true},
        {name: "unsupported forge host", raw: "git@gitlab.com:acme/app.git", wantErr: true},
        {name: "bare host instead of forge id", raw: "github.com/acme/app", wantErr: true},
        {name: "bare unsupported forge host", raw: "gitlab.com/acme/app", wantErr: true},
        {name: "unknown short forge id", raw: "zz/acme/app", wantErr: true},
    }

for _, tt := range tests {