Repository: git-town/git-town

Issue:
Add support for Codeberg.org
### Your message to us

Please add support for [Codeberg.org](https://codeberg.org) as a hosting platform 

[Codeberg.org](https://codeberg.org) is a [Forgejo](https://forgejo.org)-based collaborative development environment maintained by Codeberg e.V.

Thank you 🙏

Discussion:
@kevgo: Definitely! Supporting Codeberg and self-hosted Forgejo instances is super easy. The various forge drivers live in the [internal/forge](https://github.com/git-town/git-town/tree/main/internal/forge) package. Each driver simply needs to implement the [Connector](https://github.com/git-town/git-town/blob/30bdec53724c3b45e5e8b0b059394f94a9714574/internal/forge/forgedomain/connector.go#L14) interface.

For Codeberg, a good starting point is the existing [GitHub driver](https://github.com/git-town/git-town/tree/main/internal/forge/github). You can copy it into a new `codeberg` folder and update the URLs and other implementation details.

For self-hosted Forgejo instances, the [Bitbucket Datacenter driver](https://github.com/git-town/git-town/tree/main/internal/forge/bitbucketdatacenter) might be a good reference.
@stefanfrede: I'm happy to help, but I doubt I can do more than test.

Hopefully these links will help:
https://docs.codeberg.org
https://codeberg.org/api/swagger@kevgo: This is built now and will be released soon. Closing the ticket for now.@stefanfrede: @kevgo Thanks a lot 🙏I couldn't wait and installed from source and it works like a charm 😀@kevgo: Great to hear, thanks for the feedback! 👍 @kevgo: This is now available in [v18.2](https://github.com/git-town/git-town/releases/tag/v18.2.0). Enjoy!

Solution patch:
diff --git a/internal/cmd/sync/sync_deleted_branch.go b/internal/cmd/sync/sync_deleted_branch.go
index 90c95d947..feceec838 100644
--- a/internal/cmd/sync/sync_deleted_branch.go
+++ b/internal/cmd/sync/sync_deleted_branch.go
@@ -55,7 +55,7 @@ func syncDeletedFeatureBranchProgram(prog Mutable[program.Program], branch gitdo
 			program:            prog,
 			syncStrategy:       args.Config.NormalConfig.SyncFeatureStrategy,
 		})
-		prog.Value.Add(&opcodes.BranchDeleteIfEmptyAtRuntime{Branch: branch})
+		prog.Value.Add(&opcodes.BranchWithRemoteGoneDeleteIfEmptyAtRuntime{Branch: branch})
 	}
 }
 
diff --git a/internal/forge/codeberg/connector.go b/internal/forge/codeberg/connector.go
new file mode 100644
index 000000000..9c5277c18
--- /dev/null
+++ b/internal/forge/codeberg/connector.go
@@ -0,0 +1,239 @@
+package codeberg
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+	"strconv"
+
+	"codeberg.org/mvdkleijn/forgejo-sdk"
+	"github.com/git-town/git-town/v18/internal/cli/colors"
+	"github.com/git-town/git-town/v18/internal/cli/print"
+	"github.com/git-town/git-town/v18/internal/config/configdomain"
+	"github.com/git-town/git-town/v18/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v18/internal/git/gitdomain"
+	"github.com/git-town/git-town/v18/internal/git/giturl"
+	"github.com/git-town/git-town/v18/internal/gohacks/stringslice"
+	"github.com/git-town/git-town/v18/internal/messages"
+	. "github.com/git-town/git-town/v18/pkg/prelude"
+	"golang.org/x/oauth2"
+)
+
+// Connector provides standardized connectivity for the given repository (codeberg.org/owner/repo)
+// via the Codeberg API.
+type Connector struct {
+	forgedomain.Data
+	APIToken Option[configdomain.CodebergToken]
+	client   *codeberg.Client
+	log      print.Logger
+}
+
+func (self Connector) DefaultProposalMessage(proposal forgedomain.Proposal) string {
+	return fmt.Sprintf("%s (#%d)", proposal.Title, proposal.Number)
+}
+
+func (self Connector) FindProposalFn() Option[func(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
+	if len(forgedomain.ReadProposalOverride()) > 0 {
+		return Some(self.findProposalViaOverride)
+	}
+	if self.APIToken.IsSome() {
+		return Some(self.findProposalViaAPI)
+	}
+	return None[func(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)]()
+}
+
+func (self Connector) NewProposalURL(branch, parentBranch, _ gitdomain.LocalBranchName, _ gitdomain.ProposalTitle, _ gitdomain.ProposalBody) (string, error) {
+	toCompare := parentBranch.String() + "..." + branch.String()
+	return fmt.Sprintf("%s/compare/%s", self.RepositoryURL(), url.PathEscape(toCompare)), nil
+}
+
+func (self Connector) RepositoryURL() string {
+	return fmt.Sprintf("https://%s/%s/%s", self.HostnameWithStandardPort(), self.Organization, self.Repository)
+}
+
+func (self Connector) SearchProposalFn() Option[func(branch gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)] {
+	if self.APIToken.IsSome() {
+		return Some(self.searchProposal)
+	}
+	return None[func(branch gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error)]()
+}
+
+func (self Connector) SquashMergeProposalFn() Option[func(number int, message gitdomain.CommitMessage) error] {
+	if self.APIToken.IsSome() {
+		return Some(self.squashMergeProposal)
+	}
+	return None[func(number int, message gitdomain.CommitMessage) error]()
+}
+
+func (self Connector) UpdateProposalSourceFn() Option[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error] {
+	return None[func(number int, _ gitdomain.LocalBranchName, finalMessages stringslice.Collector) error]()
+}
+
+func (self Connector) UpdateProposalTargetFn() Option[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error] {
+	if self.APIToken.IsSome() {
+		return Some(self.updateProposalTarget)
+	}
+	return None[func(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error]()
+}
+
+func (self Connector) findProposalViaAPI(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
+	self.log.Start(messages.APIProposalLookupStart)
+	openPullRequests, _, err := self.client.ListRepoPullRequests(self.Organization, self.Repository, codeberg.ListPullRequestsOptions{
+		ListOptions: codeberg.ListOptions{
+			PageSize: 50,
+		},
+		State: codeberg.StateOpen,
+	})
+	if err != nil {
+		self.log.Failed(err.Error())
+		return None[forgedomain.Proposal](), err
+	}
+	pullRequests := FilterPullRequests(openPullRequests, branch, target)
+	switch len(pullRequests) {
+	case 0:
+		self.log.Success("none")
+		return None[forgedomain.Proposal](), nil
+	case 1:
+		proposal := parsePullRequest(pullRequests[0])
+		self.log.Success(proposal.Target.String())
+		return Some(proposal), nil
+	default:
+		return None[forgedomain.Proposal](), fmt.Errorf(messages.ProposalMultipleFromToFound, len(pullRequests), branch, target)
+	}
+}
+
+func (self Connector) findProposalViaOverride(branch, target gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
+	self.log.Start(messages.APIProposalLookupStart)
+	self.log.Ok()
+	proposalURLOverride := forgedomain.ReadProposalOverride()
+	if proposalURLOverride == forgedomain.OverrideNoProposal {
+		return None[forgedomain.Proposal](), nil
+	}
+	return Some(forgedomain.Proposal{
+		MergeWithAPI: true,
+		Number:       123,
+		Source:       branch,
+		Target:       target,
+		Title:        "title",
+		URL:          proposalURLOverride,
+	}), nil
+}
+
+func (self Connector) searchProposal(branch gitdomain.LocalBranchName) (Option[forgedomain.Proposal], error) {
+	self.log.Start(messages.APIParentBranchLookupStart, branch.String())
+	openPullRequests, _, err := self.client.ListRepoPullRequests(self.Organization, self.Repository, codeberg.ListPullRequestsOptions{
+		ListOptions: codeberg.ListOptions{
+			PageSize: 50,
+		},
+		State: codeberg.StateOpen,
+	})
+	if err != nil {
+		self.log.Failed(err.Error())
+		return None[forgedomain.Proposal](), err
+	}
+	pullRequests := FilterPullRequests2(openPullRequests, branch)
+	switch len(pullRequests) {
+	case 0:
+		self.log.Success("none")
+		return None[forgedomain.Proposal](), nil
+	case 1:
+		pullRequest := pullRequests[0]
+		proposal := parsePullRequest(pullRequest)
+		self.log.Success(proposal.Target.String())
+		return Some(proposal), nil
+	default:
+		return None[forgedomain.Proposal](), fmt.Errorf(messages.ProposalMultipleFromFound, len(pullRequests), branch)
+	}
+}
+
+func (self Connector) squashMergeProposal(number int, message gitdomain.CommitMessage) error {
+	if number <= 0 {
+		return errors.New(messages.ProposalNoNumberGiven)
+	}
+	commitMessageParts := message.Parts()
+	self.log.Start(messages.ForgeGithubMergingViaAPI, colors.BoldGreen().Styled(strconv.Itoa(number)))
+	_, _, err := self.client.MergePullRequest(self.Organization, self.Repository, int64(number), codeberg.MergePullRequestOption{
+		Style:   codeberg.MergeStyleSquash,
+		Title:   commitMessageParts.Subject,
+		Message: commitMessageParts.Text,
+	})
+	if err != nil {
+		self.log.Failed(err.Error())
+		return err
+	}
+	self.log.Ok()
+	self.log.Start(messages.APIProposalLookupStart)
+	_, _, err = self.client.GetPullRequest(self.Organization, self.Repository, int64(number))
+	self.log.Ok()
+	return err
+}
+
+func (self Connector) updateProposalTarget(number int, target gitdomain.LocalBranchName, _ stringslice.Collector) error {
+	targetName := target.String()
+	self.log.Start(messages.APIUpdateProposalTarget, colors.BoldGreen().Styled("#"+strconv.Itoa(number)), colors.BoldCyan().Styled(targetName))
+	_, _, err := self.client.EditPullRequest(self.Organization, self.Repository, int64(number), codeberg.EditPullRequestOption{
+		Base: targetName,
+	})
+	if err != nil {
+		self.log.Failed(err.Error())
+		return err
+	}
+	self.log.Ok()
+	return nil
+}
+
+func FilterPullRequests(pullRequests []*codeberg.PullRequest, branch, target gitdomain.LocalBranchName) []*codeberg.PullRequest {
+	result := []*codeberg.PullRequest{}
+	for _, pullRequest := range pullRequests {
+		if pullRequest.Head.Name == branch.String() && pullRequest.Base.Name == target.String() {
+			result = append(result, pullRequest)
+		}
+	}
+	return result
+}
+
+func FilterPullRequests2(pullRequests []*codeberg.PullRequest, branch gitdomain.LocalBranchName) []*codeberg.PullRequest {
+	result := []*codeberg.PullRequest{}
+	for _, pullRequest := range pullRequests {
+		if pullRequest.Head.Name == branch.String() {
+			result = append(result, pullRequest)
+		}
+	}
+	return result
+}
+
+// NewCodebergConfig provides Codeberg configuration data if the current repo is hosted on Codeberg,
+// otherwise nil.
+func NewConnector(args NewConnectorArgs) Connector {
+	tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: args.APIToken.String()})
+	httpClient := oauth2.NewClient(context.Background(), tokenSource)
+	codebergClient := codeberg.NewClientWithHTTP("https://"+args.RemoteURL.Host, httpClient)
+	return Connector{
+		APIToken: args.APIToken,
+		Data: forgedomain.Data{
+			Hostname:     args.RemoteURL.Host,
+			Organization: args.RemoteURL.Org,
+			Repository:   args.RemoteURL.Repo,
+		},
+		client: codebergClient,
+		log:    args.Log,
+	}
+}
+
+type NewConnectorArgs struct {
+	APIToken  Option[configdomain.CodebergToken]
+	Log       print.Logger
+	RemoteURL giturl.Parts
+}
+
+func parsePullRequest(pullRequest *codeberg.PullRequest) forgedomain.Proposal {
+	return forgedomain.Proposal{
+		MergeWithAPI: pullRequest.Mergeable,
+		Number:       int(pullRequest.Index),
+		Source:       gitdomain.NewLocalBranchName(pullRequest.Head.Ref),
+		Target:       gitdomain.NewLocalBranchName(pullRequest.Base.Ref),
+		Title:        pullRequest.Title,
+		URL:          pullRequest.HTMLURL,
+	}
+}
diff --git a/internal/forge/codeberg/detect.go b/internal/forge/codeberg/detect.go
new file mode 100644
index 000000000..c0b5fab00
--- /dev/null
+++ b/internal/forge/codeberg/detect.go
@@ -0,0 +1,8 @@
+package codeberg
+
+import "github.com/git-town/git-town/v18/internal/git/giturl"
+
+// Detect indicates whether the current repository is hosted on a Codeberg server.
+func Detect(remoteURL giturl.Parts) bool {
+	return remoteURL.Host == "codeberg.org"
+}
diff --git a/internal/forge/codeberg/doc.go b/internal/forge/codeberg/doc.go
new file mode 100644
index 000000000..728447c4c
--- /dev/null
+++ b/internal/forge/codeberg/doc.go
@@ -0,0 +1,2 @@
+// Package codeberg provides the forge connector for Codeberg.
+package codeberg
diff --git a/internal/vm/opcodes/branch_delete_if_empty_at_runtime.go b/internal/vm/opcodes/branch_with_remote_gone_delete_if_empty_at_runtime.go
similarity index 80%
rename from internal/vm/opcodes/branch_delete_if_empty_at_runtime.go
rename to internal/vm/opcodes/branch_with_remote_gone_delete_if_empty_at_runtime.go
index ec5f7240d..d3dbde754 100644
--- a/internal/vm/opcodes/branch_delete_if_empty_at_runtime.go
+++ b/internal/vm/opcodes/branch_with_remote_gone_delete_if_empty_at_runtime.go
@@ -8,13 +8,13 @@ import (
 	"github.com/git-town/git-town/v18/internal/vm/shared"
 )
 
-// BranchDeleteIfEmptyAtRuntime deletes the given branch if it has no content at runtime.
-type BranchDeleteIfEmptyAtRuntime struct {
+// BranchWithRemoteGoneDeleteIfEmptyAtRuntime deletes the given branch if it has no content at runtime.
+type BranchWithRemoteGoneDeleteIfEmptyAtRuntime struct {
 	Branch                  gitdomain.LocalBranchName
 	undeclaredOpcodeMethods `exhaustruct:"optional"`
 }
 
-func (self *BranchDeleteIfEmptyAtRuntime) Run(args shared.RunArgs) error {
+func (self *BranchWithRemoteGoneDeleteIfEmptyAtRuntime) Run(args shared.RunArgs) error {
 	parent, hasParent := args.Config.Value.NormalConfig.Lineage.Parent(self.Branch).Get()
 	if !hasParent {
 		return nil
diff --git a/internal/vm/opcodes/doc.go b/internal/vm/opcodes/doc.go
index e360dbd76..7a3b8b626 100644
--- a/internal/vm/opcodes/doc.go
+++ b/internal/vm/opcodes/doc.go
@@ -46,7 +46,7 @@ func All() []shared.Opcode {
 		&BranchCurrentResetToParent{},
 		&BranchCurrentResetToSHA{},
 		&BranchCurrentResetToSHAIfNeeded{},
-		&BranchDeleteIfEmptyAtRuntime{},
+		&BranchWithRemoteGoneDeleteIfEmptyAtRuntime{},
 		&BranchEnsureShippableChanges{},
 		&BranchLocalDelete{},
 		&BranchLocalDeleteContent{},
diff --git a/website/src/commands/completions.md b/website/src/commands/completions.md
index f1f9f5588..1510bdefe 100644
--- a/website/src/commands/completions.md
+++ b/website/src/commands/completions.md
@@ -1,7 +1,7 @@
 # git town completions
 
 ```command-summary
-git town completion (bash|fish|powershell|zsh) [--no-descriptions]
+git town completions (bash|fish|powershell|zsh) [--no-descriptions]
 ```
 
 The _completions_ command outputs shell scripts that enable auto-completion for



Test patch:
diff --git a/internal/forge/codeberg/connector_test.go b/internal/forge/codeberg/connector_test.go
new file mode 100644
index 000000000..d7887abb3
--- /dev/null
+++ b/internal/forge/codeberg/connector_test.go
@@ -0,0 +1,160 @@
+package codeberg_test
+
+import (
+	"testing"
+
+	forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk"
+	"github.com/git-town/git-town/v18/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v18/internal/forge/codeberg"
+	"github.com/shoenig/test/must"
+)
+
+func TestFilterCodebergPullRequests(t *testing.T) {
+	t.Parallel()
+	give := []*forgejosdk.PullRequest{
+		// matching branch
+		{
+			Head: &forgejosdk.PRBranchInfo{
+				Name: "branch",
+			},
+			Base: &forgejosdk.PRBranchInfo{
+				Name: "target",
+			},
+		},
+		// branch with different name
+		{
+			Head: &forgejosdk.PRBranchInfo{
+				Name: "other",
+			},
+			Base: &forgejosdk.PRBranchInfo{
+				Name: "target",
+			},
+		},
+		// branch with different target
+		{
+			Head: &forgejosdk.PRBranchInfo{
+				Name: "branch",
+			},
+			Base: &forgejosdk.PRBranchInfo{
+				Name: "other",
+			},
+		},
+	}
+	want := []*forgejosdk.PullRequest{
+		{
+			Head: &forgejosdk.PRBranchInfo{
+				Name: "branch",
+			},
+			Base: &forgejosdk.PRBranchInfo{
+				Name: "target",
+			},
+		},
+	}
+	have := codeberg.FilterPullRequests(give, "branch", "target")
+	must.Eq(t, want, have)
+}
+
+//nolint:paralleltest  // mocks HTTP
+func TestCodeberg(t *testing.T) {
+	t.Run("DefaultProposalMessage", func(t *testing.T) {
+		give := forgedomain.Proposal{
+			Number: 1,
+			Title:  "my title",
+		}
+		want := "my title (#1)"
+		connector := codeberg.Connector{}
+		have := connector.DefaultProposalMessage(give)
+		must.EqOp(t, want, have)
+	})
+
+	// THIS TEST CONNECTS TO AN EXTERNAL INTERNET HOST,
+	// WHICH MAKES IT SLOW AND FLAKY.
+	// DISABLE AS NEEDED TO DEBUG THE CODEBERG CONNECTOR.
+	//
+	// t.Run("NewProposalURL", func(t *testing.T) {
+	// 	connector, err := codeberg.NewConnector(codeberg.NewConnectorArgs{
+	// 		HostingPlatform: configdomain.HostingCodeberg,
+	// 		RemoteURL:      giturl.Parse("git@codeberg.org:git-town/docs.git"),
+	// 		APIToken:       "",
+	// 		Log:            log.Silent{},
+	// 	})
+	// 	must.NoError(t, err)
+	// 	have, err := connector.NewProposalURL("feature", "parent")
+	// 	must.NoError(t, err)
+	// 	must.EqOp(t, "https://codeberg.org/git-town/docs/compare/parent...feature", have)
+	// })
+
+	// THIS TEST CONNECTS TO AN EXTERNAL INTERNET HOST,
+	// WHICH MAKES IT SLOW AND FLAKY.
+	// DISABLE AS NEEDED TO DEBUG THE CODEBERG CONNECTOR.
+	//
+	// t.Run("RepositoryURL", func(t *testing.T) {
+	// 	connector, err := codeberg.NewConnector(codeberg.NewConnectorArgs{
+	// 		HostingPlatform: configdomain.HostingCodeberg,
+	// 		RemoteURL:      giturl.Parse("git@codeberg.org:git-town/docs.git"),
+	// 		APIToken:       "",
+	// 		Log:            log.Silent{},
+	// 	})
+	// 	must.NoError(t, err)
+	// 	have := connector.RepositoryURL()
+	// 	must.EqOp(t, "https://codeberg.org/git-town/docs", have)
+	// })
+}
+
+func TestNewCodebergConnector(t *testing.T) {
+	t.Parallel()
+
+	// THIS TEST CONNECTS TO AN EXTERNAL INTERNET HOST,
+	// WHICH MAKES IT SLOW AND FLAKY.
+	// DISABLE AS NEEDED TO DEBUG THE CODEBERG CONNECTOR.
+	//
+	// t.Run("hosted service type provided manually", func(t *testing.T) {
+	// 	t.Parallel()
+	// 	have, err := codeberg.NewConnector(codeberg.NewConnectorArgs{
+	// 		HostingPlatform: configdomain.HostingCodeberg,
+	// 		RemoteURL:      giturl.Parse("git@custom-url.com:git-town/docs.git"),
+	// 		APIToken:       "apiToken",
+	// 		Log:            log.Silent{},
+	// 	})
+	// 	must.NoError(t, err)
+	// 	wantConfig := hostingdomain.Config{
+	// 		Hostname:     "custom-url.com",
+	// 		Organization: "git-town",
+	// 		Repository:   "docs",
+	// 	}
+	// 	must.EqOp(t, wantConfig, have.Config)
+	// })
+
+	// THIS TEST CONNECTS TO AN EXTERNAL INTERNET HOST,
+	// WHICH MAKES IT SLOW AND FLAKY.
+	// DISABLE AS NEEDED TO DEBUG THE CODEBERG CONNECTOR.
+	//
+	// t.Run("repo is hosted by another forge type --> no connector", func(t *testing.T) {
+	// 	t.Parallel()
+	// 	have, err := codeberg.NewConnector(codeberg.NewConnectorArgs{
+	// 		HostingPlatform: configdomain.HostingNone,
+	// 		RemoteURL:      giturl.Parse("git@github.com:git-town/git-town.git"),
+	// 		APIToken:       "",
+	// 		Log:            log.Silent{},
+	// 	})
+	// 	must.Nil(t, have)
+	// 	must.NoError(t, err)
+	// })
+
+	// THIS TEST CONNECTS TO AN EXTERNAL INTERNET HOST,
+	// WHICH MAKES IT SLOW AND FLAKY.
+	// DISABLE AS NEEDED TO DEBUG THE CODEBERG CONNECTOR.
+	//
+	// t.Run("no origin remote --> no connector", func(t *testing.T) {
+	// 	t.Parallel()
+	// 	var remoteURL *giturl.Parts
+	// 	have, err := codeberg.NewConnector(codeberg.NewConnectorArgs{
+	// 		HostingPlatform: configdomain.HostingNone,
+	// 		RemoteURL:      remoteURL,
+	// 		APIToken:       "",
+	// 		Log:            log.Silent{},
+	// 	})
+	// 	must.Nil(t, have)
+	// 	must.NoError(t, err)
+	// })
+}
diff --git a/internal/forge/codeberg/detect_test.go b/internal/forge/codeberg/detect_test.go
new file mode 100644
index 000000000..e502d41c8
--- /dev/null
+++ b/internal/forge/codeberg/detect_test.go
@@ -0,0 +1,24 @@
+package codeberg_test
+
+import (
+	"testing"
+
+	"github.com/git-town/git-town/v18/internal/forge/codeberg"
+	"github.com/git-town/git-town/v18/internal/git/giturl"
+	"github.com/shoenig/test/must"
+)
+
+func TestDetect(t *testing.T) {
+	t.Parallel()
+	tests := map[string]bool{
+		"git@codeberg.org:git-town/docs.git":   true,  // SAAS URL
+		"git@custom-url.com:git-town/docs.git": false, // custom URL
+		"git@gitlab.com:git-town/git-town.git": false, // other hosting URL
+	}
+	for give, want := range tests {
+		url, has := giturl.Parse(give).Get()
+		must.True(t, has)
+		have := codeberg.Detect(url)
+		must.EqOp(t, want, have)
+	}
+}
diff --git a/internal/forge/gitea/connector_test.go b/internal/forge/gitea/connector_test.go
index 827f6ad3f..20f858515 100644
--- a/internal/forge/gitea/connector_test.go
+++ b/internal/forge/gitea/connector_test.go
@@ -74,9 +74,9 @@ func TestGitea(t *testing.T) {
 	// t.Run("NewProposalURL", func(t *testing.T) {
 	// 	connector, err := gitea.NewConnector(gitea.NewConnectorArgs{
 	// 		HostingPlatform: configdomain.HostingGitea,
-	// 		RemoteURL:      giturl.Parse("git@gitea.com:git-town/docs.git"),
-	// 		APIToken:       "",
-	// 		Log:            log.Silent{},
+	// 		RemoteURL:       giturl.Parse("git@gitea.com:git-town/docs.git"),
+	//    APIToken:        None[configdomain.GiteaToken](),
+	// 		Log:             log.Silent{},
 	// 	})
 	// 	must.NoError(t, err)
 	// 	have, err := connector.NewProposalURL("feature", "parent")
@@ -91,9 +91,9 @@ func TestGitea(t *testing.T) {
 	// t.Run("RepositoryURL", func(t *testing.T) {
 	// 	connector, err := gitea.NewConnector(gitea.NewConnectorArgs{
 	// 		HostingPlatform: configdomain.HostingGitea,
-	// 		RemoteURL:      giturl.Parse("git@gitea.com:git-town/docs.git"),
-	// 		APIToken:       "",
-	// 		Log:            log.Silent{},
+	// 		RemoteURL:       giturl.Parse("git@gitea.com:git-town/docs.git"),
+	//    APIToken:        None[configdomain.GiteaToken](),
+	// 		Log:             log.Silent{},
 	// 	})
 	// 	must.NoError(t, err)
 	// 	have := connector.RepositoryURL()
@@ -113,7 +113,7 @@ func TestNewGiteaConnector(t *testing.T) {
 	// 	have, err := gitea.NewConnector(gitea.NewConnectorArgs{
 	// 		HostingPlatform: configdomain.HostingGitea,
 	// 		RemoteURL:      giturl.Parse("git@custom-url.com:git-town/docs.git"),
-	// 		APIToken:       "apiToken",
+	//    APIToken:       None[configdomain.GiteaToken](),
 	// 		Log:            log.Silent{},
 	// 	})
 	// 	must.NoError(t, err)
@@ -133,9 +133,9 @@ func TestNewGiteaConnector(t *testing.T) {
 	// 	t.Parallel()
 	// 	have, err := gitea.NewConnector(gitea.NewConnectorArgs{
 	// 		HostingPlatform: configdomain.HostingNone,
-	// 		RemoteURL:      giturl.Parse("git@github.com:git-town/git-town.git"),
-	// 		APIToken:       "",
-	// 		Log:            log.Silent{},
+	// 		RemoteURL:       giturl.Parse("git@github.com:git-town/git-town.git"),
+	//    APIToken:        None[configdomain.GiteaToken](),
+	// 		Log:             log.Silent{},
 	// 	})
 	// 	must.Nil(t, have)
 	// 	must.NoError(t, err)
@@ -150,9 +150,9 @@ func TestNewGiteaConnector(t *testing.T) {
 	// 	var remoteURL *giturl.Parts
 	// 	have, err := gitea.NewConnector(gitea.NewConnectorArgs{
 	// 		HostingPlatform: configdomain.HostingNone,
-	// 		RemoteURL:      remoteURL,
-	// 		APIToken:       "",
-	// 		Log:            log.Silent{},
+	// 		RemoteURL:       remoteURL,
+	//    APIToken:        None[configdomain.GiteaToken](),
+	// 		Log:             log.Silent{},
 	// 	})
 	// 	must.Nil(t, have)
 	// 	must.NoError(t, err)
diff --git a/internal/forge/github/connector_test.go b/internal/forge/github/connector_test.go
index 83436be67..d80d9802f 100644
--- a/internal/forge/github/connector_test.go
+++ b/internal/forge/github/connector_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/git-town/git-town/v18/internal/forge/github"
 	"github.com/git-town/git-town/v18/internal/git/gitdomain"
 	"github.com/git-town/git-town/v18/internal/git/giturl"
+	. "github.com/git-town/git-town/v18/pkg/prelude"
 	"github.com/shoenig/test/must"
 )
 
@@ -87,7 +88,7 @@ func TestConnector(t *testing.T) {
 						Organization: "organization",
 						Repository:   "repo",
 					},
-					APIToken: configdomain.ParseGitHubToken("apiToken"),
+					APIToken: None[configdomain.GitHubToken](),
 				}
 				have, err := connector.NewProposalURL(tt.branch, tt.parent, "main", tt.title, tt.body)
 				must.NoError(t, err)
@@ -116,12 +117,10 @@ func TestNewConnector(t *testing.T) {
 
 	t.Run("GitHub SaaS", func(t *testing.T) {
 		t.Parallel()
-		remoteURL, has := giturl.Parse("git@github.com:git-town/docs.git").Get()
-		must.True(t, has)
 		have, err := github.NewConnector(github.NewConnectorArgs{
-			APIToken:  configdomain.ParseGitHubToken("apiToken"),
+			APIToken:  None[configdomain.GitHubToken](),
 			Log:       print.Logger{},
-			RemoteURL: remoteURL,
+			RemoteURL: giturl.Parse("git@github.com:git-town/docs.git").GetOrPanic(),
 		})
 		must.NoError(t, err)
 		wantConfig := forgedomain.Data{
@@ -134,12 +133,10 @@ func TestNewConnector(t *testing.T) {
 
 	t.Run("custom URL", func(t *testing.T) {
 		t.Parallel()
-		remoteURL, has := giturl.Parse("git@custom-url.com:git-town/docs.git").Get()
-		must.True(t, has)
 		have, err := github.NewConnector(github.NewConnectorArgs{
-			APIToken:  configdomain.ParseGitHubToken("apiToken"),
+			APIToken:  None[configdomain.GitHubToken](),
 			Log:       print.Logger{},
-			RemoteURL: remoteURL,
+			RemoteURL: giturl.Parse("git@custom-url.com:git-town/docs.git").GetOrPanic(),
 		})
 		must.NoError(t, err)
 		wantConfig := forgedomain.Data{
diff --git a/internal/forge/gitlab/connector_test.go b/internal/forge/gitlab/connector_test.go
index 42628f129..cdfecc7b5 100644
--- a/internal/forge/gitlab/connector_test.go
+++ b/internal/forge/gitlab/connector_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/git-town/git-town/v18/internal/forge/gitlab"
 	"github.com/git-town/git-town/v18/internal/git/gitdomain"
 	"github.com/git-town/git-town/v18/internal/git/giturl"
+	. "github.com/git-town/git-town/v18/pkg/prelude"
 	"github.com/shoenig/test/must"
 )
 
@@ -23,7 +24,7 @@ func TestGitlabConnector(t *testing.T) {
 				Organization: "",
 				Repository:   "",
 			},
-			APIToken: configdomain.ParseGitLabToken(""),
+			APIToken: None[configdomain.GitLabToken](),
 		}
 		give := forgedomain.Proposal{
 			Number:       1,
@@ -63,7 +64,7 @@ func TestGitlabConnector(t *testing.T) {
 			t.Run(name, func(t *testing.T) {
 				connector := gitlab.Connector{
 					Data: gitlab.Data{
-						APIToken: configdomain.ParseGitLabToken("apiToken"),
+						APIToken: None[configdomain.GitLabToken](),
 						Data: forgedomain.Data{
 							Hostname:     "gitlab.com",
 							Organization: "organization",
@@ -87,7 +88,7 @@ func TestNewGitlabConnector(t *testing.T) {
 		remoteURL, has := giturl.Parse("git@gitlab.com:git-town/docs.git").Get()
 		must.True(t, has)
 		have, err := gitlab.NewConnector(gitlab.NewConnectorArgs{
-			APIToken:  configdomain.ParseGitLabToken("apiToken"),
+			APIToken:  None[configdomain.GitLabToken](),
 			Log:       print.Logger{},
 			RemoteURL: remoteURL,
 		})
@@ -98,7 +99,7 @@ func TestNewGitlabConnector(t *testing.T) {
 				Organization: "git-town",
 				Repository:   "docs",
 			},
-			APIToken: configdomain.ParseGitLabToken("apiToken"),
+			APIToken: None[configdomain.GitLabToken](),
 		}
 		must.Eq(t, wantConfig, have.Data)
 	})
@@ -108,7 +109,7 @@ func TestNewGitlabConnector(t *testing.T) {
 		remoteURL, has := giturl.Parse("git@custom-url.com:git-town/docs.git").Get()
 		must.True(t, has)
 		have, err := gitlab.NewConnector(gitlab.NewConnectorArgs{
-			APIToken:  configdomain.ParseGitLabToken("apiToken"),
+			APIToken:  None[configdomain.GitLabToken](),
 			Log:       print.Logger{},
 			RemoteURL: remoteURL,
 		})
@@ -119,7 +120,7 @@ func TestNewGitlabConnector(t *testing.T) {
 				Organization: "git-town",
 				Repository:   "docs",
 			},
-			APIToken: configdomain.ParseGitLabToken("apiToken"),
+			APIToken: None[configdomain.GitLabToken](),
 		}
 		must.Eq(t, wantConfig, have.Data)
 	})
@@ -129,7 +130,7 @@ func TestNewGitlabConnector(t *testing.T) {
 		remoteURL, has := giturl.Parse("git@gitlab.domain:1234/group/project").Get()
 		must.True(t, has)
 		have, err := gitlab.NewConnector(gitlab.NewConnectorArgs{
-			APIToken:  configdomain.ParseGitLabToken("apiToken"),
+			APIToken:  None[configdomain.GitLabToken](),
 			Log:       print.Logger{},
 			RemoteURL: remoteURL,
 		})
@@ -140,7 +141,7 @@ func TestNewGitlabConnector(t *testing.T) {
 				Organization: "group",
 				Repository:   "project",
 			},
-			APIToken: configdomain.ParseGitLabToken("apiToken"),
+			APIToken: None[configdomain.GitLabToken](),
 		}
 		must.Eq(t, wantConfig, have.Data)
 	})
diff --git a/internal/vm/statefile/save_load_test.go b/internal/vm/statefile/save_load_test.go
index 488b2ceda..3a4ea5fba 100644
--- a/internal/vm/statefile/save_load_test.go
+++ b/internal/vm/statefile/save_load_test.go
@@ -52,7 +52,6 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.BranchCurrentResetToParent{CurrentBranch: "branch"},
 				&opcodes.BranchCurrentResetToSHA{Hard: true, SetToSHA: "111111"},
 				&opcodes.BranchCurrentResetToSHAIfNeeded{Hard: true, MustHaveSHA: "222222", SetToSHA: "111111"},
-				&opcodes.BranchDeleteIfEmptyAtRuntime{Branch: "branch"},
 				&opcodes.BranchEnsureShippableChanges{Branch: "branch", Parent: "parent"},
 				&opcodes.BranchLocalDelete{Branch: "branch"},
 				&opcodes.BranchLocalDeleteContent{BranchToDelete: "branch", BranchToRebaseOnto: "main"},
@@ -65,6 +64,7 @@ func TestLoadSave(t *testing.T) {
 				&opcodes.BranchTrackingDelete{Branch: "origin/branch"},
 				&opcodes.BranchTypeOverrideSet{Branch: "branch", BranchType: configdomain.BranchTypeFeatureBranch},
 				&opcodes.BranchTypeOverrideRemove{Branch: "branch"},
+				&opcodes.BranchWithRemoteGoneDeleteIfEmptyAtRuntime{Branch: "branch"},
 				&opcodes.ChangesDiscard{},
 				&opcodes.ChangesStage{},
 				&opcodes.ChangesUnstageAll{},
@@ -199,12 +199,6 @@ func TestLoadSave(t *testing.T) {
       },
       "type": "BranchCurrentResetToSHAIfNeeded"
     },
-    {
-      "data": {
-        "Branch": "branch"
-      },
-      "type": "BranchDeleteIfEmptyAtRuntime"
-    },
     {
       "data": {
         "Branch": "branch",
@@ -285,6 +279,12 @@ func TestLoadSave(t *testing.T) {
       },
       "type": "BranchTypeOverrideRemove"
     },
+    {
+      "data": {
+        "Branch": "branch"
+      },
+      "type": "BranchWithRemoteGoneDeleteIfEmptyAtRuntime"
+    },
     {
       "data": {},
       "type": "ChangesDiscard"