Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,12 @@ Generated by `go run ./tools/api_inventory`. This is the public BucketGit Go SDK

## `github.com/bucketgit/bgit/broker/local`

- `const TargetLogicalAlias`
- `const TargetStorageExplicit`
- `const TargetStorageShorthand`
- `func IsZeroOID`
- `func New`
- `func ParseTarget`
- `method Broker.ArchiveIssue`
- `method Broker.AssignIssue`
- `method Broker.Authorize`
Expand Down Expand Up @@ -395,6 +399,7 @@ Generated by `go run ./tools/api_inventory`. This is the public BucketGit Go SDK
- `method RepositoryStore.Read`
- `method RepositoryStore.Write`
- `method ResolverFunc.Resolve`
- `method Target.Provider`
- `type Broker`
- `type IdentityVerifier`
- `type IdentityVerifierFunc`
Expand All @@ -407,6 +412,8 @@ Generated by `go run ./tools/api_inventory`. This is the public BucketGit Go SDK
- `type Resolver`
- `type ResolverFunc`
- `type StoryMove`
- `type Target`
- `type TargetKind`

## `github.com/bucketgit/bgit/transport`

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to `bgit` are documented in this file.

This project follows semantic versioning.

## 1.4.1

Fixed

- Native Git remote-helper sessions now resolve `file://`, `s3://`, and `gs://`
local-broker shorthand consistently with `bgit clone`, rehydrate existing
bucket-backed broker state without creating repositories, and resolve bare
logical aliases only from unambiguous checkout or `BGIT_HOME` state.

## 1.4.0

Added
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,21 @@ BucketGit remote helper for `bgit://` and `bgit::` remotes:

```bash
git clone bgit::https://broker.example.com/demo.git
git remote add origin bgit::demo.git
git clone bgit::gs://demo.git
git clone bgit::s3://demo.git
git clone bgit::file://demo.git
git remote add origin bgit://demo.git
git fetch origin
git push origin main
```

`bgit::https://broker.example.com/demo.git` carries the broker URL explicitly.
`bgit::demo.git` and `bgit://demo.git` resolve through the current checkout's
BucketGit broker configuration.
`bgit::gs://demo.git`, `bgit::s3://demo.git`, and `bgit::file://demo.git` use
the same local-broker shorthand as `bgit clone`, making them suitable for a
fresh checkout or stateless workload. They open existing broker state and do
not create a missing repository. `bgit://demo.git` is a logical alias resolved
from the current checkout or an unambiguous repository mapping in `BGIT_HOME`;
use an explicit storage or broker URL when no such mapping exists.

## Custom Domains

Expand Down
3 changes: 2 additions & 1 deletion SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ See [API.md](API.md) for the generated public surface,
- `broker/capability`: broker-authorized object access through S3 STS, GCS
signed URLs, or local capabilities, including supported legacy reads.
- `broker/local`: in-process local broker persistence, scoped repository stores,
ref CAS, and issue/task-board services.
ref CAS, issue/task-board services, and provider-neutral classification of
logical, shorthand, and explicit storage targets.
- `transport`: pkt-line framing, advertised capabilities, upload-pack,
receive-pack, and Git remote-helper protocol handling.

Expand Down
103 changes: 103 additions & 0 deletions broker/local/target.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package local

import (
"errors"
"net/url"
"strings"

"github.com/bucketgit/bgit/protocol"
)

// TargetKind identifies how a BucketGit repository address must be resolved.
type TargetKind string

const (
TargetLogicalAlias TargetKind = "logical-alias"
TargetStorageShorthand TargetKind = "storage-shorthand"
TargetStorageExplicit TargetKind = "storage-explicit"
)

// Target is a provider-neutral classification of a local-broker repository
// address. Shorthand targets have no Prefix and require identity-based bucket
// resolution; explicit targets name a physical bucket and repository prefix.
type Target struct {
Kind TargetKind
Scheme string
Logical string
Bucket string
Prefix string
Original string
}

// ParseTarget classifies logical aliases and file, S3, or GCS repository
// addresses without performing credential lookup or provisioning resources.
func ParseTarget(raw string) (Target, error) {
original := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "bgit::"))
if original == "" {
return Target{}, errors.New("repository target is required")
}
if !strings.Contains(original, "://") {
logical, err := normalizeTargetLogical(original)
if err != nil {
return Target{}, err
}
return Target{Kind: TargetLogicalAlias, Logical: logical, Original: original}, nil
}
parsed, err := url.Parse(original)
if err != nil {
return Target{}, err
}
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
if scheme == "gcs" {
scheme = "gs"
}
if scheme != "file" && scheme != "s3" && scheme != "gs" {
return Target{}, errors.New("repository target must use file://, s3://, or gs://")
}
name := strings.TrimSpace(parsed.Host)
if name == "" && scheme == "file" {
name = strings.Trim(strings.TrimSpace(parsed.Path), "/")
parsed.Path = ""
}
if name == "" {
return Target{}, errors.New("repository target must include a repository or bucket name")
}
prefix := strings.Trim(strings.TrimSpace(parsed.Path), "/")
if prefix == "" {
logical, err := normalizeTargetLogical(name)
if err != nil {
return Target{}, err
}
return Target{Kind: TargetStorageShorthand, Scheme: scheme, Logical: logical, Original: original}, nil
}
if scheme == "file" {
return Target{}, errors.New("file repository shorthand must not include a path")
}
parts := strings.Split(prefix, "/")
logical, err := normalizeTargetLogical(parts[len(parts)-1])
if err != nil {
return Target{}, err
}
return Target{Kind: TargetStorageExplicit, Scheme: scheme, Logical: logical, Bucket: name, Prefix: prefix, Original: original}, nil
}

func normalizeTargetLogical(name string) (string, error) {
name = strings.TrimSuffix(strings.TrimSpace(name), ".git")
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\\`) {
return "", errors.New("repository target must include a flat repository name")
}
return name + ".git", nil
}

func (t Target) Provider() protocol.Provider {
switch t.Scheme {
case "file":
return protocol.ProviderFile
case "s3":
return protocol.ProviderS3
case "gs":
return protocol.ProviderGCS
default:
return ""
}
}
40 changes: 40 additions & 0 deletions broker/local/target_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package local

import "testing"

func TestParseTarget(t *testing.T) {
tests := []struct {
name string
raw string
kind TargetKind
scheme string
bucket string
prefix string
}{
{name: "logical", raw: "demo", kind: TargetLogicalAlias},
{name: "gcs shorthand", raw: "bgit::gs://demo", kind: TargetStorageShorthand, scheme: "gs"},
{name: "s3 shorthand", raw: "s3://demo.git", kind: TargetStorageShorthand, scheme: "s3"},
{name: "file shorthand", raw: "file://demo", kind: TargetStorageShorthand, scheme: "file"},
{name: "gcs explicit", raw: "gs://physical/repos/demo.git", kind: TargetStorageExplicit, scheme: "gs", bucket: "physical", prefix: "repos/demo.git"},
{name: "s3 explicit", raw: "s3://physical/demo.git", kind: TargetStorageExplicit, scheme: "s3", bucket: "physical", prefix: "demo.git"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ParseTarget(test.raw)
if err != nil {
t.Fatal(err)
}
if got.Kind != test.kind || got.Scheme != test.scheme || got.Bucket != test.bucket || got.Prefix != test.prefix || got.Logical != "demo.git" {
t.Fatalf("target = %#v", got)
}
})
}
}

func TestParseTargetRejectsInvalidAddresses(t *testing.T) {
for _, raw := range []string{"", "https://example.com/demo.git", "gs://", "file://demo/path"} {
if _, err := ParseTarget(raw); err == nil {
t.Fatalf("ParseTarget(%q) succeeded", raw)
}
}
}
35 changes: 6 additions & 29 deletions internal/app/broker_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"strings"
"time"

localbroker "github.com/bucketgit/bgit/broker/local"
internalconfig "github.com/bucketgit/bgit/internal/config"
"github.com/bucketgit/bgit/protocol"
"golang.org/x/crypto/ssh"
Expand Down Expand Up @@ -2211,38 +2212,14 @@ func logicalRepoFromStorageTarget(target string) (string, error) {
}

func storageTargetParts(target string) (scheme, profile, region, bucket string, ok bool) {
raw := strings.TrimSpace(target)
switch {
case strings.HasPrefix(raw, "s3://"):
scheme = "s3"
raw = strings.TrimPrefix(raw, "s3://")
case strings.HasPrefix(raw, "gs://"):
scheme = "gs"
raw = strings.TrimPrefix(raw, "gs://")
case strings.HasPrefix(raw, "file://"):
scheme = "file"
raw = strings.TrimPrefix(raw, "file://")
default:
parsed, err := localbroker.ParseTarget(target)
if err != nil || (parsed.Kind != localbroker.TargetStorageShorthand && parsed.Kind != localbroker.TargetStorageExplicit) {
return "", "", "", "", false
}
if slash := strings.Index(raw, "/"); slash >= 0 {
if scheme == "s3" || scheme == "gs" {
return scheme, "", "", "", true
}
raw = raw[:slash]
}
raw = strings.TrimSuffix(raw, ".git")
parts := strings.Split(raw, ".")
labels := parts[:0]
for _, part := range parts {
if strings.TrimSpace(part) != "" {
labels = append(labels, strings.TrimSpace(part))
}
}
if len(labels) == 0 {
return scheme, "", "", "", true
if parsed.Kind == localbroker.TargetStorageExplicit {
return parsed.Scheme, "", "", "", true
}
return scheme, "", "", strings.Join(labels, "."), true
return parsed.Scheme, "", "", strings.TrimSuffix(parsed.Logical, ".git"), true
}

func storageProfileRegionFromOptions(target, selectedProfile, selectedRegion string) (string, string) {
Expand Down
Loading