From 76583d1ac912a3ba5def130405f2b418cd368a44 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Thu, 27 Aug 2026 17:59:45 -0400 Subject: [PATCH] repo: use repository DIDs in git remotes --- README.md | 7 +- internal/app/auth_git_credential.go | 7 +- internal/app/auth_test.go | 17 + internal/app/dependencies.go | 2 + internal/app/domain_types.go | 8 +- internal/app/repo.go | 4 +- internal/app/repo_clone.go | 98 ++++- internal/app/repo_create.go | 37 +- internal/app/repos_clone_test.go | 184 ++++++++- internal/app/repos_create_test.go | 44 ++- internal/app/service_test.go | 44 ++- internal/app/target.go | 235 +++++++++++- internal/app/target_test.go | 361 ++++++++++++++++++ internal/cli/auth_git_credential.go | 13 +- internal/cli/repo_clone.go | 36 +- internal/cli/repo_create.go | 10 +- internal/cli/root_test.go | 8 +- internal/gitutil/clone_repo.go | 27 +- internal/gitutil/clone_repo_test.go | 38 ++ internal/gitutil/push_repo.go | 15 +- internal/gitutil/repo_context.go | 61 ++- internal/gitutil/repo_context_test.go | 93 +++-- knot/repo_describe.go | 24 ++ knot/repo_describe_test.go | 53 +++ tangled/repo_get_repo.go | 27 +- tangled/repo_get_repo_test.go | 33 ++ .../content/docs/cookbooks/configuration.md | 31 +- website/src/content/docs/index.mdx | 5 + 28 files changed, 1349 insertions(+), 173 deletions(-) create mode 100644 internal/gitutil/clone_repo_test.go create mode 100644 knot/repo_describe.go create mode 100644 knot/repo_describe_test.go create mode 100644 tangled/repo_get_repo_test.go diff --git a/README.md b/README.md index 360575d..8a67601 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ tg auth login alice.example.com # Clone a repository tg repo clone microcosm.blue/microcosm-rs +# Clone by repository DID +tg repo clone did:plc:example + # Work with issues and pull requests tg issue list tg issue create --body "Details" "Bug report" @@ -40,7 +43,9 @@ tg pr create --title "Add feature" --base main tg pr merge ``` -`tg` auto-detects the repository from Git remotes when run inside a cloned Tangled repo, checking `origin` first. Hosted Tangled remotes support SSH, Git, HTTP, and HTTPS URLs. Custom Knot remotes support SSH and HTTPS URLs and are accepted only when the host matches the repository's canonical record. You can also pass a fully qualified `handle/repo` argument. +`tg` auto-detects the repository from Git remotes when run inside a cloned Tangled repo, checking `origin` first. It recognizes remotes that identify a repository by `handle/repo` or by repository DID. Hosted Tangled remotes support SSH, Git, HTTP, and HTTPS URLs. Custom Knot remotes support SSH and HTTPS URLs. `tg` verifies a custom Knot against the repository record before it accepts the remote. + +When a repository record contains a repository DID, `tg` uses the DID in new Git remotes. Automatically selected Knots use the `tangled.org` proxy. An explicitly configured Knot uses a direct remote and the configured SSH port. These remotes remain valid after handle changes, repository renames, and transfers. If `tg` cannot resolve the repository record for an SSH clone, it creates a `handle/repo` remote and prints the resolution error as a warning. ## Documentation diff --git a/internal/app/auth_git_credential.go b/internal/app/auth_git_credential.go index 70970b2..4f846c6 100644 --- a/internal/app/auth_git_credential.go +++ b/internal/app/auth_git_credential.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "strings" + + "github.com/alyraffauf/tg/internal/gitutil" ) -// GitPushToken returns credentials only when requestedHost is the current +// GitPushToken returns credentials for the hosted Git proxy or the current // repository's recorded Knot. func (s *Service) GitPushToken(ctx context.Context, requestedHost string) (*GitCredentialResult, error) { _, repo, err := s.repoFromCWD(ctx) @@ -17,7 +19,8 @@ func (s *Service) GitPushToken(ctx context.Context, requestedHost string) (*GitC if err != nil { return nil, err } - if !strings.EqualFold(strings.TrimSpace(requestedHost), host) { + requestedHost = strings.TrimSpace(requestedHost) + if !strings.EqualFold(requestedHost, gitutil.HostedGitHost) && !strings.EqualFold(requestedHost, host) { return &GitCredentialResult{}, nil } hasPushScope, isOAuth, err := s.sessions.OAuthSessionHasScope(ctx, "rpc:sh.tangled.repo.push?aud=*") diff --git a/internal/app/auth_test.go b/internal/app/auth_test.go index b9eb1be..bba09d7 100644 --- a/internal/app/auth_test.go +++ b/internal/app/auth_test.go @@ -36,6 +36,23 @@ func TestGitPushToken(t *testing.T) { } } +func TestGitPushTokenAcceptsHostedGitProxy(t *testing.T) { + pds := &testPDS{} + service := testService(pds, &testGit{repoCandidates: []gitutil.RepoContext{{Handle: "owner.test", Repo: "repo"}}}, &testKnot{}) + service.appview = testAppview{repo: &tangled.Repo{Value: tangledlex.Repo{Knot: "knot.example"}}} + + credentials, err := service.GitPushToken(context.Background(), gitutil.HostedGitHost) + if err != nil { + t.Fatalf("GitPushToken() error = %v", err) + } + if !credentials.MatchesRequestedHost || credentials.Token != "token" { + t.Fatalf("GitPushToken() = %+v", credentials) + } + if len(pds.serviceAuthAudiences) != 1 || pds.serviceAuthAudiences[0] != "did:web:knot.example" { + t.Fatalf("audiences = %v, want [did:web:knot.example]", pds.serviceAuthAudiences) + } +} + func TestGitPushTokenIgnoresOtherHosts(t *testing.T) { pds := &testPDS{} service := testService(pds, &testGit{repoCandidates: []gitutil.RepoContext{{Handle: "owner.test", Repo: "repo"}}}, &testKnot{}) diff --git a/internal/app/dependencies.go b/internal/app/dependencies.go index 13a7815..3fbebab 100644 --- a/internal/app/dependencies.go +++ b/internal/app/dependencies.go @@ -23,6 +23,7 @@ type identityResolver interface { type appviewClient interface { GetRepo(context.Context, string) (*tangled.Repo, error) + GetRepoByDID(context.Context, string) (*tangled.Repo, error) ListRepos(context.Context, string) (*tangled.RepoList, error) ListIssues(context.Context, string, tangled.ListOpts) (*tangled.List, error) ListPulls(context.Context, string, tangled.ListOpts) (*tangled.List, error) @@ -61,6 +62,7 @@ type gitClient interface { type knotClient interface { CreateRepo(context.Context, knot.CreateRepoInput) (string, error) DeleteRepo(context.Context, knot.DeleteRepoInput) error + DescribeRepo(context.Context, string) (*knot.RepoDescription, error) SetDefaultBranch(context.Context, knot.SetDefaultBranchInput) error GetDefaultBranch(context.Context, string) (*knot.DefaultBranch, error) Merge(context.Context, knot.MergeInput) error diff --git a/internal/app/domain_types.go b/internal/app/domain_types.go index 5ecfa6f..31278ae 100644 --- a/internal/app/domain_types.go +++ b/internal/app/domain_types.go @@ -148,9 +148,11 @@ type RepoCreateResult struct { // RepoCloneResult is returned by repository cloning. type RepoCloneResult struct { - Handle string `json:"handle"` - Repo string `json:"repo"` - Destination string `json:"destination"` + Handle string `json:"handle"` + Repo string `json:"repo"` + RepoDID string `json:"repoDid,omitempty"` + Destination string `json:"destination"` + Warnings []string `json:"warnings,omitempty"` } // RepoEditResult is returned by repository edits. diff --git a/internal/app/repo.go b/internal/app/repo.go index da37ba1..b5cd192 100644 --- a/internal/app/repo.go +++ b/internal/app/repo.go @@ -21,7 +21,9 @@ type knotRegistration struct { // ownerHandle resolves a DID to its handle, falling back to the raw DID. func (s *Service) ownerHandle(ctx context.Context, did string) string { if ident, err := s.resolver.ResolveDID(ctx, did); err == nil { - return ident.Handle.String() + if handle := ident.Handle; handle.String() != "" && !handle.IsInvalidHandle() { + return handle.String() + } } return did } diff --git a/internal/app/repo_clone.go b/internal/app/repo_clone.go index bee5d01..6d81fed 100644 --- a/internal/app/repo_clone.go +++ b/internal/app/repo_clone.go @@ -2,9 +2,13 @@ package app import ( "context" + "errors" "fmt" + "path/filepath" + "strings" "github.com/alyraffauf/tg/internal/gitutil" + "github.com/bluesky-social/indigo/atproto/syntax" ) // CloneRepoInput configures a repository clone. @@ -14,6 +18,7 @@ type CloneRepoInput struct { Protocol string Handle string Repo string + RepoDID string Destination string } @@ -32,20 +37,94 @@ func (s *Service) CloneRepo(ctx context.Context, in CloneRepoInput) (*RepoCloneR return nil, fmt.Errorf("SSH port must be between 1 and 65535") } } - return s.cloneRepo(ctx, in) + if in.RepoDID != "" && (in.Handle != "" || in.Repo != "") { + return nil, errors.New("repository DID cannot be combined with a handle or repository name") + } + if in.RepoDID == "" && (in.Handle == "" || in.Repo == "") { + return nil, errors.New("clone requires either a repository DID or both a handle and repository name") + } + resolved, err := s.resolveCloneRepo(ctx, in) + if err != nil { + return nil, err + } + return s.cloneResolvedRepo(ctx, resolved) } -func (s *Service) cloneRepo(ctx context.Context, in CloneRepoInput) (*RepoCloneResult, error) { - if in.Protocol == "https" && in.KnotHost == "" { - repo, err := s.resolveRepo(ctx, Target{Handle: in.Handle, Repo: in.Repo}) +type resolvedCloneRepoInput struct { + KnotHost string + SSHPort int + Protocol string + Handle string + Repo string + RepoDID string + Destination string + Warnings []string +} + +func (s *Service) resolveCloneRepo(ctx context.Context, in CloneRepoInput) (resolvedCloneRepoInput, error) { + resolved := resolvedCloneRepoInput{ + KnotHost: in.KnotHost, SSHPort: in.SSHPort, Protocol: in.Protocol, + Handle: in.Handle, Repo: in.Repo, RepoDID: in.RepoDID, Destination: in.Destination, + } + var warnings []string + if in.RepoDID != "" { + target, _, err := s.resolveRepoDID(ctx, in.RepoDID, in.KnotHost) if err != nil { - return nil, fmt.Errorf("resolve repository knot: %w", err) + return resolvedCloneRepoInput{}, err } - knotHost, err := parseKnotHostname(repo.Value.Knot) + resolved.Handle = target.Handle + resolved.Repo = target.Repo + } else { + repo, err := s.resolveRepo(ctx, Target{Handle: in.Handle, Repo: in.Repo}) if err != nil { - return nil, err + if in.Protocol != "ssh" { + return resolvedCloneRepoInput{}, fmt.Errorf("resolve repository Knot: %w", err) + } + warnings = append(warnings, fmt.Sprintf("could not resolve repository DID. Using handle-based remote: %v", err)) + } else { + resolved.RepoDID = stringValue(repo.Value.RepoDid) + if resolved.RepoDID == "" { + warnings = append(warnings, "repository record has no repository DID. Using handle-based remote") + } else if _, err := syntax.ParseDID(resolved.RepoDID); err != nil { + return resolvedCloneRepoInput{}, fmt.Errorf("invalid repository DID %q: %w", resolved.RepoDID, err) + } + if in.Protocol == "https" && resolved.RepoDID == "" { + resolved.KnotHost, err = parseKnotHostname(repo.Value.Knot) + if err != nil { + return resolvedCloneRepoInput{}, err + } + } + } + } + if resolved.Destination == "" { + resolved.Destination = resolved.Repo + if err := validateDefaultCloneDestination(resolved.Destination); err != nil { + return resolvedCloneRepoInput{}, err + } + } + resolved.Warnings = warnings + return resolved, nil +} + +func validateDefaultCloneDestination(destination string) error { + unsafe := destination == "" || + filepath.IsAbs(destination) || + destination == "." || + destination == ".." || + strings.ContainsRune(destination, filepath.Separator) || + strings.ContainsRune(destination, '\x00') || + strings.HasPrefix(destination, "-") + if unsafe { + return fmt.Errorf("repository name %q cannot be used as the default clone directory; provide an explicit directory", destination) + } + return nil +} + +func (s *Service) cloneResolvedRepo(ctx context.Context, in resolvedCloneRepoInput) (*RepoCloneResult, error) { + if in.RepoDID != "" { + if _, err := syntax.ParseDID(in.RepoDID); err != nil { + return nil, fmt.Errorf("invalid repository DID %q: %w", in.RepoDID, err) } - in.KnotHost = knotHost } if err := s.git.CloneRepo(ctx, gitutil.CloneRepoParams{ KnotHost: in.KnotHost, @@ -53,6 +132,7 @@ func (s *Service) cloneRepo(ctx context.Context, in CloneRepoInput) (*RepoCloneR Protocol: in.Protocol, Handle: in.Handle, Repo: in.Repo, + RepoDID: in.RepoDID, RepoDir: in.Destination, }); err != nil { return nil, err @@ -60,7 +140,9 @@ func (s *Service) cloneRepo(ctx context.Context, in CloneRepoInput) (*RepoCloneR return &RepoCloneResult{ Handle: in.Handle, Repo: in.Repo, + RepoDID: in.RepoDID, Destination: in.Destination, + Warnings: in.Warnings, }, nil } diff --git a/internal/app/repo_create.go b/internal/app/repo_create.go index c8331b1..8338706 100644 --- a/internal/app/repo_create.go +++ b/internal/app/repo_create.go @@ -50,7 +50,8 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea } in.CloneProtocol = cloneProtocol } - if ((in.Clone && in.CloneProtocol == "ssh") || in.PushPath != "") && (in.SSHPort < 1 || in.SSHPort > 65535) { + directKnot := in.KnotHost != "" + if directKnot && ((in.Clone && in.CloneProtocol == "ssh") || in.PushPath != "") && (in.SSHPort < 1 || in.SSHPort > 65535) { return nil, fmt.Errorf("SSH port must be between 1 and 65535") } if in.KnotHost != "" { @@ -70,10 +71,16 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea Handle: provisioned.Handle, Name: in.Name, URI: provisioned.URI, Knot: provisioned.KnotHost, Warnings: provisioned.Warnings, } + remoteKnotHost := "" + remoteSSHPort := 22 + if directKnot { + remoteKnotHost = provisioned.KnotHost + remoteSSHPort = in.SSHPort + } if in.Clone { - if _, err := s.cloneRepo(ctx, CloneRepoInput{ - KnotHost: provisioned.KnotHost, SSHPort: in.SSHPort, - Protocol: in.CloneProtocol, Handle: provisioned.Handle, Repo: in.Name, Destination: in.Name, + if _, err := s.cloneResolvedRepo(ctx, resolvedCloneRepoInput{ + KnotHost: remoteKnotHost, SSHPort: remoteSSHPort, + Protocol: in.CloneProtocol, Handle: provisioned.Handle, Repo: in.Name, RepoDID: provisioned.RepoDID, Destination: in.Name, }); err != nil { return nil, fmt.Errorf("clone new repository: %w", err) } @@ -83,8 +90,9 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea return result, nil } pushResult, err := s.pushNewRepo(ctx, PushNewRepoInput{ - KnotHost: provisioned.KnotHost, SSHPort: in.SSHPort, RepoDID: provisioned.RepoDID, Dir: in.PushPath, - Handle: provisioned.Handle, Repo: in.Name, RemoteName: in.RemoteName, + KnotHost: provisioned.KnotHost, RemoteKnotHost: remoteKnotHost, SSHPort: remoteSSHPort, + RepoDID: provisioned.RepoDID, Dir: in.PushPath, + RemoteName: in.RemoteName, }) if pushResult.defaultBranchWarning != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("could not set default branch: %v", pushResult.defaultBranchWarning)) @@ -231,21 +239,20 @@ type pushNewRepoResult struct { // PushNewRepoInput configures pushing a newly created repository. type PushNewRepoInput struct { - KnotHost string - SSHPort int - RepoDID string - Dir string - Handle string - Repo string - RemoteName string + KnotHost string + RemoteKnotHost string + SSHPort int + RepoDID string + Dir string + RemoteName string } func (s *Service) pushNewRepo(ctx context.Context, in PushNewRepoInput) (pushNewRepoResult, error) { branch, defaultBranchErr := s.setDefaultBranchFromDir(ctx, in.KnotHost, in.RepoDID, in.Dir) result := pushNewRepoResult{defaultBranch: branch, defaultBranchWarning: defaultBranchErr} if err := s.git.PushNewRepo(ctx, gitutil.PushNewRepoParams{ - Dir: in.Dir, KnotHost: in.KnotHost, SSHPort: in.SSHPort, - Handle: in.Handle, Repo: in.Repo, RemoteName: in.RemoteName, + Dir: in.Dir, KnotHost: in.RemoteKnotHost, SSHPort: in.SSHPort, + RepoDID: in.RepoDID, RemoteName: in.RemoteName, }); err != nil { return result, fmt.Errorf("push to new repository: %w", err) } diff --git a/internal/app/repos_clone_test.go b/internal/app/repos_clone_test.go index 8fb55fc..b451e95 100644 --- a/internal/app/repos_clone_test.go +++ b/internal/app/repos_clone_test.go @@ -2,16 +2,21 @@ package app import ( "context" + "errors" + "strings" "testing" "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/alyraffauf/tg/knot" "github.com/alyraffauf/tg/tangled" ) -func TestCloneRepoResolvesKnotForHTTPS(t *testing.T) { +func TestCloneRepoUsesRepositoryDIDForHTTPS(t *testing.T) { gitClient := &testGit{} service := testService(&testPDS{}, gitClient, &testKnot{}) - service.appview = testAppview{repo: &tangled.Repo{Value: tangledlex.Repo{Knot: "knot.example"}}} + service.appview = testAppview{repo: &tangled.Repo{Value: tangledlex.Repo{ + Knot: "knot.example", RepoDid: optionalString("did:plc:repo"), + }}} _, err := service.CloneRepo(context.Background(), CloneRepoInput{ Protocol: "https", Handle: "owner.test", Repo: "repo", Destination: "repo", @@ -22,11 +27,152 @@ func TestCloneRepoResolvesKnotForHTTPS(t *testing.T) { if len(gitClient.clones) != 1 { t.Fatalf("clone calls = %v, want one", gitClient.clones) } - if gitClient.clones[0].KnotHost != "knot.example" || gitClient.clones[0].Protocol != "https" { + if gitClient.clones[0].KnotHost != "" || gitClient.clones[0].Protocol != "https" || gitClient.clones[0].RepoDID != "did:plc:repo" { t.Fatalf("clone input = %+v", gitClient.clones[0]) } } +func TestCloneRepoResolvesRepositoryDIDInput(t *testing.T) { + const repoDID = "did:plc:repository" + gitClient := &testGit{} + knotClient := &testKnot{description: &knot.RepoDescription{ + RepoDID: repoDID, OwnerDID: "did:plc:owner", RKey: "repo-rkey", + }} + service := testService(&testPDS{}, gitClient, knotClient) + service.resolver = testResolver{ + identity: repositoryIdentity(repoDID, "owner.test", "https://knot.example"), + } + service.appview = testAppview{repo: &tangled.Repo{URI: "at://did:plc:owner/sh.tangled.repo/repo-rkey", Value: tangledlex.Repo{ + Name: optionalString("current-name"), Knot: "knot.example", RepoDid: optionalString(repoDID), + }}} + + result, err := service.CloneRepo(context.Background(), CloneRepoInput{Protocol: "ssh", RepoDID: repoDID}) + if err != nil { + t.Fatalf("CloneRepo() error = %v", err) + } + if result.Handle != "owner.test" || result.Repo != "current-name" || result.Destination != "current-name" || result.RepoDID != repoDID { + t.Fatalf("CloneRepo() = %+v", result) + } + if len(gitClient.clones) != 1 || gitClient.clones[0].RepoDID != repoDID { + t.Fatalf("clone calls = %+v", gitClient.clones) + } +} + +func TestCloneRepoUsesRecordKeyWhenRepositoryNameIsEmpty(t *testing.T) { + const repoDID = "did:plc:repository" + gitClient := &testGit{} + service := testService(&testPDS{}, gitClient, &testKnot{description: &knot.RepoDescription{ + RepoDID: repoDID, OwnerDID: "did:plc:owner", RKey: "repo-rkey", + }}) + service.resolver = testResolver{ + identity: repositoryIdentity(repoDID, "owner.test", "https://knot.example"), + } + service.appview = testAppview{repo: &tangled.Repo{URI: "at://did:plc:owner/sh.tangled.repo/repo-rkey", Value: tangledlex.Repo{ + Knot: "knot.example", RepoDid: optionalString(repoDID), + }}} + + result, err := service.CloneRepo(context.Background(), CloneRepoInput{Protocol: "ssh", RepoDID: repoDID}) + if err != nil { + t.Fatalf("CloneRepo() error = %v", err) + } + if result.Repo != "repo-rkey" || result.Destination != "repo-rkey" { + t.Fatalf("CloneRepo() = %+v", result) + } +} + +func TestCloneRepoRejectsUnsafeDefaultDestination(t *testing.T) { + const repoDID = "did:plc:repository" + tests := []string{"../outside", "/tmp/outside", "--bare", ".", "..", "nested/repo", "bad\x00name"} + for _, repoName := range tests { + t.Run(repoName, func(t *testing.T) { + gitClient := &testGit{} + service := testService(&testPDS{}, gitClient, &testKnot{description: &knot.RepoDescription{ + RepoDID: repoDID, OwnerDID: "did:plc:owner", RKey: "repo-rkey", + }}) + service.resolver = testResolver{ + identity: repositoryIdentity(repoDID, "owner.test", "https://knot.example"), + } + service.appview = testAppview{repo: &tangled.Repo{URI: "at://did:plc:owner/sh.tangled.repo/repo-rkey", Value: tangledlex.Repo{ + Name: optionalString(repoName), Knot: "knot.example", RepoDid: optionalString(repoDID), + }}} + + _, err := service.CloneRepo(context.Background(), CloneRepoInput{Protocol: "ssh", RepoDID: repoDID}) + if err == nil || !strings.Contains(err.Error(), "provide an explicit directory") { + t.Fatalf("CloneRepo() error = %v, want explicit-directory hint", err) + } + if len(gitClient.clones) != 0 { + t.Fatalf("clone calls = %+v, want none", gitClient.clones) + } + }) + } +} + +func TestCloneRepoPreservesExplicitDestination(t *testing.T) { + const repoDID = "did:plc:repository" + tests := []string{"../work", "/tmp/work", "--bare"} + for _, destination := range tests { + t.Run(destination, func(t *testing.T) { + gitClient := &testGit{} + service := testService(&testPDS{}, gitClient, &testKnot{description: &knot.RepoDescription{ + RepoDID: repoDID, OwnerDID: "did:plc:owner", RKey: "repo-rkey", + }}) + service.resolver = testResolver{ + identity: repositoryIdentity(repoDID, "owner.test", "https://knot.example"), + } + service.appview = testAppview{repo: &tangled.Repo{URI: "at://did:plc:owner/sh.tangled.repo/repo-rkey", Value: tangledlex.Repo{ + Name: optionalString(destination), Knot: "knot.example", RepoDid: optionalString(repoDID), + }}} + + result, err := service.CloneRepo(context.Background(), CloneRepoInput{ + Protocol: "ssh", RepoDID: repoDID, Destination: destination, + }) + if err != nil { + t.Fatalf("CloneRepo() error = %v", err) + } + if result.Destination != destination || len(gitClient.clones) != 1 || gitClient.clones[0].RepoDir != destination { + t.Fatalf("CloneRepo() = %+v, clone calls = %+v", result, gitClient.clones) + } + }) + } +} + +func TestCloneRepoFallsBackToHandleForSSH(t *testing.T) { + gitClient := &testGit{} + service := testService(&testPDS{}, gitClient, &testKnot{}) + service.appview = testAppview{repoErr: errors.New("appview unavailable")} + + result, err := service.CloneRepo(context.Background(), CloneRepoInput{ + Protocol: "ssh", Handle: "owner.test", Repo: "repo", Destination: "repo", + }) + if err != nil { + t.Fatalf("CloneRepo() error = %v", err) + } + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "handle-based remote") { + t.Fatalf("CloneRepo() warnings = %v", result.Warnings) + } + if len(gitClient.clones) != 1 || gitClient.clones[0].RepoDID != "" { + t.Fatalf("clone calls = %+v", gitClient.clones) + } +} + +func TestCloneRepoRejectsRepositoryRecordWithInvalidDID(t *testing.T) { + gitClient := &testGit{} + service := testService(&testPDS{}, gitClient, &testKnot{}) + service.appview = testAppview{repo: &tangled.Repo{Value: tangledlex.Repo{ + RepoDid: optionalString("not-a-did"), + }}} + + _, err := service.CloneRepo(context.Background(), CloneRepoInput{ + Protocol: "ssh", Handle: "owner.test", Repo: "repo", + }) + if err == nil || !strings.Contains(err.Error(), `invalid repository DID "not-a-did"`) { + t.Fatalf("CloneRepo() error = %v", err) + } + if len(gitClient.clones) != 0 { + t.Fatalf("clone calls = %+v, want none", gitClient.clones) + } +} + func TestCloneRepoRejectsUnsupportedProtocol(t *testing.T) { service := testService(&testPDS{}, &testGit{}, &testKnot{}) @@ -35,3 +181,35 @@ func TestCloneRepoRejectsUnsupportedProtocol(t *testing.T) { t.Fatalf("CloneRepo() error = %v", err) } } + +func TestCloneRepoRejectsMixedOrIncompleteIdentity(t *testing.T) { + tests := []struct { + name string + input CloneRepoInput + want string + }{ + { + name: "DID and handle name", + input: CloneRepoInput{Protocol: "ssh", RepoDID: "did:plc:repo", Handle: "owner.test", Repo: "repo"}, + want: "cannot be combined", + }, + { + name: "missing repository name", + input: CloneRepoInput{Protocol: "ssh", Handle: "owner.test"}, + want: "either a repository DID or both a handle and repository name", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitClient := &testGit{} + service := testService(&testPDS{}, gitClient, &testKnot{}) + _, err := service.CloneRepo(context.Background(), tt.input) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("CloneRepo() error = %v, want containing %q", err, tt.want) + } + if len(gitClient.clones) != 0 { + t.Fatalf("clone calls = %+v, want none", gitClient.clones) + } + }) + } +} diff --git a/internal/app/repos_create_test.go b/internal/app/repos_create_test.go index f0333ae..48f8317 100644 --- a/internal/app/repos_create_test.go +++ b/internal/app/repos_create_test.go @@ -9,11 +9,10 @@ import ( "github.com/alyraffauf/tg/atproto" ) -func TestCreateRepoValidatesConnectionSettingsBeforeProvisioning(t *testing.T) { +func TestCreateRepoValidatesKnotBeforeProvisioning(t *testing.T) { tests := []struct { name string knotHost string - sshPort int pushPath string clone bool wantHost string @@ -23,14 +22,10 @@ func TestCreateRepoValidatesConnectionSettingsBeforeProvisioning(t *testing.T) { 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: "push to explicit Knot", knotHost: "knot.example", pushPath: ".", wantHost: "knot.example", wantCreates: 1, wantPuts: 1}, + {name: "clone from explicit Knot", knotHost: "knot.example", 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}, + {name: "valid hostname", knotHost: "knot.example", wantHost: "knot.example", wantCreates: 1, wantPuts: 1}, } for _, tt := range tests { @@ -41,7 +36,7 @@ func TestCreateRepoValidatesConnectionSettingsBeforeProvisioning(t *testing.T) { 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, + KnotHost: tt.knotHost, SSHPort: 2222, Name: "example", Clone: tt.clone, PushPath: tt.pushPath, }) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { @@ -61,12 +56,18 @@ func TestCreateRepoValidatesConnectionSettingsBeforeProvisioning(t *testing.T) { 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.wantClones == 1 && (git.clones[0].KnotHost != "knot.example" || git.clones[0].SSHPort != 2222) { + t.Fatalf("clone destination = %+v, want knot.example:2222", git.clones[0]) + } + if tt.wantClones == 1 && git.clones[0].RepoDID != "did:plc:repo" { + t.Fatalf("clone repository DID = %q", git.clones[0].RepoDID) } 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.pushPath != "" && (len(git.pushes) != 1 || git.pushes[0].KnotHost != "knot.example" || git.pushes[0].SSHPort != 2222) { + t.Fatalf("push destination = %+v, want knot.example:2222", git.pushes) + } if tt.wantErr == "" { wantAudience := "did:web:" + tt.wantHost if len(pds.serviceAuthAudiences) == 0 { @@ -188,7 +189,7 @@ func TestCreateRepoSelectsKnot(t *testing.T) { } result, err := service.CreateRepo(context.Background(), CreateRepoInput{ - KnotHost: tt.configuredKnot, SSHPort: 22, Name: "example", Clone: tt.clone, PushPath: pushPath, + KnotHost: tt.configuredKnot, Name: "example", Clone: tt.clone, PushPath: pushPath, }) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { @@ -217,11 +218,20 @@ func TestCreateRepoSelectsKnot(t *testing.T) { if tt.wantErr != "" && (pds.serviceAuthCalls != 0 || len(pds.puts) != 0) { t.Fatalf("service auth/put calls = %d/%d, want no mutation side effects", pds.serviceAuthCalls, len(pds.puts)) } - if tt.push && (len(git.pushes) != 1 || git.pushes[0].KnotHost != tt.wantKnot) { - t.Fatalf("pushes = %+v, want one push to %q", git.pushes, tt.wantKnot) + if tt.push && len(git.pushes) != 1 { + t.Fatalf("pushes = %+v, want one push", git.pushes) + } + if tt.push && git.pushes[0].RepoDID != "did:plc:repo" { + t.Fatalf("push repository DID = %q", git.pushes[0].RepoDID) + } + if tt.push && (git.pushes[0].KnotHost != "" || git.pushes[0].SSHPort != 22) { + t.Fatalf("automatic push destination = %+v, want tangled.org:22", git.pushes[0]) + } + if tt.clone && (len(git.clones) != 1 || git.clones[0].KnotHost != "") { + t.Fatalf("clones = %+v, want one clone through tangled.org", git.clones) } - if tt.clone && (len(git.clones) != 1 || git.clones[0].KnotHost != tt.wantKnot) { - t.Fatalf("clones = %+v, want one clone from %q", git.clones, tt.wantKnot) + if tt.clone && git.clones[0].SSHPort != 22 { + t.Fatalf("clone SSH port = %d, want 22", git.clones[0].SSHPort) } if tt.wantErr == "" { if len(knotFactory.hosts) == 0 || knotFactory.hosts[0] != tt.wantKnot { diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 7bc12a1..f622291 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -66,7 +66,7 @@ func TestCreateRepoRecordsDefaultBranchOutcome(t *testing.T) { if knotClient.setDefaultBranchInput.Repo != "did:plc:repo" { t.Fatalf("SetDefaultBranch() repo = %q, want newly created repository DID", knotClient.setDefaultBranchInput.Repo) } - if git.pushes[0].KnotHost != "knot.example" || git.pushes[0].SSHPort != 2222 { + if git.pushes[0].RepoDID != "did:plc:repo" || git.pushes[0].KnotHost != "knot.example" || git.pushes[0].SSHPort != 2222 { t.Fatalf("git push destination = %+v", git.pushes[0]) } }) @@ -470,6 +470,9 @@ type testKnot struct { mergeCalls int mergeInput knot.MergeInput createCalls int + description *knot.RepoDescription + describeErr error + describeDIDs []string } func (k *testKnot) CreateRepo(context.Context, knot.CreateRepoInput) (string, error) { @@ -490,6 +493,16 @@ func (k *testKnot) GetDefaultBranch(context.Context, string) (*knot.DefaultBranc } return k.defaultBranch, nil } +func (k *testKnot) DescribeRepo(_ context.Context, repoDID string) (*knot.RepoDescription, error) { + k.describeDIDs = append(k.describeDIDs, repoDID) + if k.describeErr != nil { + return nil, k.describeErr + } + if k.description == nil { + return nil, errors.New("not implemented") + } + return k.description, nil +} func (k *testKnot) Merge(_ context.Context, input knot.MergeInput) error { k.mergeCalls++ k.mergeInput = input @@ -497,13 +510,32 @@ func (k *testKnot) Merge(_ context.Context, input knot.MergeInput) error { } type testAppview struct { - repo *tangled.Repo - pulls *tangled.List - search *tangled.SearchResult - stars int64 + repo *tangled.Repo + repoErr error + getRepoHook func(string) + getRepoByDIDHook func(string) + repoByDID *tangled.Repo + repoByDIDErr error + pulls *tangled.List + search *tangled.SearchResult + stars int64 } -func (a testAppview) GetRepo(context.Context, string) (*tangled.Repo, error) { return a.repo, nil } +func (a testAppview) GetRepo(_ context.Context, uri string) (*tangled.Repo, error) { + if a.getRepoHook != nil { + a.getRepoHook(uri) + } + return a.repo, a.repoErr +} +func (a testAppview) GetRepoByDID(_ context.Context, repoDID string) (*tangled.Repo, error) { + if a.getRepoByDIDHook != nil { + a.getRepoByDIDHook(repoDID) + } + if a.repoByDID != nil || a.repoByDIDErr != nil { + return a.repoByDID, a.repoByDIDErr + } + return a.repo, a.repoErr +} func (testAppview) ListRepos(context.Context, string) (*tangled.RepoList, error) { return nil, errors.New("not implemented") } diff --git a/internal/app/target.go b/internal/app/target.go index dae6959..061ffee 100644 --- a/internal/app/target.go +++ b/internal/app/target.go @@ -5,16 +5,23 @@ import ( "errors" "fmt" "net/http" + "net/url" + "strconv" "strings" "github.com/alyraffauf/tg/tangled" "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" ) // Target identifies a repository by owner handle (or DID) and repo name. type Target struct { - Handle string - Repo string + Handle string + Repo string + RepoDID string + // ownerDID preserves the verified owner when a repository DID was resolved. + ownerDID string } func (t Target) String() string { return t.Handle + "/" + t.Repo } @@ -45,6 +52,14 @@ func (s *Service) targetFromCWD(ctx context.Context, resolveHosted bool) (Target } var failures []string for _, candidate := range candidates { + if candidate.RepoDID != "" { + target, repo, err := s.resolveRepoDID(ctx, candidate.RepoDID, candidate.KnotHost) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", candidate.RepoDID, err)) + continue + } + return target, repo, nil + } target := Target{Handle: candidate.Handle, Repo: candidate.Repo} if candidate.KnotHost == "" && !resolveHosted { return target, nil, nil @@ -67,15 +82,202 @@ func (s *Service) targetFromCWD(ctx context.Context, resolveHosted bool) (Target return Target{}, nil, fmt.Errorf("no Git remote matches a Tangled repository record: %s; pass the repository as handle/repo", strings.Join(failures, "; ")) } +// resolveRepoDID maps a repository DID to its current owner record and verifies +// that the DID document, Knot metadata, and ATProto record agree. +func (s *Service) resolveRepoDID(ctx context.Context, repoDID, remoteKnotHost string) (Target, *tangled.Repo, error) { + if _, err := syntax.ParseDID(repoDID); err != nil { + return Target{}, nil, fmt.Errorf("invalid repository DID %q: %w", repoDID, err) + } + ident, err := s.resolver.ResolveDID(ctx, repoDID) + if err != nil { + return Target{}, nil, fmt.Errorf("resolve Knot for repository DID %q: %w", repoDID, err) + } + serviceURL, err := repositoryKnotServiceURL(ident) + if err != nil { + return Target{}, nil, fmt.Errorf("resolve Knot for repository DID %q: %w", repoDID, err) + } + knotEndpoint, err := parseKnotServiceEndpoint(serviceURL) + if err != nil { + return Target{}, nil, fmt.Errorf("resolve Knot for repository DID %q: %w", repoDID, err) + } + if remoteKnotHost != "" { + remoteHostname, parseErr := knotHostnameFromHost(remoteKnotHost) + if parseErr != nil || remoteHostname != knotEndpoint.Hostname { + return Target{}, nil, fmt.Errorf("remote Knot %q does not match repository DID Knot %q", remoteKnotHost, knotEndpoint.Authority) + } + } + + description, describeErr := s.knot.NewPublic(knotEndpoint.Authority).DescribeRepo(ctx, repoDID) + if describeErr != nil { + if !isDescribeRepoUnsupported(describeErr) { + return Target{}, nil, fmt.Errorf("describe repository DID %q through Knot %q: %w", repoDID, knotEndpoint.Authority, describeErr) + } + repo, appviewErr := s.appview.GetRepoByDID(ctx, repoDID) + if appviewErr != nil { + return Target{}, nil, fmt.Errorf("find repository DID %q through appview after Knot %q returned %v: %w", repoDID, knotEndpoint.Authority, describeErr, appviewErr) + } + target, err := s.targetFromRepoDIDRecord(ctx, repoDID, knotEndpoint.Hostname, repo) + return target, repo, err + } + if description.RepoDID != repoDID { + return Target{}, nil, fmt.Errorf("Knot described repository DID %q as %q", repoDID, description.RepoDID) + } + if _, err := syntax.ParseDID(description.OwnerDID); err != nil { + return Target{}, nil, fmt.Errorf("Knot returned invalid owner DID %q for repository %q: %w", description.OwnerDID, repoDID, err) + } + if _, err := syntax.ParseRecordKey(description.RKey); err != nil { + return Target{}, nil, fmt.Errorf("Knot returned invalid repository record key %q for %q: %w", description.RKey, repoDID, err) + } + + recordURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", description.OwnerDID, description.RKey) + repo, err := s.appview.GetRepo(ctx, recordURI) + if err != nil { + return Target{}, nil, fmt.Errorf("get repository record %q: %w", recordURI, err) + } + if repo.URI == "" { + repo.URI = recordURI + } + target, err := s.targetFromRepoDIDRecord(ctx, repoDID, knotEndpoint.Hostname, repo) + if err != nil { + return Target{}, nil, err + } + if target.ownerDID != description.OwnerDID || extractRKey(repo.URI) != description.RKey { + return Target{}, nil, fmt.Errorf("repository record %q does not match Knot owner %q and record key %q", repo.URI, description.OwnerDID, description.RKey) + } + return target, repo, nil +} + +func isDescribeRepoUnsupported(err error) bool { + var apiError *atclient.APIError + if !errors.As(err, &apiError) { + return false + } + switch apiError.StatusCode { + case http.StatusNotFound: + return apiError.Name == "XRPCNotSupported" + case http.StatusNotImplemented: + return apiError.Name == "MethodNotImplemented" + default: + return false + } +} + +func repositoryKnotServiceURL(ident *identity.Identity) (string, error) { + if ident == nil { + return "", errors.New("nil identity has no Knot service endpoint") + } + services := []struct { + id string + serviceType string + }{ + {id: "tangled_knot", serviceType: "TangledKnot"}, + {id: "atproto_pds", serviceType: "AtprotoPersonalDataServer"}, + } + for _, expected := range services { + service := ident.Services[expected.id] + if service.Type == expected.serviceType && service.URL != "" { + return service.URL, nil + } + } + return "", errors.New("DID document has no TangledKnot or AtprotoPersonalDataServer service endpoint") +} + +type knotServiceEndpoint struct { + Hostname string + Authority string +} + +func parseKnotServiceEndpoint(raw string) (knotServiceEndpoint, error) { + serviceURL, err := url.Parse(raw) + if err != nil { + return knotServiceEndpoint{}, fmt.Errorf("parse service endpoint %q: %w", raw, err) + } + path := serviceURL.EscapedPath() + if serviceURL.Scheme != "https" || + serviceURL.Hostname() == "" || + serviceURL.User != nil || + serviceURL.RawQuery != "" || + serviceURL.ForceQuery || + serviceURL.Fragment != "" || + (path != "" && path != "/") { + return knotServiceEndpoint{}, fmt.Errorf("repository DID service endpoint must be an HTTPS Knot URL, got %q", raw) + } + hostname, err := parseKnotHostname(serviceURL.Hostname()) + if err != nil { + return knotServiceEndpoint{}, err + } + port := serviceURL.Port() + if port == "" { + return knotServiceEndpoint{Hostname: hostname, Authority: hostname}, nil + } + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return knotServiceEndpoint{}, fmt.Errorf("repository DID service endpoint has invalid HTTPS port %q", port) + } + if portNumber == 443 { + return knotServiceEndpoint{Hostname: hostname, Authority: hostname}, nil + } + return knotServiceEndpoint{Hostname: hostname, Authority: hostname + ":" + strconv.Itoa(portNumber)}, nil +} + +func knotHostnameFromHost(raw string) (string, error) { + hostURL, err := url.Parse("https://" + raw) + if err != nil || hostURL.Host != raw || hostURL.Hostname() == "" { + return "", fmt.Errorf("invalid Knot host %q", raw) + } + return parseKnotHostname(hostURL.Hostname()) +} + +func (s *Service) targetFromRepoDIDRecord(ctx context.Context, repoDID, knotHostname string, repo *tangled.Repo) (Target, error) { + if repo == nil { + return Target{}, errors.New("appview returned an empty repository record") + } + uri, err := syntax.ParseATURI(repo.URI) + if err != nil || uri.Collection().String() != repoCollection || uri.RecordKey().String() == "" { + return Target{}, fmt.Errorf("appview returned invalid repository record URI %q", repo.URI) + } + ownerDID := uri.Authority().String() + if _, err := syntax.ParseDID(ownerDID); err != nil { + return Target{}, fmt.Errorf("repository record %q has invalid owner DID %q: %w", repo.URI, ownerDID, err) + } + if recordRepoDID := stringValue(repo.Value.RepoDid); recordRepoDID != repoDID { + return Target{}, fmt.Errorf("repository record %q has repository DID %q, want %q", repo.URI, recordRepoDID, repoDID) + } + recordKnotHost, err := parseKnotHostname(repo.Value.Knot) + if err != nil || recordKnotHost != knotHostname { + return Target{}, fmt.Errorf("repository record Knot %q does not match repository DID Knot %q", repo.Value.Knot, knotHostname) + } + repoName := stringValue(repo.Value.Name) + if repoName == "" { + repoName = uri.RecordKey().String() + } + return Target{ + Handle: s.ownerHandle(ctx, ownerDID), + Repo: repoName, + RepoDID: repoDID, + ownerDID: ownerDID, + }, nil +} + // resolveRepo finds a repository record even when its rkey does not match // the repository name. func (s *Service) resolveRepo(ctx context.Context, t Target) (*tangled.Repo, error) { - ident, err := s.resolver.ResolveHandle(ctx, t.Handle) + if t.RepoDID != "" { + repo, err := s.appview.GetRepoByDID(ctx, t.RepoDID) + if err != nil { + return nil, fmt.Errorf("get repository by DID %q: %w", t.RepoDID, err) + } + if recordRepoDID := stringValue(repo.Value.RepoDid); recordRepoDID != t.RepoDID { + return nil, fmt.Errorf("repository record %q has repository DID %q, want %q", repo.URI, recordRepoDID, t.RepoDID) + } + return repo, nil + } + ownerDID, err := s.targetOwnerDID(ctx, t) if err != nil { - return nil, fmt.Errorf("resolve handle %q: %w", t.Handle, err) + return nil, err } - recordURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, t.Repo) + recordURI := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ownerDID, t.Repo) if repo, err := s.appview.GetRepo(ctx, recordURI); err == nil { if repo.URI == "" { repo.URI = recordURI @@ -83,12 +285,31 @@ func (s *Service) resolveRepo(ctx context.Context, t Target) (*tangled.Repo, err if isCanonicalRepoRecord(*repo) || stringValue(repo.Value.Name) == "" { return repo, nil } - return s.resolveCanonicalRepo(ctx, ident.DID.String(), t, repo) + return s.resolveCanonicalRepo(ctx, ownerDID, t, repo) } else if !shouldListRepoRecords(err) { return nil, fmt.Errorf("get repository %q: %w", t.Repo, err) } - return s.resolveCanonicalRepo(ctx, ident.DID.String(), t, nil) + return s.resolveCanonicalRepo(ctx, ownerDID, t, nil) +} + +func (s *Service) targetOwnerDID(ctx context.Context, target Target) (string, error) { + if target.ownerDID != "" { + did, err := syntax.ParseDID(target.ownerDID) + if err != nil { + return "", fmt.Errorf("invalid owner DID %q: %w", target.ownerDID, err) + } + return did.String(), nil + } + if did, err := syntax.ParseDID(target.Handle); err == nil { + return did.String(), nil + } + + ident, err := s.resolver.ResolveHandle(ctx, target.Handle) + if err != nil { + return "", fmt.Errorf("resolve handle %q: %w", target.Handle, err) + } + return ident.DID.String(), nil } func (s *Service) resolveCanonicalRepo(ctx context.Context, ownerDID string, t Target, directRepo *tangled.Repo) (*tangled.Repo, error) { diff --git a/internal/app/target_test.go b/internal/app/target_test.go index 7305966..27a55e5 100644 --- a/internal/app/target_test.go +++ b/internal/app/target_test.go @@ -2,12 +2,18 @@ package app import ( "context" + "errors" + "net/http" "strings" "testing" "github.com/alyraffauf/tg/internal/gitutil" "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/alyraffauf/tg/knot" "github.com/alyraffauf/tg/tangled" + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" ) func TestParseTarget(t *testing.T) { @@ -124,9 +130,364 @@ func TestRepoFromCWDReturnsVerifiedCustomKnotRecord(t *testing.T) { } } +func TestTargetFromCWDResolvesRepositoryPermalink(t *testing.T) { + const repoDID = "did:plc:repository" + gitClient := &testGit{repoCandidates: []gitutil.RepoContext{{RepoDID: repoDID}}} + knotClient := &testKnot{description: &knot.RepoDescription{ + RepoDID: repoDID, OwnerDID: "did:plc:owner", RKey: "current-rkey", + }} + service := testService(&testPDS{}, gitClient, knotClient) + service.resolver = testResolver{ + identity: repositoryIdentity(repoDID, "owner.test", "https://knot.example"), + } + var requestedURI string + service.appview = testAppview{ + getRepoHook: func(uri string) { requestedURI = uri }, + repo: &tangled.Repo{Value: tangledlex.Repo{ + Name: optionalString("current-name"), Knot: "knot.example", RepoDid: optionalString(repoDID), + }}, + } + + target, err := service.TargetFromCWD(context.Background()) + if err != nil { + t.Fatalf("TargetFromCWD() error = %v", err) + } + if target != (Target{Handle: "owner.test", Repo: "current-name", RepoDID: repoDID, ownerDID: "did:plc:owner"}) { + t.Fatalf("TargetFromCWD() = %+v", target) + } + if requestedURI != "at://did:plc:owner/sh.tangled.repo/current-rkey" { + t.Fatalf("GetRepo() URI = %q", requestedURI) + } + factory := service.knot.(*testKnotFactory) + if len(factory.hosts) != 1 || factory.hosts[0] != "knot.example" { + t.Fatalf("Knot hosts = %v", factory.hosts) + } +} + +func TestTargetFromCWDResolvesRepositoryPermalinkWithCustomKnotPort(t *testing.T) { + const repoDID = "did:plc:repository" + knotClient := &testKnot{description: &knot.RepoDescription{ + RepoDID: repoDID, OwnerDID: "did:plc:owner", RKey: "repo", + }} + service := testService(&testPDS{}, &testGit{repoCandidates: []gitutil.RepoContext{{ + RepoDID: repoDID, KnotHost: "KNOT.EXAMPLE:8443", + }}}, knotClient) + service.resolver = testResolver{ + identity: repositoryIdentity(repoDID, "owner.test", "https://KNOT.EXAMPLE:8443"), + } + service.appview = testAppview{repo: &tangled.Repo{Value: tangledlex.Repo{ + Knot: "knot.example", RepoDid: optionalString(repoDID), + }}} + + target, err := service.TargetFromCWD(context.Background()) + if err != nil { + t.Fatalf("TargetFromCWD() error = %v", err) + } + if target.RepoDID != repoDID { + t.Fatalf("TargetFromCWD() = %+v", target) + } + factory := service.knot.(*testKnotFactory) + if len(factory.hosts) != 1 || factory.hosts[0] != "knot.example:8443" { + t.Fatalf("Knot hosts = %v", factory.hosts) + } +} + +func TestResolveRepoUsesOwnerDIDWithoutResolvingHandle(t *testing.T) { + tests := []Target{ + {Handle: "owner.test", Repo: "example", ownerDID: "did:plc:owner"}, + {Handle: "did:plc:owner", Repo: "example"}, + } + for _, target := range tests { + t.Run(target.String(), func(t *testing.T) { + service := testService(&testPDS{}, &testGit{}, &testKnot{}) + service.resolver = didOnlyResolver{testResolver: testResolver{ + identity: &identity.Identity{DID: syntax.DID("did:plc:owner")}, + }} + var requestedURI string + service.appview = testAppview{ + getRepoHook: func(uri string) { requestedURI = uri }, + repo: &tangled.Repo{Value: tangledlex.Repo{Knot: "knot.example"}}, + } + + if _, err := service.resolveRepo(context.Background(), target); err != nil { + t.Fatalf("resolveRepo() error = %v", err) + } + if requestedURI != "at://did:plc:owner/sh.tangled.repo/example" { + t.Fatalf("GetRepo() URI = %q", requestedURI) + } + }) + } +} + +func TestResolveRepoUsesStableRepositoryDID(t *testing.T) { + const repoDID = "did:plc:repository" + service := testService(&testPDS{}, &testGit{}, &testKnot{}) + var requestedDID string + service.appview = testAppview{ + getRepoHook: func(uri string) { t.Fatalf("unexpected name lookup %q", uri) }, + getRepoByDIDHook: func(did string) { + requestedDID = did + }, + repoByDID: &tangled.Repo{ + URI: "at://did:plc:new-owner/sh.tangled.repo/new-name", + Value: tangledlex.Repo{RepoDid: optionalString(repoDID)}, + }, + } + + repo, err := service.resolveRepo(context.Background(), Target{ + Handle: "old-owner.test", Repo: "old-name", RepoDID: repoDID, ownerDID: "did:plc:old-owner", + }) + if err != nil { + t.Fatalf("resolveRepo() error = %v", err) + } + if requestedDID != repoDID || repo.URI != "at://did:plc:new-owner/sh.tangled.repo/new-name" { + t.Fatalf("resolveRepo() requested %q and returned %+v", requestedDID, repo) + } +} + +func TestRepositoryKnotServiceURL(t *testing.T) { + tests := []struct { + name string + services map[string]identity.ServiceEndpoint + want string + wantErr bool + }{ + { + name: "current service", + services: map[string]identity.ServiceEndpoint{ + "tangled_knot": {Type: "TangledKnot", URL: "https://current.example"}, + }, + want: "https://current.example", + }, + { + name: "current service preferred over legacy", + services: map[string]identity.ServiceEndpoint{ + "tangled_knot": {Type: "TangledKnot", URL: "https://current.example"}, + "atproto_pds": {Type: "AtprotoPersonalDataServer", URL: "https://legacy.example"}, + }, + want: "https://current.example", + }, + { + name: "legacy service", + services: map[string]identity.ServiceEndpoint{ + "atproto_pds": {Type: "AtprotoPersonalDataServer", URL: "https://legacy.example"}, + }, + want: "https://legacy.example", + }, + { + name: "wrong service type", + services: map[string]identity.ServiceEndpoint{ + "tangled_knot": {Type: "AtprotoPersonalDataServer", URL: "https://wrong.example"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repositoryKnotServiceURL(&identity.Identity{Services: tt.services}) + if tt.wantErr { + if err == nil { + t.Fatalf("repositoryKnotServiceURL() = %q, want error", got) + } + return + } + if err != nil || got != tt.want { + t.Fatalf("repositoryKnotServiceURL() = %q, %v, want %q", got, err, tt.want) + } + }) + } +} + +func TestTargetFromCWDRejectsUnverifiedRepositoryPermalink(t *testing.T) { + const repoDID = "did:plc:repository" + tests := []struct { + name string + remoteKnot string + serviceURL string + description *knot.RepoDescription + recordDID string + recordKnot string + describeErr error + want string + }{ + {name: "remote Knot mismatch", remoteKnot: "other.example", serviceURL: "https://knot.example", want: "does not match repository DID Knot"}, + {name: "described DID mismatch", serviceURL: "https://knot.example", description: &knot.RepoDescription{RepoDID: "did:plc:other", OwnerDID: "did:plc:owner", RKey: "repo"}, want: "described repository DID"}, + {name: "record DID mismatch", serviceURL: "https://knot.example", description: &knot.RepoDescription{RepoDID: repoDID, OwnerDID: "did:plc:owner", RKey: "repo"}, recordDID: "did:plc:other", recordKnot: "knot.example", want: "has repository DID"}, + {name: "record Knot mismatch", serviceURL: "https://knot.example", description: &knot.RepoDescription{RepoDID: repoDID, OwnerDID: "did:plc:owner", RKey: "repo"}, recordDID: repoDID, recordKnot: "other.example", want: "does not match repository DID Knot"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + knotClient := &testKnot{description: tt.description, describeErr: tt.describeErr} + service := testService(&testPDS{}, &testGit{repoCandidates: []gitutil.RepoContext{{ + RepoDID: repoDID, KnotHost: tt.remoteKnot, + }}}, knotClient) + service.resolver = testResolver{identity: repositoryIdentity(repoDID, "owner.test", tt.serviceURL)} + service.appview = testAppview{repo: &tangled.Repo{Value: tangledlex.Repo{ + Knot: tt.recordKnot, RepoDid: optionalString(tt.recordDID), + }}} + + _, err := service.TargetFromCWD(context.Background()) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("TargetFromCWD() error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestTargetFromCWDFallsBackToAppviewForLegacyKnot(t *testing.T) { + const repoDID = "did:plc:repository" + unsupportedErrors := []struct { + name string + err error + }{ + {name: "unsupported XRPC endpoint", err: &atclient.APIError{StatusCode: http.StatusNotFound, Name: "XRPCNotSupported"}}, + {name: "unimplemented method", err: &atclient.APIError{StatusCode: http.StatusNotImplemented, Name: "MethodNotImplemented"}}, + } + for _, unsupported := range unsupportedErrors { + t.Run(unsupported.name, func(t *testing.T) { + service := testService(&testPDS{}, &testGit{repoCandidates: []gitutil.RepoContext{{RepoDID: repoDID}}}, &testKnot{ + describeErr: unsupported.err, + }) + service.resolver = testResolver{identity: repositoryIdentity(repoDID, "owner.test", "https://knot.example")} + service.appview = testAppview{repoByDID: &tangled.Repo{ + URI: "at://did:plc:owner/sh.tangled.repo/current-rkey", + Value: tangledlex.Repo{ + Name: optionalString("current-name"), Knot: "knot.example", RepoDid: optionalString(repoDID), + }, + }} + + target, err := service.TargetFromCWD(context.Background()) + if err != nil { + t.Fatalf("TargetFromCWD() error = %v", err) + } + if target.RepoDID != repoDID || target.Handle != "owner.test" || target.Repo != "current-name" { + t.Fatalf("TargetFromCWD() = %+v", target) + } + }) + } +} + +func TestResolveRepoDIDDoesNotFallBackAfterDescribeFailure(t *testing.T) { + const repoDID = "did:plc:repository" + tests := []struct { + name string + err error + }{ + {name: "repository not found", err: &atclient.APIError{StatusCode: http.StatusNotFound, Name: "RepoNotFound"}}, + {name: "network failure", err: errors.New("dial tcp: connection refused")}, + {name: "timeout", err: context.DeadlineExceeded}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + service := testService(&testPDS{}, &testGit{}, &testKnot{describeErr: test.err}) + service.resolver = testResolver{identity: repositoryIdentity(repoDID, "owner.test", "https://knot.example")} + service.appview = testAppview{ + getRepoByDIDHook: func(string) { t.Fatal("unexpected appview fallback") }, + repoByDID: &tangled.Repo{ + URI: "at://did:plc:old-owner/sh.tangled.repo/old-name", + Value: tangledlex.Repo{ + Knot: "knot.example", RepoDid: optionalString(repoDID), + }, + }, + } + + _, _, err := service.resolveRepoDID(context.Background(), repoDID, "") + if !errors.Is(err, test.err) { + t.Fatalf("resolveRepoDID() error = %v, want wrapped %v", err, test.err) + } + }) + } +} + +func TestParseKnotServiceEndpoint(t *testing.T) { + tests := []struct { + raw string + wantAuthority string + wantHostname string + }{ + {raw: "https://KNOT.EXAMPLE", wantAuthority: "knot.example", wantHostname: "knot.example"}, + {raw: "https://KNOT.EXAMPLE/", wantAuthority: "knot.example", wantHostname: "knot.example"}, + {raw: "https://KNOT.EXAMPLE:443/", wantAuthority: "knot.example", wantHostname: "knot.example"}, + {raw: "https://KNOT.EXAMPLE:00443/", wantAuthority: "knot.example", wantHostname: "knot.example"}, + {raw: "https://knot.example:8443/", wantAuthority: "knot.example:8443", wantHostname: "knot.example"}, + {raw: "https://knot.example/xrpc"}, + {raw: "http://knot.example"}, + {raw: "https://user@knot.example"}, + {raw: "https://knot.example?query=yes"}, + {raw: "https://knot.example/#fragment"}, + {raw: "https://knot.example:0/"}, + {raw: "https://knot.example:65536/"}, + {raw: "https://knot.example:not-a-port/"}, + } + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + got, err := parseKnotServiceEndpoint(tt.raw) + if tt.wantAuthority == "" { + if err == nil { + t.Fatalf("parseKnotServiceEndpoint() = %+v, want error", got) + } + return + } + want := knotServiceEndpoint{Authority: tt.wantAuthority, Hostname: tt.wantHostname} + if err != nil || got != want { + t.Fatalf("parseKnotServiceEndpoint() = %+v, %v, want %+v", got, err, want) + } + }) + } +} + +func TestResolveRepoDIDRejectsInvalidKnotEndpointBeforeNetworkCall(t *testing.T) { + const repoDID = "did:plc:repository" + tests := []string{ + "http://knot.example", + "https://user@knot.example", + "https://knot.example/xrpc", + "https://knot.example?query=yes", + "https://knot.example/#fragment", + "https://knot.example:0/", + "https://knot.example:65536/", + } + for _, serviceURL := range tests { + t.Run(serviceURL, func(t *testing.T) { + service := testService(&testPDS{}, &testGit{}, &testKnot{}) + service.resolver = testResolver{ + identity: repositoryIdentity(repoDID, "owner.test", serviceURL), + } + + _, _, err := service.resolveRepoDID(context.Background(), repoDID, "") + if err == nil { + t.Fatal("resolveRepoDID() error = nil") + } + factory := service.knot.(*testKnotFactory) + if len(factory.hosts) != 0 { + t.Fatalf("Knot hosts = %v, want no network client", factory.hosts) + } + }) + } +} + +func repositoryIdentity(repoDID, ownerHandle, knotURL string) *identity.Identity { + return &identity.Identity{ + DID: syntax.DID(repoDID), + Handle: syntax.Handle(ownerHandle), + Services: map[string]identity.ServiceEndpoint{ + "tangled_knot": {Type: "TangledKnot", URL: knotURL}, + }, + } +} + func TestTargetString(t *testing.T) { target := Target{Handle: "aly.codes", Repo: "tg"} if got := target.String(); got != "aly.codes/tg" { t.Fatalf("String() = %q, want %q", got, "aly.codes/tg") } } + +type didOnlyResolver struct { + testResolver +} + +func (didOnlyResolver) ResolveHandle(context.Context, string) (*identity.Identity, error) { + return nil, errors.New("unexpected handle resolution") +} diff --git a/internal/cli/auth_git_credential.go b/internal/cli/auth_git_credential.go index 277078e..f68d76e 100644 --- a/internal/cli/auth_git_credential.go +++ b/internal/cli/auth_git_credential.go @@ -14,18 +14,19 @@ func newAuthGitCredentialCommand(service *app.Service) *cobra.Command { return &cobra.Command{ Use: "git-credential ", Short: "Git credential helper for HTTPS push to Tangled knots", - Long: `Provide HTTPS credentials for Git pushes to a Tangled knot. + Long: `Provide HTTPS credentials for Git pushes to a Tangled Knot. -Configure the helper for a knot: +Configure the helper for the hosted Git proxy: - git config --global credential."https://".helper "!tg auth git-credential" + git config --global credential."https://tangled.org".helper "!tg auth git-credential" -Point the remote at that knot: +Point the remote at the hosted Git proxy: - git remote set-url origin https:////.git + git remote set-url origin https://tangled.org/ The helper mints a short-lived token only for the current repository's recorded -knot. It does not store credentials.`, +Knot. It accepts credential requests from the hosted proxy or that Knot. The +helper does not store credentials.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { switch args[0] { diff --git a/internal/cli/repo_clone.go b/internal/cli/repo_clone.go index 205b3fd..acb5251 100644 --- a/internal/cli/repo_clone.go +++ b/internal/cli/repo_clone.go @@ -4,43 +4,49 @@ import ( "fmt" "github.com/alyraffauf/tg/internal/app" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/spf13/cobra" ) func newRepoCloneCommand(service *app.Service, defaultProtocol string) *cobra.Command { return &cobra.Command{ - Use: "clone [directory]", + Use: "clone [directory]", Short: "Clone a Tangled repository", Long: `Clone a Tangled repository into a local directory. The default destination is the repository name. If only a repository name is -given, the authenticated user's handle is used. +given, the authenticated user's handle is used. If a record supplies a name +that is unsafe as a default directory, provide the directory argument. -Run "tg auth login" first when using the repository-only form.`, +Run "tg auth login" first when using the repository-only form. A repository DID +does not require a tg login. The clone's origin remote uses the DID.`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - target, err := resolveCloneTarget(ctx, args[0], service) - if err != nil { - return err + input := app.CloneRepoInput{Protocol: defaultProtocol} + if _, err := syntax.ParseDID(args[0]); err == nil { + input.RepoDID = args[0] + } else { + target, err := resolveCloneTarget(ctx, args[0], service) + if err != nil { + return err + } + input.Handle = target.Handle + input.Repo = target.Repo } - - dest := target.Repo if len(args) == 2 { - dest = args[1] + input.Destination = args[1] } - result, err := service.CloneRepo(ctx, app.CloneRepoInput{ - Handle: target.Handle, - Repo: target.Repo, - Destination: dest, - Protocol: defaultProtocol, - }) + result, err := service.CloneRepo(ctx, input) if err != nil { return fmt.Errorf("clone %q: %w", args[0], err) } return output(cmd, result, func(clone *app.RepoCloneResult) { fmt.Fprintf(cmd.OutOrStdout(), "Cloned %s/%s into %s\n", clone.Handle, clone.Repo, clone.Destination) + for _, warning := range clone.Warnings { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", warning) + } }) }, } diff --git a/internal/cli/repo_create.go b/internal/cli/repo_create.go index c32bb9b..e0b164b 100644 --- a/internal/cli/repo_create.go +++ b/internal/cli/repo_create.go @@ -38,11 +38,10 @@ Requires authentication (run "tg auth login" first).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - parsedSSHPort, err := parseRepoCreateSSHPort(sshPort, defaultProtocol, clone, pushPath) + parsedSSHPort, err := parseRepoCreateSSHPort(sshPort, knotHost, defaultProtocol, clone, pushPath) if err != nil { return err } - result, err := service.CreateRepo(ctx, app.CreateRepoInput{ KnotHost: knotHost, SSHPort: parsedSSHPort, Name: args[0], Description: description, Clone: clone, CloneProtocol: defaultProtocol, PushPath: pushPath, RemoteName: remote, @@ -55,15 +54,16 @@ Requires authentication (run "tg auth login" first).`, } command.Flags().StringVar(&description, "description", "", "Repository description") command.Flags().StringVar(&knotHost, "knot", defaultKnot, "Knot host to provision and optionally push to (overrides TG_KNOT, config, and automatic discovery)") - command.Flags().StringVar(&sshPort, "ssh-port", defaultSSHPort, "SSH port for cloning from or pushing to the selected Knot (overrides config file and TG_SSH_PORT)") + command.Flags().StringVar(&sshPort, "ssh-port", defaultSSHPort, "SSH port for cloning from or pushing to an explicitly selected Knot (overrides config file and TG_SSH_PORT)") 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") return command } -func parseRepoCreateSSHPort(sshPort, cloneProtocol string, clone bool, pushPath string) (int, error) { - if pushPath == "" && (!clone || cloneProtocol != "ssh") { +func parseRepoCreateSSHPort(sshPort, knotHost, cloneProtocol string, clone bool, pushPath string) (int, error) { + usesDirectSSH := knotHost != "" && (pushPath != "" || clone && cloneProtocol == "ssh") + if !usesDirectSSH { return 0, nil } parsedSSHPort, err := strconv.Atoi(sshPort) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 1901fe3..8609bd7 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -99,7 +99,7 @@ func TestRepoCreateSSHPortHelp(t *testing.T) { 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 (overrides config file and TG_SSH_PORT)" { + if flag == nil || flag.Usage != "SSH port for cloning from or pushing to an explicitly selected Knot (overrides config file and TG_SSH_PORT)" { t.Fatalf("ssh-port flag = %+v", flag) } if flag.DefValue != "2200" { @@ -113,7 +113,7 @@ func TestRepoCreateSSHPortHelp(t *testing.T) { } } -func TestRepoCreateRejectsMalformedSSHPort(t *testing.T) { +func TestRepoCreateRejectsMalformedSSHPortForExplicitKnot(t *testing.T) { command := newRepoCreateCommand(&app.Service{}, "configured.example", "not-a-port", "ssh") if err := command.Flags().Set("clone", "true"); err != nil { t.Fatalf("set clone flag: %v", err) @@ -124,8 +124,8 @@ func TestRepoCreateRejectsMalformedSSHPort(t *testing.T) { } } -func TestParseRepoCreateSSHPort(t *testing.T) { - port, err := parseRepoCreateSSHPort("not-a-port", "https", true, "") +func TestParseRepoCreateSSHPortIgnoresProxyRemote(t *testing.T) { + port, err := parseRepoCreateSSHPort("not-a-port", "", "ssh", true, "") if err != nil { t.Fatalf("parseRepoCreateSSHPort() error = %v", err) } diff --git a/internal/gitutil/clone_repo.go b/internal/gitutil/clone_repo.go index c1ca02e..99cc5d3 100644 --- a/internal/gitutil/clone_repo.go +++ b/internal/gitutil/clone_repo.go @@ -12,32 +12,41 @@ type CloneRepoParams struct { Protocol string // SSH or HTTPS Handle string // Tangled owner handle Repo string // repository name + RepoDID string // stable repository DID, when available RepoDir string // local directory to clone into } -// CloneRepo clones handle/repo from Tangled into params.RepoDir. +// CloneRepo clones the repository identified by params into params.RepoDir. func (c *Client) CloneRepo(ctx context.Context, params CloneRepoParams) error { - url, err := cloneRemoteURL(params.Protocol, params.KnotHost, params.SSHPort, params.Handle, params.Repo) + url, err := cloneRemoteURL(params) if err != nil { return err } - return c.run(ctx, "git", "clone", url, params.RepoDir) + return c.run(ctx, "git", "clone", "--", url, params.RepoDir) } func CloneRepo(ctx context.Context, params CloneRepoParams) error { return defaultClient.CloneRepo(ctx, params) } -func cloneRemoteURL(protocol, knotHost string, sshPort int, handle, repo string) (string, error) { - switch protocol { +func cloneRemoteURL(params CloneRepoParams) (string, error) { + if params.RepoDID != "" { + return repositoryDIDRemoteURL(repositoryDIDRemote{ + Protocol: params.Protocol, + KnotHost: params.KnotHost, + SSHPort: params.SSHPort, + RepoDID: params.RepoDID, + }) + } + switch params.Protocol { case "ssh": - return knotRemoteURL(knotHost, sshPort, handle, repo), nil + return knotRemoteURL(params.KnotHost, params.SSHPort, params.Handle+"/"+params.Repo), nil case "https": - if knotHost == "" { + if params.KnotHost == "" { return "", fmt.Errorf("HTTPS clone requires a Knot host") } - return "https://" + knotHost + "/" + handle + "/" + repo + ".git", nil + return "https://" + params.KnotHost + "/" + params.Handle + "/" + params.Repo + ".git", nil default: - return "", fmt.Errorf("unsupported clone protocol %q", protocol) + return "", fmt.Errorf("unsupported clone protocol %q", params.Protocol) } } diff --git a/internal/gitutil/clone_repo_test.go b/internal/gitutil/clone_repo_test.go new file mode 100644 index 0000000..36f9ae4 --- /dev/null +++ b/internal/gitutil/clone_repo_test.go @@ -0,0 +1,38 @@ +package gitutil + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestCloneRepoTerminatesGitOptions(t *testing.T) { + binDir := t.TempDir() + argsFile := filepath.Join(t.TempDir(), "args") + gitPath := filepath.Join(binDir, "git") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$TG_TEST_ARGS_FILE\"\n" + if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("PATH", binDir) + t.Setenv("TG_TEST_ARGS_FILE", argsFile) + + err := NewClient(nil, nil).CloneRepo(context.Background(), CloneRepoParams{ + Protocol: "https", RepoDID: "did:plc:repository", RepoDir: "--bare", + }) + if err != nil { + t.Fatalf("CloneRepo() error = %v", err) + } + data, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read fake git arguments: %v", err) + } + got := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") + want := []string{"clone", "--", "https://tangled.org/did:plc:repository", "--bare"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("git arguments = %q, want %q", got, want) + } +} diff --git a/internal/gitutil/push_repo.go b/internal/gitutil/push_repo.go index e28f7b5..19c9db4 100644 --- a/internal/gitutil/push_repo.go +++ b/internal/gitutil/push_repo.go @@ -7,17 +7,24 @@ import ( type PushNewRepoParams struct { Dir string // local repository to push from - KnotHost string // Knot hosting the repository + KnotHost string // explicit Knot hosting the repository SSHPort int // Knot SSH port - Handle string // Tangled owner handle - Repo string // repository name + RepoDID string // stable repository DID, when available RemoteName string // git remote to add and push to } // 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 := knotRemoteURL(params.KnotHost, params.SSHPort, params.Handle, params.Repo) + remoteURL, err := repositoryDIDRemoteURL(repositoryDIDRemote{ + Protocol: "ssh", + KnotHost: params.KnotHost, + SSHPort: params.SSHPort, + RepoDID: params.RepoDID, + }) + if err != nil { + return err + } 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 ecd498d..a6662b5 100644 --- a/internal/gitutil/repo_context.go +++ b/internal/gitutil/repo_context.go @@ -10,33 +10,52 @@ import ( "strings" "github.com/alyraffauf/tg/knot" + "github.com/bluesky-social/indigo/atproto/syntax" ) -// tangledHost is the hostname for Tangled repositories. -const tangledHost = "tangled.org" +// HostedGitHost is Tangled's hosted Git proxy. +const HostedGitHost = "tangled.org" // defaultRemote is the conventional name of the primary git remote. const defaultRemote = "origin" -// 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 { +// repoPath may be a handle/repo pair or a repository DID. +func knotRemoteURL(knotHost string, sshPort int, repoPath string) string { gitHost := knotHost if gitHost == "" || gitHost == knot.DefaultKnot { - gitHost = tangledHost + gitHost = HostedGitHost } if sshPort != 22 { - return fmt.Sprintf("ssh://git@%s:%d/%s/%s", gitHost, sshPort, handle, repo) + return fmt.Sprintf("ssh://git@%s:%d/%s", gitHost, sshPort, repoPath) } - if gitHost == tangledHost { - return tangledRemoteURL(handle, repo) + return "git@" + gitHost + ":" + repoPath +} + +type repositoryDIDRemote struct { + Protocol string + KnotHost string + SSHPort int + RepoDID string +} + +func repositoryDIDRemoteURL(remote repositoryDIDRemote) (string, error) { + if _, err := syntax.ParseDID(remote.RepoDID); err != nil { + return "", fmt.Errorf("invalid repository DID %q: %w", remote.RepoDID, err) + } + switch remote.Protocol { + case "ssh": + return knotRemoteURL(remote.KnotHost, remote.SSHPort, remote.RepoDID), nil + case "https": + host := remote.KnotHost + if host == "" || host == knot.DefaultKnot { + host = HostedGitHost + } + return "https://" + host + "/" + remote.RepoDID, nil + default: + return "", fmt.Errorf("unsupported clone protocol %q", remote.Protocol) } - return "git@" + gitHost + ":" + handle + "/" + repo } // RepoContext holds an untrusted repository candidate parsed from a git remote @@ -46,6 +65,7 @@ type RepoContext struct { KnotHost string Handle string Repo string + RepoDID string } // DetectRepoCandidatesFromCWD scans the git remotes in the current directory @@ -104,11 +124,12 @@ func parseRepoCandidate(raw string) (*RepoContext, bool) { if err != nil { return nil, false } - hosted := strings.EqualFold(u.Hostname(), tangledHost) + hosted := strings.EqualFold(u.Hostname(), HostedGitHost) if !hosted && u.Scheme != "ssh" && u.Scheme != "https" { return nil, false } - candidate, ok := splitHandleRepo(strings.TrimPrefix(u.Path, "/")) + path := strings.Trim(strings.TrimPrefix(u.Path, "/"), "/") + candidate, ok := splitRepoPath(path) if !ok { return nil, false } @@ -118,6 +139,16 @@ func parseRepoCandidate(raw string) (*RepoContext, bool) { return candidate, true } +func splitRepoPath(path string) (*RepoContext, bool) { + if strings.Contains(path, "/") { + return splitHandleRepo(path) + } + if _, err := syntax.ParseDID(path); err != nil { + return nil, false + } + return &RepoContext{RepoDID: path}, true +} + // parseGitURL parses a git remote URL, including SCP-like syntax // (e.g. git@host:path), which net/url.Parse does not handle. func parseGitURL(raw string) (*url.URL, error) { diff --git a/internal/gitutil/repo_context_test.go b/internal/gitutil/repo_context_test.go index f8816f3..9890a7c 100644 --- a/internal/gitutil/repo_context_test.go +++ b/internal/gitutil/repo_context_test.go @@ -2,6 +2,7 @@ package gitutil import ( "slices" + "strings" "testing" "github.com/alyraffauf/tg/knot" @@ -9,36 +10,42 @@ import ( func TestParseRepoCandidate(t *testing.T) { tests := []struct { - name string - url string - wantOK bool - wantKnot string - wantHandle string - wantRepo string + name string + url string + wantOK bool + wantKnot string + wantHandle string + wantRepo string + wantRepoDID string }{ - {"ssh scp-like", "git@tangled.org:aly.codes/tg", true, "", "aly.codes", "tg"}, - {"ssh scp-like with .git", "git@tangled.org:aly.codes/tg.git", true, "", "aly.codes", "tg"}, - {"ssh scp-like no user", "tangled.org:aly.codes/tg", true, "", "aly.codes", "tg"}, - {"ssh scp-like trailing slash", "git@tangled.org:aly.codes/tg/", true, "", "aly.codes", "tg"}, - {"ssh:// with user", "ssh://git@tangled.org/aly.codes/tg", true, "", "aly.codes", "tg"}, - {"ssh:// without user", "ssh://tangled.org/aly.codes/tg", true, "", "aly.codes", "tg"}, - {"ssh:// with port", "ssh://git@tangled.org:2222/aly.codes/tg", true, "", "aly.codes", "tg"}, - {"git://", "git://tangled.org/aly.codes/tg", true, "", "aly.codes", "tg"}, - {"git:// with .git", "git://tangled.org/aly.codes/tg.git", true, "", "aly.codes", "tg"}, - {"https", "https://tangled.org/aly.codes/tg", true, "", "aly.codes", "tg"}, - {"https with .git", "https://tangled.org/aly.codes/tg.git", true, "", "aly.codes", "tg"}, - {"https trailing slash", "https://tangled.org/aly.codes/tg/", true, "", "aly.codes", "tg"}, - {"https .git trailing slash", "https://tangled.org/aly.codes/tg.git/", true, "", "aly.codes", "tg"}, - {"https extra segment", "https://tangled.org/aly.codes/tg/extra", false, "", "", ""}, - {"http", "http://tangled.org/aly.codes/tg", true, "", "aly.codes", "tg"}, - {"hostname case insensitive", "git@Tangled.ORG:aly.codes/tg", true, "", "aly.codes", "tg"}, - {"custom Knot ssh", "git@knot.example:aly.codes/tg", true, "knot.example", "aly.codes", "tg"}, - {"custom Knot ssh URL with port", "ssh://git@KNOT.EXAMPLE:2222/aly.codes/tg", true, "knot.example", "aly.codes", "tg"}, - {"github ssh is an untrusted candidate", "git@github.com:alyraffauf/tg.git", true, "github.com", "alyraffauf", "tg"}, - {"github https is an untrusted candidate", "https://github.com/alyraffauf/tg.git", true, "github.com", "alyraffauf", "tg"}, - {"unrelated HTTPS is an untrusted candidate", "https://example.com/foo/bar", true, "example.com", "foo", "bar"}, - {"unrelated git protocol", "git://example.com/foo/bar", false, "", "", ""}, - {"empty", "", false, "", "", ""}, + {"ssh scp-like", "git@tangled.org:aly.codes/tg", true, "", "aly.codes", "tg", ""}, + {"ssh scp-like with .git", "git@tangled.org:aly.codes/tg.git", true, "", "aly.codes", "tg", ""}, + {"ssh scp-like no user", "tangled.org:aly.codes/tg", true, "", "aly.codes", "tg", ""}, + {"ssh scp-like trailing slash", "git@tangled.org:aly.codes/tg/", true, "", "aly.codes", "tg", ""}, + {"ssh:// with user", "ssh://git@tangled.org/aly.codes/tg", true, "", "aly.codes", "tg", ""}, + {"ssh:// without user", "ssh://tangled.org/aly.codes/tg", true, "", "aly.codes", "tg", ""}, + {"ssh:// with port", "ssh://git@tangled.org:2222/aly.codes/tg", true, "", "aly.codes", "tg", ""}, + {"git://", "git://tangled.org/aly.codes/tg", true, "", "aly.codes", "tg", ""}, + {"git:// with .git", "git://tangled.org/aly.codes/tg.git", true, "", "aly.codes", "tg", ""}, + {"https", "https://tangled.org/aly.codes/tg", true, "", "aly.codes", "tg", ""}, + {"https with .git", "https://tangled.org/aly.codes/tg.git", true, "", "aly.codes", "tg", ""}, + {"https trailing slash", "https://tangled.org/aly.codes/tg/", true, "", "aly.codes", "tg", ""}, + {"https .git trailing slash", "https://tangled.org/aly.codes/tg.git/", true, "", "aly.codes", "tg", ""}, + {"https extra segment", "https://tangled.org/aly.codes/tg/extra", false, "", "", "", ""}, + {"http", "http://tangled.org/aly.codes/tg", true, "", "aly.codes", "tg", ""}, + {"hostname case insensitive", "git@Tangled.ORG:aly.codes/tg", true, "", "aly.codes", "tg", ""}, + {"SSH permalink", "git@tangled.org:did:plc:repo", true, "", "", "", "did:plc:repo"}, + {"HTTPS permalink", "https://tangled.org/did:plc:repo", true, "", "", "", "did:plc:repo"}, + {"DID ending in .git", "https://tangled.org/did:web:forge.git/", true, "", "", "", "did:web:forge.git"}, + {"custom Knot permalink", "ssh://git@knot.example/did:plc:repo", true, "knot.example", "", "", "did:plc:repo"}, + {"invalid one-segment path", "git@tangled.org:not-a-did", false, "", "", "", ""}, + {"custom Knot ssh", "git@knot.example:aly.codes/tg", true, "knot.example", "aly.codes", "tg", ""}, + {"custom Knot ssh URL with port", "ssh://git@KNOT.EXAMPLE:2222/aly.codes/tg", true, "knot.example", "aly.codes", "tg", ""}, + {"github ssh is an untrusted candidate", "git@github.com:alyraffauf/tg.git", true, "github.com", "alyraffauf", "tg", ""}, + {"github https is an untrusted candidate", "https://github.com/alyraffauf/tg.git", true, "github.com", "alyraffauf", "tg", ""}, + {"unrelated HTTPS is an untrusted candidate", "https://example.com/foo/bar", true, "example.com", "foo", "bar", ""}, + {"unrelated git protocol", "git://example.com/foo/bar", false, "", "", "", ""}, + {"empty", "", false, "", "", "", ""}, } for _, tt := range tests { @@ -59,6 +66,9 @@ func TestParseRepoCandidate(t *testing.T) { if rc.Repo != tt.wantRepo { t.Errorf("Repo = %q, want %q", rc.Repo, tt.wantRepo) } + if rc.RepoDID != tt.wantRepoDID { + t.Errorf("RepoDID = %q, want %q", rc.RepoDID, tt.wantRepoDID) + } }) } } @@ -68,6 +78,7 @@ func TestKnotRemoteURL(t *testing.T) { name string knotHost string sshPort int + repoPath string want string }{ {name: "hosted proxy", sshPort: 22, want: "git@tangled.org:aly.codes/tg"}, @@ -75,11 +86,17 @@ func TestKnotRemoteURL(t *testing.T) { {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"}, + {name: "repository DID", knotHost: "knot.example", sshPort: 22, want: "git@knot.example:did:plc:repo", repoPath: "did:plc:repo"}, + {name: "repository DID and custom port", knotHost: "knot.example", sshPort: 2222, want: "ssh://git@knot.example:2222/did:plc:repo", repoPath: "did:plc:repo"}, } 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 { + repoPath := tt.repoPath + if repoPath == "" { + repoPath = "aly.codes/tg" + } + if got := knotRemoteURL(tt.knotHost, tt.sshPort, repoPath); got != tt.want { t.Fatalf("knotRemoteURL() = %q, want %q", got, tt.want) } }) @@ -92,21 +109,31 @@ func TestCloneRemoteURL(t *testing.T) { protocol string knotHost string sshPort int + repoDID string expectedURL string expectedErr string }{ {name: "SSH", protocol: "ssh", knotHost: "knot.example", sshPort: 22, expectedURL: "git@knot.example:aly.codes/tg"}, + {name: "SSH permalink", protocol: "ssh", knotHost: "knot.example", sshPort: 22, repoDID: "did:plc:repo", expectedURL: "git@knot.example:did:plc:repo"}, + {name: "SSH permalink with custom port", protocol: "ssh", knotHost: "knot.example", sshPort: 2222, repoDID: "did:plc:repo", expectedURL: "ssh://git@knot.example:2222/did:plc:repo"}, + {name: "SSH permalink without Knot", protocol: "ssh", sshPort: 22, repoDID: "did:plc:repo", expectedURL: "git@tangled.org:did:plc:repo"}, {name: "HTTPS", protocol: "https", knotHost: "knot.example", expectedURL: "https://knot.example/aly.codes/tg.git"}, + {name: "HTTPS permalink", protocol: "https", knotHost: "knot.example", repoDID: "did:plc:repo", expectedURL: "https://knot.example/did:plc:repo"}, + {name: "HTTPS permalink without Knot", protocol: "https", repoDID: "did:plc:repo", expectedURL: "https://tangled.org/did:plc:repo"}, + {name: "invalid permalink", protocol: "ssh", repoDID: "not-a-did", expectedErr: "invalid repository DID \"not-a-did\""}, {name: "HTTPS without knot", protocol: "https", expectedErr: "HTTPS clone requires a Knot host"}, {name: "unsupported protocol", protocol: "git", expectedErr: "unsupported clone protocol \"git\""}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { - cloneURL, err := cloneRemoteURL(testCase.protocol, testCase.knotHost, testCase.sshPort, "aly.codes", "tg") + cloneURL, err := cloneRemoteURL(CloneRepoParams{ + Protocol: testCase.protocol, KnotHost: testCase.knotHost, SSHPort: testCase.sshPort, + Handle: "aly.codes", Repo: "tg", RepoDID: testCase.repoDID, + }) if testCase.expectedErr != "" { - if err == nil || err.Error() != testCase.expectedErr { - t.Fatalf("cloneRemoteURL() error = %v, want %q", err, testCase.expectedErr) + if err == nil || !strings.Contains(err.Error(), testCase.expectedErr) { + t.Fatalf("cloneRemoteURL() error = %v, want containing %q", err, testCase.expectedErr) } return } diff --git a/knot/repo_describe.go b/knot/repo_describe.go new file mode 100644 index 0000000..d4789fe --- /dev/null +++ b/knot/repo_describe.go @@ -0,0 +1,24 @@ +package knot + +import ( + "context" + "fmt" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// RepoDescription identifies the current ATProto record for a repository DID. +type RepoDescription struct { + RepoDID string `json:"repoDid"` + OwnerDID string `json:"ownerDid"` + RKey string `json:"rkey"` +} + +// DescribeRepo returns the repository metadata reported by the Knot. +func (c *Client) DescribeRepo(ctx context.Context, repoDID string) (*RepoDescription, error) { + var description RepoDescription + if err := c.Get(ctx, syntax.NSID("sh.tangled.repo.describeRepo"), map[string]any{"repoDid": repoDID}, &description); err != nil { + return nil, fmt.Errorf("describe repository %q: %w", repoDID, err) + } + return &description, nil +} diff --git a/knot/repo_describe_test.go b/knot/repo_describe_test.go new file mode 100644 index 0000000..4d4d252 --- /dev/null +++ b/knot/repo_describe_test.go @@ -0,0 +1,53 @@ +package knot + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestDescribeRepo(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || request.URL.Path != "/xrpc/sh.tangled.repo.describeRepo" { + t.Fatalf("request = %s %s", request.Method, request.URL.Path) + } + if got := request.URL.Query().Get("repoDid"); got != "did:plc:repository" { + t.Fatalf("repoDid = %q", got) + } + _, _ = writer.Write([]byte(`{"repoDid":"did:plc:repository","ownerDid":"did:plc:owner","rkey":"repo"}`)) + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := NewPublicWithClient(serverURL.Host, server.Client()) + description, err := client.DescribeRepo(context.Background(), "did:plc:repository") + if err != nil { + t.Fatalf("DescribeRepo() error = %v", err) + } + if description.RepoDID != "did:plc:repository" || description.OwnerDID != "did:plc:owner" || description.RKey != "repo" { + t.Fatalf("DescribeRepo() = %+v", description) + } +} + +func TestDescribeRepoWrapsKnotError(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + http.Error(writer, `{"error":"RepoNotFound"}`, http.StatusNotFound) + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := NewPublicWithClient(serverURL.Host, server.Client()) + _, err = client.DescribeRepo(context.Background(), "did:plc:missing") + if err == nil || !strings.Contains(err.Error(), `describe repository "did:plc:missing"`) { + t.Fatalf("DescribeRepo() error = %v", err) + } +} diff --git a/tangled/repo_get_repo.go b/tangled/repo_get_repo.go index b11e22e..dd5290c 100644 --- a/tangled/repo_get_repo.go +++ b/tangled/repo_get_repo.go @@ -6,6 +6,8 @@ import ( "fmt" "github.com/alyraffauf/tg/internal/tangledlex" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" ) type Repo struct { @@ -19,17 +21,34 @@ func (t *Tangled) GetRepo(ctx context.Context, repoURI string) (*Repo, error) { if err != nil { return nil, fmt.Errorf("get tangled repo %q: %w", repoURI, err) } - value, err := recordJSON(response.Value, &tangledlex.Repo{}) + return decodeRepo(response.Uri, response.Cid, response.Value) +} + +// GetRepoByDID returns the repository record with repoDID. +func (t *Tangled) GetRepoByDID(ctx context.Context, repoDID string) (*Repo, error) { + var response struct { + CID *string `json:"cid,omitempty"` + URI string `json:"uri"` + Value *lexutil.LexiconTypeDecoder `json:"value"` + } + if err := t.Client.Get(ctx, syntax.NSID("sh.tangled.repo.getRepoByRepoDid"), map[string]any{"repoDid": repoDID}, &response); err != nil { + return nil, fmt.Errorf("get tangled repo by DID %q: %w", repoDID, err) + } + return decodeRepo(response.URI, response.CID, response.Value) +} + +func decodeRepo(uri string, cid *string, valueDecoder *lexutil.LexiconTypeDecoder) (*Repo, error) { + value, err := recordJSON(valueDecoder, &tangledlex.Repo{}) if err != nil { - return nil, fmt.Errorf("decode tangled repo %q: %w", repoURI, err) + return nil, fmt.Errorf("decode tangled repo %q: %w", uri, err) } var record tangledlex.Repo if err := json.Unmarshal(value, &record); err != nil { - return nil, fmt.Errorf("decode tangled repo %q: %w", repoURI, err) + return nil, fmt.Errorf("decode tangled repo %q: %w", uri, err) } - return &Repo{URI: response.Uri, CID: dereference(response.Cid), Value: record}, nil + return &Repo{URI: uri, CID: dereference(cid), Value: record}, nil } func dereference(value *string) string { diff --git a/tangled/repo_get_repo_test.go b/tangled/repo_get_repo_test.go new file mode 100644 index 0000000..c7acb3c --- /dev/null +++ b/tangled/repo_get_repo_test.go @@ -0,0 +1,33 @@ +package tangled + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bluesky-social/indigo/atproto/atclient" +) + +func TestGetRepoByDID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/xrpc/sh.tangled.repo.getRepoByRepoDid" { + t.Fatalf("request path = %q", request.URL.Path) + } + if got := request.URL.Query().Get("repoDid"); got != "did:plc:repository" { + t.Fatalf("repoDid = %q", got) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"uri":"at://did:plc:owner/sh.tangled.repo/example","value":{"$type":"sh.tangled.repo","knot":"knot.example","repoDid":"did:plc:repository","createdAt":"2026-08-27T00:00:00Z"}}`)) + })) + defer server.Close() + + client := Tangled{Client: &atclient.APIClient{Client: server.Client(), Host: server.URL}} + repo, err := client.GetRepoByDID(context.Background(), "did:plc:repository") + if err != nil { + t.Fatalf("GetRepoByDID() error = %v", err) + } + if repo.URI != "at://did:plc:owner/sh.tangled.repo/example" || repo.Value.RepoDid == nil || *repo.Value.RepoDid != "did:plc:repository" { + t.Fatalf("GetRepoByDID() = %+v", repo) + } +} diff --git a/website/src/content/docs/cookbooks/configuration.md b/website/src/content/docs/cookbooks/configuration.md index 1760be6..89da3ef 100644 --- a/website/src/content/docs/cookbooks/configuration.md +++ b/website/src/content/docs/cookbooks/configuration.md @@ -6,7 +6,7 @@ description: Config file, environment variables, and flags. `tg` resolves configuration values from the following sources, in increasing precedence (later sources override earlier ones): -1. **Defaults** — `appview` is `https://bobbin.klbr.net`; `knot` is unset to permit automatic verified Knot discovery; `ssh-port` is `22`; `protocol` is `ssh` +1. **Defaults.** `appview` is `https://bobbin.klbr.net`. `knot` is unset to permit automatic verified Knot discovery. `ssh-port` is `22`. `protocol` is `ssh`. 2. **Config file** — `$XDG_CONFIG_HOME/tg/config.toml` (or `~/.config/tg/config.toml`) 3. **Environment variables** — prefixed `TG_` (e.g. `TG_APPVIEW`) 4. **Command-line flags** — e.g. `--appview` @@ -23,27 +23,28 @@ ssh-port = 2222 protocol = "ssh" ``` -`knot` and `ssh-port` are defaults for `tg repo create`. They select where a -repository is provisioned and the SSH port used when constructing its initial -clone or push remote. Existing repositories continue to use their configured -Git remotes. +`knot` selects where `tg repo create` provisions a repository. If you select a +Knot explicitly, new repository DID remotes connect to that Knot. `ssh-port` +sets the port for direct SSH clone and push remotes. Automatically selected +Knots use the `tangled.org` proxy on SSH port 22. `protocol` selects the URL used by `tg repo clone` and `tg repo create --clone`. -Set it to `ssh` (the default) or `https`. HTTPS clone URLs use the repository's -recorded Knot. `ssh-port` applies to `tg repo create` and its `--clone` or -`--push` setup; standalone SSH clones use port 22. +Set it to `ssh` (the default) or `https`. If the repository record contains a +repository DID, `tg` uses the DID in the remote. An explicitly selected Knot +uses a direct remote. HTTPS clones that fall back to `handle/repo` use the +repository's recorded Knot. Override the config file location with `--config /path/to/config.toml`. ## Environment variables -| Variable | Config key | Purpose | -| ------------- | ---------- | ------------------------------------- | -| `TG_APPVIEW` | `appview` | Appview host URL | -| `TG_ACCOUNT` | `account` | Account handle or DID | -| `TG_KNOT` | `knot` | Knot host for repo creation | -| `TG_SSH_PORT` | `ssh-port` | SSH port used during repo creation | -| `TG_PROTOCOL` | `protocol` | Clone URL protocol (`ssh` or `https`) | +| Variable | Config key | Purpose | +| ------------- | ---------- | ---------------------------------------- | +| `TG_APPVIEW` | `appview` | Appview host URL | +| `TG_ACCOUNT` | `account` | Account handle or DID | +| `TG_KNOT` | `knot` | Knot host for repo creation | +| `TG_SSH_PORT` | `ssh-port` | SSH port for an explicitly selected Knot | +| `TG_PROTOCOL` | `protocol` | Clone URL protocol (`ssh` or `https`) | Keys containing `.` or `-` map to `TG_`-prefixed underscore-separated names (e.g. `foo.bar` → `TG_FOO_BAR`). diff --git a/website/src/content/docs/index.mdx b/website/src/content/docs/index.mdx index dc33da4..55ab63f 100644 --- a/website/src/content/docs/index.mdx +++ b/website/src/content/docs/index.mdx @@ -19,6 +19,7 @@ import { Card, CardGrid, LinkCard } from "@astrojs/starlight/components"; ## Install + {/* prettier-ignore */} ```bash brew tap alyraffauf/tap @@ -26,6 +27,7 @@ import { Card, CardGrid, LinkCard } from "@astrojs/starlight/components"; brew install alyraffauf/tap/tg ``` + {/* prettier-ignore */} ```bash nix profile add github:alyraffauf/tg @@ -38,6 +40,9 @@ import { Card, CardGrid, LinkCard } from "@astrojs/starlight/components"; ```bash tg auth login alice.example.com tg repo clone aly.codes/tg + +# Clone by repository DID and use the DID in the origin URL +tg repo clone did:plc:g5uweck3xar3m745g43giuhr ``` ## Guides -- 2.51.2