From fdc2afd87141a041a3885778cf9aa49a1332375a Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 24 Jul 2026 18:24:39 -0600 Subject: [PATCH] repo create: use selected knot for git The --knot option provisions a repository on the selected knot, but clone and initial push still build remotes for tangled.org. That leaves repositories created on self-hosted knots unable to complete the requested Git workflow. Route those operations through the selected knot and accept its SSH port so deployments with nonstandard ports work too. Validate the connection settings before provisioning to avoid leaving behind a repository that cannot be reached as requested. --- internal/app/git.go | 16 +++++- internal/app/repos.go | 29 +++++++++- internal/app/repos_create_test.go | 80 +++++++++++++++++++++++++++ internal/app/service_test.go | 36 +++++++++--- internal/cli/repo_create.go | 4 +- internal/cli/root_test.go | 20 +++++++ internal/gitutil/clone_repo.go | 10 ++-- internal/gitutil/push_repo.go | 4 +- internal/gitutil/repo_context.go | 20 ++++++- internal/gitutil/repo_context_test.go | 25 +++++++++ 10 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 internal/app/repos_create_test.go diff --git a/internal/app/git.go b/internal/app/git.go index 8afe825..0400fb7 100644 --- a/internal/app/git.go +++ b/internal/app/git.go @@ -9,6 +9,8 @@ import ( // CloneRepoInput configures a repository clone. type CloneRepoInput struct { + KnotHost string + SSHPort int Handle string Repo string Destination string @@ -16,10 +18,18 @@ type CloneRepoInput struct { // CloneRepo clones a Tangled repository into Destination. func (s *Service) CloneRepo(ctx context.Context, in CloneRepoInput) (*RepoCloneResult, error) { + if in.SSHPort == 0 { + in.SSHPort = 22 + } + if in.SSHPort < 1 || in.SSHPort > 65535 { + return nil, fmt.Errorf("SSH port must be between 1 and 65535") + } if err := s.git.CloneRepo(ctx, gitutil.CloneRepoParams{ - Handle: in.Handle, - Repo: in.Repo, - RepoDir: in.Destination, + KnotHost: in.KnotHost, + SSHPort: in.SSHPort, + Handle: in.Handle, + Repo: in.Repo, + RepoDir: in.Destination, }); err != nil { return nil, err } diff --git a/internal/app/repos.go b/internal/app/repos.go index b8c414b..8d70d26 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -13,6 +13,7 @@ import ( "github.com/alyraffauf/tg/internal/tangledlex" "github.com/alyraffauf/tg/knot" "github.com/alyraffauf/tg/tangled" + "github.com/bluesky-social/indigo/atproto/syntax" ) // ViewRepo fetches a single repository record. @@ -113,6 +114,7 @@ type ProvisionRepoInput struct { // CreateRepoInput configures provisioning and optional local setup. type CreateRepoInput struct { KnotHost string + SSHPort int Name string Description string Clone bool @@ -122,6 +124,14 @@ type CreateRepoInput struct { // CreateRepo provisions a repository and performs requested local Git setup. func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCreateResult, error) { + if (in.Clone || in.PushPath != "") && (in.SSHPort < 1 || in.SSHPort > 65535) { + return nil, fmt.Errorf("SSH port must be between 1 and 65535") + } + knotHost, err := parseKnotHostname(in.KnotHost) + if err != nil { + return nil, err + } + in.KnotHost = knotHost uri, handle, err := s.provisionRepo(ctx, ProvisionRepoInput{ KnotHost: in.KnotHost, Name: in.Name, Description: in.Description, }) @@ -130,7 +140,10 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea } result := &RepoCreateResult{Handle: handle, Name: in.Name, URI: uri, Knot: in.KnotHost} if in.Clone { - if _, err := s.CloneRepo(ctx, CloneRepoInput{Handle: handle, Repo: in.Name, Destination: in.Name}); err != nil { + if _, err := s.CloneRepo(ctx, CloneRepoInput{ + KnotHost: in.KnotHost, SSHPort: in.SSHPort, + Handle: handle, Repo: in.Name, Destination: in.Name, + }); err != nil { return nil, fmt.Errorf("clone new repository: %w", err) } result.Cloned = true @@ -139,7 +152,7 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea return result, nil } pushResult, err := s.pushNewRepo(ctx, PushNewRepoInput{ - KnotHost: in.KnotHost, RepoURI: uri, Dir: in.PushPath, + KnotHost: in.KnotHost, SSHPort: in.SSHPort, RepoURI: uri, Dir: in.PushPath, Handle: handle, Repo: in.Name, RemoteName: in.RemoteName, }) if pushResult.defaultBranchWarning != nil { @@ -190,6 +203,14 @@ func (s *Service) provisionRepo(ctx context.Context, in ProvisionRepoInput) (uri return uri, s.ownerHandle(ctx, did), nil } +func parseKnotHostname(raw string) (string, error) { + hostname, err := syntax.ParseHandle(raw) + if err != nil { + return "", fmt.Errorf("invalid Knot hostname %q: %w", raw, err) + } + return hostname.Normalize().String(), nil +} + func (s *Service) ownerHandle(ctx context.Context, did string) string { if ident, err := s.resolver.ResolveDID(ctx, did); err == nil { return ident.Handle.String() @@ -240,7 +261,8 @@ func (s *Service) pushNewRepo(ctx context.Context, in PushNewRepoInput) (pushNew branch, defaultBranchErr := s.setDefaultBranchFromDir(ctx, in.KnotHost, in.RepoURI, in.Dir) result := pushNewRepoResult{defaultBranch: branch, defaultBranchWarning: defaultBranchErr} if err := s.git.PushNewRepo(ctx, gitutil.PushNewRepoParams{ - Dir: in.Dir, Handle: in.Handle, Repo: in.Repo, RemoteName: in.RemoteName, + Dir: in.Dir, KnotHost: in.KnotHost, SSHPort: in.SSHPort, + Handle: in.Handle, Repo: in.Repo, RemoteName: in.RemoteName, }); err != nil { return result, fmt.Errorf("push to new repository: %w", err) } @@ -250,6 +272,7 @@ func (s *Service) pushNewRepo(ctx context.Context, in PushNewRepoInput) (pushNew // PushNewRepoInput configures pushing a newly created repository. type PushNewRepoInput struct { KnotHost string + SSHPort int RepoURI string Dir string Handle string diff --git a/internal/app/repos_create_test.go b/internal/app/repos_create_test.go new file mode 100644 index 0000000..cdd553b --- /dev/null +++ b/internal/app/repos_create_test.go @@ -0,0 +1,80 @@ +package app + +import ( + "context" + "strings" + "testing" +) + +func TestCreateRepoValidatesConnectionSettingsBeforeProvisioning(t *testing.T) { + tests := []struct { + name string + knotHost string + sshPort int + pushPath string + clone bool + wantHost string + wantErr string + wantClones int + wantCreates int + wantPuts int + }{ + {name: "malformed hostname", knotHost: "https://knot.example/path", wantErr: "invalid Knot hostname"}, + {name: "zero push port", knotHost: "knot.example", pushPath: ".", wantErr: "SSH port"}, + {name: "negative push port", knotHost: "knot.example", sshPort: -1, pushPath: ".", wantErr: "SSH port"}, + {name: "push port above maximum", knotHost: "knot.example", sshPort: 65536, pushPath: ".", wantErr: "SSH port"}, + {name: "zero clone port", knotHost: "knot.example", clone: true, wantErr: "SSH port"}, + {name: "valid custom port", knotHost: "knot.example", sshPort: 2222, pushPath: ".", wantHost: "knot.example", wantCreates: 1, wantPuts: 1}, + {name: "clone from custom knot and port", knotHost: "knot.example", sshPort: 2222, clone: true, wantHost: "knot.example", wantClones: 1, wantCreates: 1, wantPuts: 1}, + {name: "hostname is canonicalized", knotHost: "KNOT.EXAMPLE", wantHost: "knot.example", wantCreates: 1, wantPuts: 1}, + {name: "unused invalid port", knotHost: "knot.example", wantHost: "knot.example", wantCreates: 1, wantPuts: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pds := &testPDS{} + knotClient := &testKnot{} + git := &testGit{} + service := testService(pds, git, knotClient) + + result, err := service.CreateRepo(context.Background(), CreateRepoInput{ + KnotHost: tt.knotHost, SSHPort: tt.sshPort, Name: "example", Clone: tt.clone, PushPath: tt.pushPath, + }) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("CreateRepo() error = %v, want containing %q", err, tt.wantErr) + } + } else { + if err != nil { + t.Fatalf("CreateRepo() error = %v", err) + } + if result.Knot != tt.wantHost { + t.Fatalf("CreateRepo() Knot = %q, want %q", result.Knot, tt.wantHost) + } + } + if knotClient.createCalls != tt.wantCreates || len(pds.puts) != tt.wantPuts { + t.Fatalf("create/put calls = %d/%d, want %d/%d", knotClient.createCalls, len(pds.puts), tt.wantCreates, tt.wantPuts) + } + if len(git.clones) != tt.wantClones { + t.Fatalf("clone calls = %+v, want %d", git.clones, tt.wantClones) + } + if tt.wantClones == 1 && (git.clones[0].KnotHost != tt.wantHost || git.clones[0].SSHPort != tt.sshPort) { + t.Fatalf("clone destination = %+v, want %s:%d", git.clones[0], tt.wantHost, tt.sshPort) + } + if tt.wantErr != "" && (pds.serviceAuthCalls != 0 || len(git.pushes) != 0) { + t.Fatalf("service auth/push calls = %d/%d, want no side effects", pds.serviceAuthCalls, len(git.pushes)) + } + if tt.wantErr == "" { + wantAudience := "did:web:" + tt.wantHost + if len(pds.serviceAuthAudiences) == 0 { + t.Fatal("service auth was not requested") + } + for _, audience := range pds.serviceAuthAudiences { + if audience != wantAudience { + t.Fatalf("service auth audience = %q, want %q", audience, wantAudience) + } + } + } + }) + } +} diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 060a01a..7fbc4ce 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -42,7 +42,7 @@ func TestCreateRepoRecordsDefaultBranchOutcome(t *testing.T) { service := testService(pds, git, knotClient) result, err := service.CreateRepo(context.Background(), CreateRepoInput{ - KnotHost: "knot.example", Name: "example", PushPath: ".", RemoteName: "origin", + KnotHost: "knot.example", SSHPort: 2222, Name: "example", PushPath: ".", RemoteName: "origin", }) if err != nil { t.Fatalf("CreateRepo() error = %v", err) @@ -59,6 +59,9 @@ func TestCreateRepoRecordsDefaultBranchOutcome(t *testing.T) { if len(git.pushes) != 1 { t.Fatalf("git pushes = %+v", git.pushes) } + if git.pushes[0].KnotHost != "knot.example" || git.pushes[0].SSHPort != 2222 { + t.Fatalf("git push destination = %+v", git.pushes[0]) + } }) } } @@ -214,7 +217,7 @@ func testService(pds *testPDS, git *testGit, knotClient *testKnot) *Service { resolver: resolver, sessions: testSessions{pds: pds}, git: git, - knot: testKnotFactory{client: knotClient}, + knot: &testKnotFactory{client: knotClient}, } } @@ -259,10 +262,12 @@ func (s testSessions) APIClient(context.Context) (*atclient.APIClient, error) { } type testPDS struct { - puts []atproto.PutRecordInput - deletes []atproto.DeleteRecordInput - record *atproto.GetRecordOutput - putErr error + puts []atproto.PutRecordInput + deletes []atproto.DeleteRecordInput + record *atproto.GetRecordOutput + putErr error + serviceAuthCalls int + serviceAuthAudiences []string } func (p *testPDS) PutRecord(_ context.Context, input atproto.PutRecordInput) (string, string, error) { @@ -295,16 +300,23 @@ func (p *testPDS) ListAllRecords(context.Context, string, string, atproto.ListRe return nil, errors.New("not implemented") } -func (p *testPDS) GetServiceAuth(context.Context, string, string) (string, error) { +func (p *testPDS) GetServiceAuth(_ context.Context, audience, _ string) (string, error) { + p.serviceAuthCalls++ + p.serviceAuthAudiences = append(p.serviceAuthAudiences, audience) return "token", nil } type testGit struct { branch string + clones []gitutil.CloneRepoParams pushes []gitutil.PushNewRepoParams } -func (g *testGit) CloneRepo(context.Context, gitutil.CloneRepoParams) error { return nil } +func (g *testGit) CloneRepo(_ context.Context, input gitutil.CloneRepoParams) error { + g.clones = append(g.clones, input) + return nil +} + func (g *testGit) PushNewRepo(_ context.Context, input gitutil.PushNewRepoParams) error { g.pushes = append(g.pushes, input) return nil @@ -323,18 +335,24 @@ func (g *testGit) DetectRepoFromCWD(context.Context) (*gitutil.RepoContext, erro type testKnotFactory struct { client knotClient + hosts []string } -func (f testKnotFactory) New(string, string) knotClient { return f.client } +func (f *testKnotFactory) New(host string, _ string) knotClient { + f.hosts = append(f.hosts, host) + return f.client +} type testKnot struct { setDefaultBranchErr error deleteErr error deleteCalls int mergeCalls int + createCalls int } func (k *testKnot) CreateRepo(context.Context, knot.CreateRepoInput) (string, error) { + k.createCalls++ return "did:plc:repo", nil } func (k *testKnot) DeleteRepo(context.Context, knot.DeleteRepoInput) error { diff --git a/internal/cli/repo_create.go b/internal/cli/repo_create.go index 0dc9876..e759740 100644 --- a/internal/cli/repo_create.go +++ b/internal/cli/repo_create.go @@ -9,6 +9,7 @@ import ( func newRepoCreateCommand(service *app.Service) *cobra.Command { var description, knotHost, pushPath, remote string + var sshPort int var clone bool command := &cobra.Command{ @@ -35,7 +36,7 @@ Requires authentication (run "tg auth login" first).`, } result, err := service.CreateRepo(ctx, app.CreateRepoInput{ - KnotHost: selectedKnot, Name: args[0], Description: description, + KnotHost: selectedKnot, SSHPort: sshPort, Name: args[0], Description: description, Clone: clone, PushPath: pushPath, RemoteName: remote, }) if err != nil { @@ -46,6 +47,7 @@ Requires authentication (run "tg auth login" first).`, } command.Flags().StringVar(&description, "description", "", "Repository description") command.Flags().StringVar(&knotHost, "knot", "", "knot host to create on (default "+app.DefaultKnot+")") + command.Flags().IntVar(&sshPort, "ssh-port", 22, "SSH port for cloning from or pushing to the selected Knot") command.Flags().BoolVar(&clone, "clone", false, "Clone the new repository into the current directory") command.Flags().StringVar(&pushPath, "push", "", "Push an existing local repository at this path to the new remote (e.g. .)") command.Flags().StringVar(&remote, "remote", "origin", "Remote name to use with --push") diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 1f4a2f1..1e42588 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -30,6 +30,26 @@ func TestNewRootCreatesIndependentCommandState(t *testing.T) { } } +func TestRepoCreateSSHPortHelp(t *testing.T) { + create, _, err := NewRoot(&app.Service{}).Find([]string{"repo", "create"}) + if err != nil { + t.Fatalf("find repo create command: %v", err) + } + flag := create.Flags().Lookup("ssh-port") + if flag == nil || flag.Usage != "SSH port for cloning from or pushing to the selected Knot" { + t.Fatalf("ssh-port flag = %+v", flag) + } + if flag.DefValue != "22" { + t.Fatalf("ssh-port default = %q, want 22", flag.DefValue) + } + if err := create.Flags().Set("ssh-port", "2200"); err != nil { + t.Fatalf("set ssh-port: %v", err) + } + if got := flag.Value.String(); got != "2200" { + t.Fatalf("ssh-port = %q, want 2200", got) + } +} + func TestNewRootCreatesIndependentStateCommands(t *testing.T) { firstRoot := NewRoot(&app.Service{}) secondRoot := NewRoot(&app.Service{}) diff --git a/internal/gitutil/clone_repo.go b/internal/gitutil/clone_repo.go index 63540c5..94d512d 100644 --- a/internal/gitutil/clone_repo.go +++ b/internal/gitutil/clone_repo.go @@ -6,14 +6,16 @@ import ( // CloneRepoParams groups the inputs to CloneRepo. type CloneRepoParams struct { - Handle string // Tangled owner handle - Repo string // repository name - RepoDir string // local directory to clone into + KnotHost string // Knot hosting the repository + SSHPort int // Knot SSH port + Handle string // Tangled owner handle + Repo string // repository name + RepoDir string // local directory to clone into } // CloneRepo clones handle/repo from Tangled into params.RepoDir. func (c *Client) CloneRepo(ctx context.Context, params CloneRepoParams) error { - url := tangledRemoteURL(params.Handle, params.Repo) + url := knotRemoteURL(params.KnotHost, params.SSHPort, params.Handle, params.Repo) return c.run(ctx, "git", "clone", url, params.RepoDir) } diff --git a/internal/gitutil/push_repo.go b/internal/gitutil/push_repo.go index 0e07fff..e28f7b5 100644 --- a/internal/gitutil/push_repo.go +++ b/internal/gitutil/push_repo.go @@ -7,6 +7,8 @@ import ( type PushNewRepoParams struct { Dir string // local repository to push from + KnotHost string // Knot hosting the repository + SSHPort int // Knot SSH port Handle string // Tangled owner handle Repo string // repository name RemoteName string // git remote to add and push to @@ -15,7 +17,7 @@ type PushNewRepoParams struct { // PushNewRepo adds a remote at Dir and pushes the current branch. // Fails if RemoteName already exists. func (c *Client) PushNewRepo(ctx context.Context, params PushNewRepoParams) error { - remoteURL := tangledRemoteURL(params.Handle, params.Repo) + remoteURL := knotRemoteURL(params.KnotHost, params.SSHPort, params.Handle, params.Repo) if err := c.runIn(params.Dir, ctx, "git", "remote", "add", params.RemoteName, remoteURL); err != nil { return fmt.Errorf("add remote %q (already exists? use --remote to pick another name): %w", params.RemoteName, err) } diff --git a/internal/gitutil/repo_context.go b/internal/gitutil/repo_context.go index 89a7173..69e4874 100644 --- a/internal/gitutil/repo_context.go +++ b/internal/gitutil/repo_context.go @@ -8,6 +8,8 @@ import ( "os/exec" "slices" "strings" + + "github.com/alyraffauf/tg/knot" ) // tangledHost is the hostname for Tangled repositories. @@ -16,11 +18,27 @@ const tangledHost = "tangled.org" // defaultRemote is the conventional name of the primary git remote. const defaultRemote = "origin" -// tangledRemoteURL builds the SSH clone/push URL for a Tangled repo. +// tangledRemoteURL builds the hosted Tangled SSH URL for a repository. func tangledRemoteURL(handle, repo string) string { return "git@" + tangledHost + ":" + handle + "/" + repo } +// knotRemoteURL builds an SSH URL for a repository on knotHost. Tangled's +// default Knot and callers without a selected Knot use the hosted proxy. +func knotRemoteURL(knotHost string, sshPort int, handle, repo string) string { + gitHost := knotHost + if gitHost == "" || gitHost == knot.DefaultKnot { + gitHost = tangledHost + } + if sshPort != 22 { + return fmt.Sprintf("ssh://git@%s:%d/%s/%s", gitHost, sshPort, handle, repo) + } + if gitHost == tangledHost { + return tangledRemoteURL(handle, repo) + } + return "git@" + gitHost + ":" + handle + "/" + repo +} + // RepoContext holds the handle and repo name parsed from a git remote URL. type RepoContext struct { Handle string diff --git a/internal/gitutil/repo_context_test.go b/internal/gitutil/repo_context_test.go index 4b68e64..b347e72 100644 --- a/internal/gitutil/repo_context_test.go +++ b/internal/gitutil/repo_context_test.go @@ -3,6 +3,8 @@ package gitutil import ( "slices" "testing" + + "github.com/alyraffauf/tg/knot" ) func TestParseTangledURL(t *testing.T) { @@ -55,6 +57,29 @@ func TestParseTangledURL(t *testing.T) { } } +func TestKnotRemoteURL(t *testing.T) { + tests := []struct { + name string + knotHost string + sshPort int + want string + }{ + {name: "hosted proxy", sshPort: 22, want: "git@tangled.org:aly.codes/tg"}, + {name: "hosted proxy and custom port", sshPort: 2222, want: "ssh://git@tangled.org:2222/aly.codes/tg"}, + {name: "default knot through hosted proxy", knotHost: knot.DefaultKnot, sshPort: 22, want: "git@tangled.org:aly.codes/tg"}, + {name: "default knot through hosted proxy and custom port", knotHost: knot.DefaultKnot, sshPort: 2222, want: "ssh://git@tangled.org:2222/aly.codes/tg"}, + {name: "custom knot and port", knotHost: "knot.secluded.site", sshPort: 2222, want: "ssh://git@knot.secluded.site:2222/aly.codes/tg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := knotRemoteURL(tt.knotHost, tt.sshPort, "aly.codes", "tg"); got != tt.want { + t.Fatalf("knotRemoteURL() = %q, want %q", got, tt.want) + } + }) + } +} + func TestOriginFirst(t *testing.T) { tests := []struct { name string -- 2.51.2