A CLI for tangled operations.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make repo clone protocol configurable #3

merged opened by onev.cat targeting main from onev.cat/tang: feature/clone-protocol-config

Summary#

  • Add clone.protocol config with https default and ssh support.
  • Make tang repo clone OWNER/REPO choose HTTPS or SSH from clone.protocol.
  • Pass explicit clone URLs directly to git clone.
  • Keep hosted knot1.tangled.sh clone URLs aligned with Tangled web by using tangled.org.
  • Update README and Phase 3 notes.

Validation#

  • go test ./...
  • make build
  • make lint
  • E2E default HTTPS clone with isolated config.
  • E2E clone.protocol=ssh clone with isolated config.
  • E2E explicit HTTPS URL clone while config was set to SSH.

Tracking issue: #1

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:kl2ejrmz5zmxnno3ll4luz76/sh.tangled.repo.pull/3mkuxmid3fd22
+159 -11
Diff #0
+17 -4
README.md
··· 153 153 ```sh 154 154 tang repo clone onev.cat/tang-playground 155 155 tang repo clone onev.cat/tang-playground ./playground 156 + tang repo clone git@tangled.org:onev.cat/tang-playground ./playground 156 157 ``` 157 158 158 - `repo clone` uses the SSH clone URL shown by the Tangled web UI, for example 159 - `git@tangled.org:onev.cat/tang-playground`. Make sure the matching public key is 160 - registered in Tangled before cloning private or writable repositories. 159 + When the repository argument is `OWNER/REPO`, `repo clone` chooses the clone URL 160 + from `clone.protocol`. The default is `https`, matching GitHub CLI's configured 161 + protocol model. Set it to `ssh` when you want Tangled's SSH clone URL: 162 + 163 + ```sh 164 + tang config get clone.protocol 165 + tang config set clone.protocol ssh 166 + tang config set clone.protocol https 167 + ``` 168 + 169 + When the repository argument is an explicit clone URL, `repo clone` passes it 170 + directly to `git clone` and does not consult `clone.protocol`. 161 171 162 172 Create a repository: 163 173 ··· 290 300 tang config list 291 301 tang config get knot.hosts 292 302 tang config set knot.hosts knot1.tangled.sh,tangled.org 303 + tang config get clone.protocol 304 + tang config set clone.protocol ssh 293 305 tang config set appview.url https://tangled.org 294 306 tang config set constellation.url https://constellation.microcosm.blue 295 307 tang config set remote origin ··· 357 369 - Some older pull request records do not contain patch rounds, so `pr diff` and 358 370 `pr checkout` cannot operate on them. 359 371 - SSH clone depends on Tangled's SSH key authorization index. If a freshly added 360 - key is rejected, retry after the key appears in `tang ssh-key list`. 372 + key is rejected, retry after the key appears in `tang ssh-key list`, or use 373 + the default HTTPS clone protocol. 361 374 - `repo create` requires a create-capable knot. Pass `--knot` when the default 362 375 service route is not sufficient. 363 376 - `pr merge` depends on the repository knot's `sh.tangled.repo.merge` endpoint.
+4
ai-docs/2026-05-02-PHASE_3_repo_pr.md
··· 48 48 - Created PR `at://did:plc:kl2ejrmz5zmxnno3ll4luz76/sh.tangled.repo.pull/3mkuuamwfj322`. 49 49 - `tang pr merge 3mkuuamwfj322 --subject "Merge Phase 3 E2E"` succeeded. 50 50 - Deleted both temporary remote branches afterward. 51 + - Completed follow-up on 2026-05-03: `clone.protocol` default E2E used an isolated config directory, confirmed `clone.protocol=https`, cloned `onev.cat/tang-playground`, and observed origin `https://tangled.org/onev.cat/tang-playground`. 52 + - Completed follow-up on 2026-05-03: `clone.protocol=ssh` E2E used an isolated config directory, cloned `onev.cat/tang-playground`, and observed origin `git@tangled.org:onev.cat/tang-playground`. 53 + - Completed follow-up on 2026-05-03: explicit URL E2E set `clone.protocol=ssh`, cloned `https://tangled.org/onev.cat/tang-playground`, and confirmed the explicit HTTPS URL was preserved. 51 54 52 55 ## Notes 53 56 ··· 56 59 - `pr merge` does not expose `--squash`, `--rebase`, or `--merge`, matching the current Tangled merge endpoint. 57 60 - Follow-up on 2026-05-03: `knot1.tangled.sh` is now the first default knot host, so `repo create` uses the create-capable knot by default. `tangled.org` remains in the default host list for AppView-style and existing remote URLs. 58 61 - Follow-up on 2026-05-03: Tangled web maps repos whose record knot is `knot1.tangled.sh` to hosted clone URLs on `tangled.org`. After refreshing the account SSH key, `git clone git@tangled.org:onev.cat/tang-playground` succeeded, so `repo clone` now uses the web-equivalent SSH clone URL. 62 + - Follow-up on 2026-05-03: `repo clone` now follows `clone.protocol` (`https` by default, `ssh` optional) for `OWNER/REPO` inputs, while explicit clone URLs are passed directly to `git clone`. Unit tests cover config validation, hosted clone URL mapping, protocol selection, and explicit URL detection. 59 63 60 64 ## Completion 61 65
+1 -1
internal/cli/config.go
··· 74 74 if rendered, err := renderJSONIfRequested(cmd, opts, values); rendered || err != nil { 75 75 return err 76 76 } 77 - for _, key := range []string{"knot.hosts", "constellation.url", "appview.url", "remote"} { 77 + for _, key := range []string{"knot.hosts", "constellation.url", "appview.url", "clone.protocol", "remote"} { 78 78 if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s=%s\n", key, formatConfigValue(values[key])); err != nil { 79 79 return err 80 80 }
+19 -5
internal/cli/repo.go
··· 8 8 "github.com/spf13/cobra" 9 9 "tangled.org/onev.cat/tang/internal/auth" 10 10 "tangled.org/onev.cat/tang/internal/config" 11 + tanggit "tangled.org/onev.cat/tang/internal/git" 11 12 localrepo "tangled.org/onev.cat/tang/internal/repo" 12 13 "tangled.org/onev.cat/tang/internal/tangled" 13 14 ) ··· 123 124 124 125 func newRepoCloneCommand() *cobra.Command { 125 126 return &cobra.Command{ 126 - Use: "clone <owner/name> [dir]", 127 + Use: "clone <owner/name|url> [dir]", 127 128 Short: "Clone a repository", 128 129 Args: cobra.RangeArgs(1, 2), 129 130 RunE: func(cmd *cobra.Command, args []string) error { 130 - owner, name, err := splitOwnerRepo(args[0]) 131 - if err != nil { 132 - return err 133 - } 134 131 dir := "" 135 132 if len(args) > 1 { 136 133 dir = args[1] 137 134 } 135 + if isExplicitCloneURL(args[0]) { 136 + return tanggit.Clone(cmd.Context(), args[0], dir) 137 + } 138 + owner, name, err := splitOwnerRepo(args[0]) 139 + if err != nil { 140 + return err 141 + } 138 142 cfg, err := config.Load() 139 143 if err != nil { 140 144 return err ··· 166 170 } 167 171 return parts[0], parts[1], nil 168 172 } 173 + 174 + func isExplicitCloneURL(input string) bool { 175 + if strings.Contains(input, "://") { 176 + return true 177 + } 178 + at := strings.Index(input, "@") 179 + colon := strings.Index(input, ":") 180 + slash := strings.Index(input, "/") 181 + return at > 0 && colon > at && (slash == -1 || colon < slash) 182 + }
+18
internal/cli/repo_test.go
··· 1 + package cli 2 + 3 + import "testing" 4 + 5 + func TestIsExplicitCloneURL(t *testing.T) { 6 + tests := map[string]bool{ 7 + "git@tangled.org:onev.cat/tang": true, 8 + "https://tangled.org/onev.cat/tang": true, 9 + "ssh://git@tangled.org/onev.cat/tang": true, 10 + "onev.cat/tang": false, 11 + "did:plc:abc/tang": false, 12 + } 13 + for input, want := range tests { 14 + if got := isExplicitCloneURL(input); got != want { 15 + t.Fatalf("isExplicitCloneURL(%q) = %v, want %v", input, got, want) 16 + } 17 + } 18 + }
+29
internal/config/store.go
··· 18 18 ) 19 19 20 20 var ErrUnsupportedKey = errors.New("unsupported config key") 21 + var ErrUnsupportedValue = errors.New("unsupported config value") 21 22 22 23 type Config struct { 23 24 Knot KnotConfig `toml:"knot" json:"knot"` 24 25 Constellation ConstellationConfig `toml:"constellation" json:"constellation"` 25 26 AppView AppViewConfig `toml:"appview" json:"appview"` 27 + Clone CloneConfig `toml:"clone" json:"clone"` 26 28 Remote string `toml:"remote,omitempty" json:"remote,omitempty"` 27 29 28 30 path string ··· 40 42 URL string `toml:"url" json:"url"` 41 43 } 42 44 45 + type CloneConfig struct { 46 + Protocol string `toml:"protocol" json:"protocol"` 47 + } 48 + 43 49 func Defaults() *Config { 44 50 return &Config{ 45 51 Knot: KnotConfig{Hosts: []string{DefaultKnotHost, LegacyKnotHost}}, 46 52 Constellation: ConstellationConfig{URL: DefaultConstellationURL}, 47 53 AppView: AppViewConfig{URL: DefaultAppViewURL}, 54 + Clone: CloneConfig{Protocol: "https"}, 48 55 } 49 56 } 50 57 ··· 90 97 return c.Constellation.URL, nil 91 98 case "appview.url": 92 99 return c.AppView.URL, nil 100 + case "clone.protocol": 101 + return c.Clone.Protocol, nil 93 102 case "remote": 94 103 return c.Remote, nil 95 104 case "pds.url": ··· 111 120 c.Constellation.URL = strings.TrimSpace(value) 112 121 case "appview.url": 113 122 c.AppView.URL = strings.TrimSpace(value) 123 + case "clone.protocol": 124 + protocol, err := normalizeCloneProtocol(value) 125 + if err != nil { 126 + return err 127 + } 128 + c.Clone.Protocol = protocol 114 129 case "remote": 115 130 c.Remote = strings.TrimSpace(value) 116 131 case "pds.url": ··· 130 145 "knot.hosts": append([]string(nil), c.Knot.Hosts...), 131 146 "constellation.url": constellationURL, 132 147 "appview.url": c.AppView.URL, 148 + "clone.protocol": c.Clone.Protocol, 133 149 "remote": c.Remote, 134 150 } 135 151 } ··· 166 182 if c.AppView.URL == "" { 167 183 c.AppView.URL = DefaultAppViewURL 168 184 } 185 + if c.Clone.Protocol == "" { 186 + c.Clone.Protocol = "https" 187 + } 169 188 } 170 189 171 190 func defaultPath() (string, error) { ··· 196 215 } 197 216 return out 198 217 } 218 + 219 + func normalizeCloneProtocol(value string) (string, error) { 220 + protocol := strings.ToLower(strings.TrimSpace(value)) 221 + switch protocol { 222 + case "ssh", "https": 223 + return protocol, nil 224 + default: 225 + return "", fmt.Errorf("%w: clone.protocol must be ssh or https", ErrUnsupportedValue) 226 + } 227 + }
+19
internal/config/store_test.go
··· 15 15 if !reflect.DeepEqual(cfg.Knot.Hosts, []string{"knot1.tangled.sh", "tangled.org"}) { 16 16 t.Fatalf("default knot hosts = %#v", cfg.Knot.Hosts) 17 17 } 18 + if cfg.Clone.Protocol != "https" { 19 + t.Fatalf("default clone protocol = %q", cfg.Clone.Protocol) 20 + } 18 21 if cfg.Constellation.URL != DefaultConstellationURL { 19 22 t.Fatalf("default constellation URL = %q", cfg.Constellation.URL) 20 23 } ··· 35 38 if err := cfg.Set("constellation.url", "https://constellation.example.com"); err != nil { 36 39 t.Fatalf("Set constellation.url returned error: %v", err) 37 40 } 41 + if err := cfg.Set("clone.protocol", "ssh"); err != nil { 42 + t.Fatalf("Set clone.protocol returned error: %v", err) 43 + } 38 44 39 45 reloaded, err := LoadAt(path) 40 46 if err != nil { ··· 46 52 if reloaded.Constellation.URL != "https://constellation.example.com" { 47 53 t.Fatalf("reloaded constellation URL = %q", reloaded.Constellation.URL) 48 54 } 55 + if reloaded.Clone.Protocol != "ssh" { 56 + t.Fatalf("reloaded clone protocol = %q", reloaded.Clone.Protocol) 57 + } 58 + } 59 + 60 + func TestCloneProtocolValidation(t *testing.T) { 61 + cfg, err := LoadAt(filepath.Join(t.TempDir(), "config.toml")) 62 + if err != nil { 63 + t.Fatalf("LoadAt returned error: %v", err) 64 + } 65 + if err := cfg.Set("clone.protocol", "git"); !errors.Is(err, ErrUnsupportedValue) { 66 + t.Fatalf("Set clone.protocol invalid error = %v", err) 67 + } 49 68 } 50 69 51 70 func TestPDSURLIsUnsupported(t *testing.T) {
+20 -1
internal/tangled/repos.go
··· 130 130 if err != nil { 131 131 return err 132 132 } 133 - return git.Clone(ctx, repo.CloneSSH, dir) 133 + url, err := s.CloneURL(*repo) 134 + if err != nil { 135 + return err 136 + } 137 + return git.Clone(ctx, url, dir) 138 + } 139 + 140 + func (s *RepoService) CloneURL(repo Repo) (string, error) { 141 + protocol := "https" 142 + if s.Config != nil && s.Config.Clone.Protocol != "" { 143 + protocol = s.Config.Clone.Protocol 144 + } 145 + switch protocol { 146 + case "ssh": 147 + return repo.CloneSSH, nil 148 + case "https": 149 + return repo.CloneHTTPS, nil 150 + default: 151 + return "", fmt.Errorf("%w: clone.protocol must be ssh or https", config.ErrUnsupportedValue) 152 + } 134 153 } 135 154 136 155 func repoFromRecord(owner, uri, cid string, record *core.Repo) Repo {
+32
internal/tangled/repos_test.go
··· 1 1 package tangled 2 2 3 3 import ( 4 + "errors" 4 5 "testing" 5 6 6 7 core "tangled.org/core/api/tangled" 8 + "tangled.org/onev.cat/tang/internal/config" 7 9 ) 8 10 9 11 func TestRepoFromRecordBuildsCloneURLs(t *testing.T) { ··· 33 35 t.Fatalf("CloneHTTPS = %q", repo.CloneHTTPS) 34 36 } 35 37 } 38 + 39 + func TestRepoCloneURLUsesConfiguredProtocol(t *testing.T) { 40 + repo := Repo{ 41 + CloneSSH: "git@tangled.org:onev.cat/tang", 42 + CloneHTTPS: "https://tangled.org/onev.cat/tang", 43 + } 44 + service := NewRepoService(&config.Config{Clone: config.CloneConfig{Protocol: "https"}}, nil) 45 + got, err := service.CloneURL(repo) 46 + if err != nil { 47 + t.Fatalf("CloneURL https error = %v", err) 48 + } 49 + if got != repo.CloneHTTPS { 50 + t.Fatalf("CloneURL https = %q", got) 51 + } 52 + service.Config.Clone.Protocol = "ssh" 53 + got, err = service.CloneURL(repo) 54 + if err != nil { 55 + t.Fatalf("CloneURL ssh error = %v", err) 56 + } 57 + if got != repo.CloneSSH { 58 + t.Fatalf("CloneURL ssh = %q", got) 59 + } 60 + } 61 + 62 + func TestRepoCloneURLRejectsUnsupportedProtocol(t *testing.T) { 63 + service := NewRepoService(&config.Config{Clone: config.CloneConfig{Protocol: "git"}}, nil) 64 + if _, err := service.CloneURL(Repo{}); !errors.Is(err, config.ErrUnsupportedValue) { 65 + t.Fatalf("CloneURL invalid protocol error = %v", err) 66 + } 67 + }

History

1 round 0 comments
sign up or login to add to the discussion
onev.cat submitted #0
1 commit
expand
Make repo clone protocol configurable
expand 0 comments
pull request successfully merged