Repository: akuity/kargo
Pull request #1833 (2024-04-12 21:58:28)

refactor: embed ui assets in binaries
This small change embeds all UI assets into the CLI and control plane binaries, constituting a minor step toward #1728, specifically the "It should allow the user to visit the UI in their browser" part.

Although it makes the server launched by the CLI capable of serving the UI, the UI still doesn't allow for the possibility of not needing to authn.

As I said... a minor step.



netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *docs-kargo-akuity-io* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | b2dc2aac6c6477d49d980ded0b4074aa773f60eb |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/docs-kargo-akuity-io/deploys/6619ae87741c7e0008fc233d |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/akuity/kargo/pull/1833?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) Report
Attention: Patch coverage is `0%` with `4 lines` in your changes are missing coverage. Please review.
> Project coverage is 44.91%. Comparing base [(`255578c`)](https://app.codecov.io/gh/akuity/kargo/commit/255578c75ca21f5d3249c063c7583f69407bbfb2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) to head [(`b2dc2aa`)](https://app.codecov.io/gh/akuity/kargo/pull/1833?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).
> Report is 3 commits behind head on main.

| [Files](https://app.codecov.io/gh/akuity/kargo/pull/1833?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) | Patch % | Lines |
|---|---|---|
| [internal/api/server.go](https://app.codecov.io/gh/akuity/kargo/pull/1833?src=pr&el=tree&filepath=internal%2Fapi%2Fserver.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL3NlcnZlci5nbw==) | 0.00% | [4 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1833?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1833      +/-   ##
==========================================
+ Coverage   44.89%   44.91%   +0.01%     
==========================================
  Files         213      213              
  Lines       13624    13625       +1     
==========================================
+ Hits         6116     6119       +3     
+ Misses       7244     7242       -2     
  Partials      264      264              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/akuity/kargo/pull/1833?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).


Patch:
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 6c6666a3..7935831d 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -181,6 +181,26 @@ jobs:
         key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
         restore-keys: |
           ${{ runner.os }}-go-
+    - name: Install pnpm
+      uses: pnpm/action-setup@v3
+      with:
+        version: 8
+        run_install: false
+    - name: Install nodejs
+      uses: actions/setup-node@v4
+      with:
+        node-version: "20.12.2"
+        cache: "pnpm"
+        cache-dependency-path: "**/pnpm-lock.yaml"
+    - name: Build UI
+      env:
+        VERSION: ${{ github.ref_name }}
+      working-directory: ./ui
+      run: |
+        pnpm install
+        NODE_ENV=production pnpm run build
+        rm -rf ../internal/api/ui
+        mv build ../internal/api/ui
     - name: Build CLI
       env:
         GOFLAGS: -buildvcs=false
@@ -210,10 +230,11 @@ jobs:
     runs-on: ubuntu-latest
     container:
       image: golang:1.22.2-bookworm
+    strategy:
+      matrix:
+        os: [linux, darwin, windows]
+        arch: [amd64, arm64]
     steps:
-    - name: Install awscli
-      run: |
-        apt update && apt install awscli -y
     - name: Checkout code
       uses: actions/checkout@v4
     - uses: actions/cache@v4
@@ -222,13 +243,38 @@ jobs:
         key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
         restore-keys: |
           ${{ runner.os }}-go-
+    - name: Install pnpm
+      uses: pnpm/action-setup@v3
+      with:
+        version: 8
+        run_install: false
+    - name: Install nodejs
+      uses: actions/setup-node@v4
+      with:
+        node-version: "20.12.2"
+        cache: "pnpm"
+        cache-dependency-path: "**/pnpm-lock.yaml"
+    - name: Build UI
+      env:
+        VERSION: ${{ github.ref_name }}
+      working-directory: ./ui
+      run: |
+        pnpm install
+        NODE_ENV=production pnpm run build
+        rm -rf ../internal/api/ui
+        mv build ../internal/api/ui
     - name: Build CLI
       env:
         GOFLAGS: -buildvcs=false
+        GOOS: ${{ matrix.os }}
+        GOARCH: ${{ matrix.arch }}
         VERSION: ${{ needs.publish-image.outputs.unstable-version }}
         GIT_COMMIT: ${{ github.sha }}
         GIT_TREE_STATE: clean
-      run: make nightly-cli
+      run: make build-nightly-cli
+    - name: Install awscli
+      run: |
+        apt update && apt install awscli -y
     - name: Configure AWS credentials
       uses: aws-actions/configure-aws-credentials@v4
       with:
@@ -239,7 +285,7 @@ jobs:
         CF_DISTRIBUTION_ID: ${{ secrets.CF_DISTRIBUTION_ID }}
         VERSION: ${{ needs.publish-image.outputs.unstable-version }}
       run: |
-        aws s3 sync "./bin/kargo-cli/${VERSION}" "s3://kargo-release/kargo-cli/${VERSION}"
+        aws s3 sync "./bin/kargo-cli/${VERSION}/${{ matrix.os }}/${{ matrix.arch }}" "s3://kargo-release/kargo-cli/${VERSION}/${{ matrix.os }}/${{ matrix.arch }}"
         printf "${VERSION}" > ./bin/kargo-cli/unstable.txt
         aws s3 cp ./bin/kargo-cli/unstable.txt s3://kargo-release/kargo-cli/unstable.txt
         aws cloudfront create-invalidation \
diff --git a/Dockerfile b/Dockerfile
index 3e4d5fce..e595ddce 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,19 @@
+####################################################################################################
+# ui-builder
+####################################################################################################
+FROM --platform=$BUILDPLATFORM docker.io/library/node:20.12.2 AS ui-builder
+
+RUN npm install --global pnpm
+WORKDIR /ui
+COPY ["ui/package.json", "ui/pnpm-lock.yaml", "./"]
+ARG VERSION
+
+RUN pnpm install
+
+COPY ["ui/", "."]
+
+RUN NODE_ENV='production' VERSION=${VERSION} pnpm run build
+
 ####################################################################################################
 # back-end-builder
 ####################################################################################################
@@ -17,6 +33,7 @@ COPY api/ api/
 COPY cmd/ cmd/
 COPY internal/ internal/
 COPY pkg/ pkg/
+COPY --from=ui-builder /ui/build internal/api/ui/
 
 ARG VERSION
 ARG GIT_COMMIT
@@ -30,22 +47,6 @@ RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
 
 WORKDIR /kargo/bin
 
-####################################################################################################
-# ui-builder
-####################################################################################################
-FROM --platform=$BUILDPLATFORM docker.io/library/node:20.12.1 AS ui-builder
-
-RUN npm install --global pnpm
-WORKDIR /ui
-COPY ["ui/package.json", "ui/pnpm-lock.yaml", "./"]
-ARG VERSION
-
-RUN pnpm install
-
-COPY ["ui/", "."]
-
-RUN NODE_ENV='production' VERSION=${VERSION} pnpm run build
-
 ####################################################################################################
 # tools
 ####################################################################################################
@@ -87,7 +88,7 @@ CMD ["/usr/local/bin/kargo"]
 # - supports development
 # - not used for official image builds
 ####################################################################################################
-FROM --platform=$BUILDPLATFORM docker.io/library/node:20.12.1 AS ui-dev
+FROM --platform=$BUILDPLATFORM docker.io/library/node:20.12.2 AS ui-dev
 
 RUN npm install --global pnpm
 WORKDIR /ui
@@ -110,7 +111,6 @@ USER root
 
 COPY --from=back-end-builder /kargo/bin/ /usr/local/bin/
 COPY --from=tools /tools/ /usr/local/bin/
-COPY --from=ui-builder /ui/build /ui/build
 
 USER 1000:0
 
diff --git a/Makefile b/Makefile
index bf0681ee..77a73feb 100644
--- a/Makefile
+++ b/Makefile
@@ -115,20 +115,11 @@ build-cli:
 # Used for Nighty/Unstable builds                                              #
 ################################################################################
 
-.PHONY: kargo-all
-kargo-all:
+.PHONY: build-nightly-cli
+build-nightly-cli:
 	CGO_ENABLED=0 go build \
 		-ldflags "-w -X $(VERSION_PACKAGE).version=$(VERSION) -X $(VERSION_PACKAGE).buildDate=$$(date -u +'%Y-%m-%dT%H:%M:%SZ') -X $(VERSION_PACKAGE).gitCommit=$(GIT_COMMIT) -X $(VERSION_PACKAGE).gitTreeState=$(GIT_TREE_STATE)" \
-		-o bin/kargo-cli/${VERSION}/${GOOS}/${GOARCH}/${BIN_NAME} ./cmd/cli
-
-.PHONY: nightly-cli
-nightly-cli:
-	make BIN_NAME=kargo GOOS=darwin GOARCH=amd64 kargo-all
-	make BIN_NAME=kargo GOOS=darwin GOARCH=arm64 kargo-all
-	make BIN_NAME=kargo GOOS=linux GOARCH=amd64 kargo-all
-	make BIN_NAME=kargo GOOS=linux GOARCH=arm64 kargo-all
-	make BIN_NAME=kargo.exe GOOS=windows GOARCH=amd64 kargo-all
-	make BIN_NAME=kargo.exe GOOS=windows GOARCH=arm64 kargo-all
+		-o bin/kargo-cli/${VERSION}/${GOOS}/${GOARCH}/kargo$(shell [ ${GOOS} = windows ] && echo .exe) ./cmd/cli
 
 ################################################################################
 # Code generation: To be run after modifications to API types                  #
diff --git a/api/v1alpha1/generated.proto b/api/v1alpha1/generated.proto
index 94a3f078..1dcc458d 100644
--- a/api/v1alpha1/generated.proto
+++ b/api/v1alpha1/generated.proto
@@ -500,19 +500,27 @@ message GitSubscription {
   // only with great caution.
   optional bool insecureSkipTLSVerify = 7;
 
-  // IncludePaths is a list of regular expressions that can optionally be used to
+  // IncludePaths is a list of filter strings that can optionally be used to
   // limit file paths in repository, changes in which will result in creation of
   // new freight. When not specified - changes in any path will produce new
-  // freight, it is equivalent to having a IncludePaths with an entry of ".*"
+  // freight, it is equivalent to having a IncludePaths with an entry of "regex:.*"
   // When both IncludePaths and ExcludePaths are specified and match same path/paths,
   // ExcludePaths will prevail over IncludePaths.
+  // Filter strings can be specified as prefix/exact match strings, globs or regular
+  // expressions, default being prefix/exact match, for example "helm/charts/", for
+  // regular expressions strings need to be prepended by either "regex:" or "regexp:"
+  // as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
   // +kubebuilder:validation:Optional
   repeated string includePaths = 8;
 
-  // ExcludePaths is an optional list of regular expressions used to specify paths
+  // ExcludePaths is an optional list of filter strings used to specify paths
   // in git repository, changes in which should never produce new freight. When used
   // in conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes
   // precedence over IncludePaths.
+  // Filter strings can be specified as prefix/exact match strings, globs or regular
+  // expressions, default being prefix/exact match, for example "helm/charts/", for
+  // regular expressions strings need to be prepended by either "regex:" or "regexp:"
+  // as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
   // +kubebuilder:validation:Optional
   repeated string excludePaths = 9;
 }
diff --git a/api/v1alpha1/warehouse_types.go b/api/v1alpha1/warehouse_types.go
index c4daffd6..2ff2990d 100644
--- a/api/v1alpha1/warehouse_types.go
+++ b/api/v1alpha1/warehouse_types.go
@@ -126,18 +126,26 @@ type GitSubscription struct {
 	// should be ignored when connecting to the repository. This should be enabled
 	// only with great caution.
 	InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty" protobuf:"varint,7,opt,name=insecureSkipTLSVerify"`
-	// IncludePaths is a list of regular expressions that can optionally be used to
+	// IncludePaths is a list of filter strings that can optionally be used to
 	// limit file paths in repository, changes in which will result in creation of
 	// new freight. When not specified - changes in any path will produce new
-	// freight, it is equivalent to having a IncludePaths with an entry of ".*"
+	// freight, it is equivalent to having a IncludePaths with an entry of "regex:.*"
 	// When both IncludePaths and ExcludePaths are specified and match same path/paths,
 	// ExcludePaths will prevail over IncludePaths.
+	// Filter strings can be specified as prefix/exact match strings, globs or regular
+	// expressions, default being prefix/exact match, for example "helm/charts/", for
+	// regular expressions strings need to be prepended by either "regex:" or "regexp:"
+	// as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
 	//+kubebuilder:validation:Optional
 	IncludePaths []string `json:"includePaths,omitempty" protobuf:"bytes,8,rep,name=includePaths"`
-	// ExcludePaths is an optional list of regular expressions used to specify paths
+	// ExcludePaths is an optional list of filter strings used to specify paths
 	// in git repository, changes in which should never produce new freight. When used
 	// in conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes
 	// precedence over IncludePaths.
+	// Filter strings can be specified as prefix/exact match strings, globs or regular
+	// expressions, default being prefix/exact match, for example "helm/charts/", for
+	// regular expressions strings need to be prepended by either "regex:" or "regexp:"
+	// as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
 	//+kubebuilder:validation:Optional
 	ExcludePaths []string `json:"excludePaths,omitempty" protobuf:"bytes,9,rep,name=excludePaths"`
 }
diff --git a/charts/kargo/resources/crds/kargo.akuity.io_warehouses.yaml b/charts/kargo/resources/crds/kargo.akuity.io_warehouses.yaml
index 4a3a4379..e539a718 100644
--- a/charts/kargo/resources/crds/kargo.akuity.io_warehouses.yaml
+++ b/charts/kargo/resources/crds/kargo.akuity.io_warehouses.yaml
@@ -138,10 +138,14 @@ spec:
                           type: string
                         excludePaths:
                           description: |-
-                            ExcludePaths is an optional list of regular expressions used to specify paths
+                            ExcludePaths is an optional list of filter strings used to specify paths
                             in git repository, changes in which should never produce new freight. When used
                             in conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes
                             precedence over IncludePaths.
+                            Filter strings can be specified as prefix/exact match strings, globs or regular
+                            expressions, default being prefix/exact match, for example "helm/charts/", for
+                            regular expressions strings need to be prepended by either "regex:" or "regexp:"
+                            as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
                           items:
                             type: string
                           type: array
@@ -157,12 +161,16 @@ spec:
                           type: array
                         includePaths:
                           description: |-
-                            IncludePaths is a list of regular expressions that can optionally be used to
+                            IncludePaths is a list of filter strings that can optionally be used to
                             limit file paths in repository, changes in which will result in creation of
                             new freight. When not specified - changes in any path will produce new
-                            freight, it is equivalent to having a IncludePaths with an entry of ".*"
+                            freight, it is equivalent to having a IncludePaths with an entry of "regex:.*"
                             When both IncludePaths and ExcludePaths are specified and match same path/paths,
                             ExcludePaths will prevail over IncludePaths.
+                            Filter strings can be specified as prefix/exact match strings, globs or regular
+                            expressions, default being prefix/exact match, for example "helm/charts/", for
+                            regular expressions strings need to be prepended by either "regex:" or "regexp:"
+                            as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*/*/values.*ml"
                           items:
                             type: string
                           type: array
diff --git a/cmd/controlplane/api.go b/cmd/controlplane/api.go
index ee7d8552..de0becd2 100644
--- a/cmd/controlplane/api.go
+++ b/cmd/controlplane/api.go
@@ -22,6 +22,7 @@ import (
 	"github.com/akuity/kargo/internal/api/kubernetes"
 	rollouts "github.com/akuity/kargo/internal/controller/rollouts/api/v1alpha1"
 	"github.com/akuity/kargo/internal/kubeclient"
+	libEvent "github.com/akuity/kargo/internal/kubernetes/event"
 	"github.com/akuity/kargo/internal/os"
 	versionpkg "github.com/akuity/kargo/internal/version"
 )
@@ -163,7 +164,7 @@ func (o *apiOptions) setupAPIClient(ctx context.Context) (*rest.Config, client.C
 		}
 	}()
 
-	return restCfg, mgr.GetClient(), mgr.GetEventRecorderFor("api"), nil
+	return restCfg, mgr.GetClient(), libEvent.NewRecorder(ctx, scheme, mgr.GetClient(), "api"), nil
 }
 
 func registerKargoIndexers(ctx context.Context, mgr ctrl.Manager) error {
diff --git a/cmd/controlplane/webhooks.go b/cmd/controlplane/webhooks.go
index 50b6be4e..a54ec97e 100644
--- a/cmd/controlplane/webhooks.go
+++ b/cmd/controlplane/webhooks.go
@@ -108,7 +108,7 @@ func (o *webhooksServerOptions) run(ctx context.Context) error {
 		return fmt.Errorf("index Stages by Freight: %w", err)
 	}
 
-	if err = freight.SetupWebhookWithManager(webhookCfg, mgr); err != nil {
+	if err = freight.SetupWebhookWithManager(ctx, webhookCfg, mgr); err != nil {
 		return fmt.Errorf("setup Freight webhook: %w", err)
 	}
 	if err = project.SetupWebhookWithManager(
@@ -117,7 +117,7 @@ func (o *webhooksServerOptions) run(ctx context.Context) error {
 	); err != nil {
 		return fmt.Errorf("setup Project webhook: %w", err)
 	}
-	if err = promotion.SetupWebhookWithManager(webhookCfg, mgr); err != nil {
+	if err = promotion.SetupWebhookWithManager(ctx, webhookCfg, mgr); err != nil {
 		return fmt.Errorf("setup Promotion webhook: %w", err)
 	}
 	if err = stage.SetupWebhookWithManager(webhookCfg, mgr); err != nil {
diff --git a/internal/api/config/config.go b/internal/api/config/config.go
index 220d16f9..9b7a20ed 100644
--- a/internal/api/config/config.go
+++ b/internal/api/config/config.go
@@ -15,7 +15,6 @@ import (
 
 type StandardConfig struct {
 	GracefulShutdownTimeout time.Duration `envconfig:"GRACEFUL_SHUTDOWN_TIMEOUT" default:"30s"`
-	UIDirectory             string        `envconfig:"UI_DIR" default:"./ui/build"`
 }
 
 type ServerConfig struct {
diff --git a/internal/api/promote_to_stage_subscribers_v1alpha1.go b/internal/api/promote_to_stage_subscribers_v1alpha1.go
index add4d122..3b84143d 100644
--- a/internal/api/promote_to_stage_subscribers_v1alpha1.go
+++ b/internal/api/promote_to_stage_subscribers_v1alpha1.go
@@ -134,6 +134,11 @@ func (s *server) PromoteToStageSubscribers(
 	createdPromos := make([]*kargoapi.Promotion, 0, len(subscribers))
 	for _, subscriber := range subscribers {
 		newPromo := kargo.NewPromotion(subscriber, freight.Name)
+		if subscriber.Spec.PromotionMechanisms == nil {
+			// Avoid creating a Promotion if the subscriber has no
+			// PromotionMechanisms, and is a "control flow" Stage.
+			continue
+		}
 		if err := s.createPromotionFn(ctx, &newPromo); err != nil {
 			promoteErrs = append(promoteErrs, err)
 			continue
diff --git a/internal/api/server.go b/internal/api/server.go
index 4f246142..20179061 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -2,11 +2,12 @@ package api
 
 import (
 	"context"
+	"embed"
 	"errors"
 	"fmt"
+	"io/fs"
 	"net"
 	"net/http"
-	goos "os"
 	"time"
 
 	"connectrpc.com/grpchealth"
@@ -26,13 +27,15 @@ import (
 	"github.com/akuity/kargo/internal/api/option"
 	"github.com/akuity/kargo/internal/api/validation"
 	rollouts "github.com/akuity/kargo/internal/controller/rollouts/api/v1alpha1"
-	httputil "github.com/akuity/kargo/internal/http"
 	"github.com/akuity/kargo/internal/logging"
 	"github.com/akuity/kargo/pkg/api/service/v1alpha1/svcv1alpha1connect"
 )
 
 var (
 	_ svcv1alpha1connect.KargoServiceHandler = &server{}
+
+	//go:embed all:ui
+	ui embed.FS
 )
 
 type server struct {
@@ -192,7 +195,11 @@ func (s *server) Serve(ctx context.Context, l net.Listener) error {
 	mux.Handle(grpchealth.NewHandler(NewHealthChecker(), opts))
 	path, svcHandler := svcv1alpha1connect.NewKargoServiceHandler(s, opts)
 	mux.Handle(path, svcHandler)
-	mux.Handle("/", s.newDashboardRequestHandler())
+	uiFS := fs.FS(ui)
+	if uiFS, err = fs.Sub(uiFS, "ui"); err != nil {
+		return fmt.Errorf("error initializing UI file system: %w", err)
+	}
+	mux.Handle("/", http.FileServer(http.FS(uiFS)))
 	if s.cfg.DexProxyConfig != nil {
 		dexProxyCfg := dex.ProxyConfigFromEnv()
 		dexProxy, err := dex.NewProxy(dexProxyCfg)
@@ -248,19 +255,3 @@ func (s *server) Serve(ctx context.Context, l net.Listener) error {
 		return err
 	}
 }
-
-func (s *server) newDashboardRequestHandler() http.HandlerFunc {
-	fs := http.FileServer(http.Dir(s.cfg.UIDirectory))
-	return func(w http.ResponseWriter, req *http.Request) {
-		path := s.cfg.UIDirectory + req.URL.Path
-		info, err := goos.Stat(path)
-		if goos.IsNotExist(err) || info.IsDir() {
-			if w != nil {
-				httputil.SetNoCacheHeaders(w)
-				http.ServeFile(w, req, s.cfg.UIDirectory+"/index.html")
-			}
-		} else {
-			fs.ServeHTTP(w, req)
-		}
-	}
-}
diff --git a/internal/api/ui/.keep b/internal/api/ui/.keep
new file mode 100644
index 00000000..e69de29b
diff --git a/internal/argocd/health.go b/internal/argocd/health.go
index 742b3de0..0121bf80 100644
--- a/internal/argocd/health.go
+++ b/internal/argocd/health.go
@@ -267,9 +267,9 @@ func filterAppConditions(
 func getDesiredRevision(app *argocd.Application, freight kargoapi.FreightReference) string {
 	if app.Spec.Source.Chart == "" {
 		// This source points to a git repository
-		sourceGitRepoURL := git.NormalizeGitURL(app.Spec.Source.RepoURL)
+		sourceGitRepoURL := git.NormalizeURL(app.Spec.Source.RepoURL)
 		for _, commit := range freight.Commits {
-			if git.NormalizeGitURL(commit.RepoURL) == sourceGitRepoURL {
+			if git.NormalizeURL(commit.RepoURL) == sourceGitRepoURL {
 				if commit.HealthCheckCommit != "" {
 					return commit.HealthCheckCommit
 				}
diff --git a/internal/cli/cmd/server/server.go b/internal/cli/cmd/server/server.go
index 8f90884e..9c926d52 100644
--- a/internal/cli/cmd/server/server.go
+++ b/internal/cli/cmd/server/server.go
@@ -14,7 +14,7 @@ import (
 	"github.com/akuity/kargo/internal/api/kubernetes"
 	"github.com/akuity/kargo/internal/cli/option"
 	"github.com/akuity/kargo/internal/cli/templates"
-	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
 )
 
 type serverOptions struct {
@@ -100,7 +100,7 @@ func (o *serverOptions) run(ctx context.Context) error {
 		},
 		client,
 		client,
-		&fakekubeclient.EventRecorder{},
+		&fakeevent.EventRecorder{},
 	)
 	if err := srv.Serve(ctx, l); err != nil {
 		return fmt.Errorf("serve error: %w", err)
diff --git a/internal/controller/promotion/argocd.go b/internal/controller/promotion/argocd.go
index 3a468f86..a7ffcbb6 100644
--- a/internal/controller/promotion/argocd.go
+++ b/internal/controller/promotion/argocd.go
@@ -323,8 +323,8 @@ func applyArgoCDSourceUpdate(
 	} else {
 		// We're dealing with a git repo, so we should normalize the repo URLs
 		// before comparing them.
-		sourceRepoURL := git.NormalizeGitURL(source.RepoURL)
-		if sourceRepoURL != git.NormalizeGitURL(update.RepoURL) {
+		sourceRepoURL := git.NormalizeURL(source.RepoURL)
+		if sourceRepoURL != git.NormalizeURL(update.RepoURL) {
 			return source, nil
 		}
 		// If we get to here, we have confirmed that this update is applicable to
@@ -332,7 +332,7 @@ func applyArgoCDSourceUpdate(
 		//
 		// Now find the commit in the new freight that corresponds to this source.
 		for _, commit := range newFreight.Commits {
-			if git.NormalizeGitURL(commit.RepoURL) == sourceRepoURL {
+			if git.NormalizeURL(commit.RepoURL) == sourceRepoURL {
 				source.TargetRevision = commit.ID
 				break
 			}
diff --git a/internal/controller/promotion/git.go b/internal/controller/promotion/git.go
index 57111d1e..b9fe54e1 100644
--- a/internal/controller/promotion/git.go
+++ b/internal/controller/promotion/git.go
@@ -224,9 +224,9 @@ func getReadRef(
 	update kargoapi.GitRepoUpdate,
 	commits []kargoapi.GitCommit,
 ) (string, int, error) {
-	updateRepoURL := libGit.NormalizeGitURL(update.RepoURL)
+	updateRepoURL := libGit.NormalizeURL(update.RepoURL)
 	for i, commit := range commits {
-		if libGit.NormalizeGitURL(commit.RepoURL) == updateRepoURL {
+		if libGit.NormalizeURL(commit.RepoURL) == updateRepoURL {
 			if update.WriteBranch == commit.Branch && update.PullRequest == nil {
 				return "", -1, fmt.Errorf(
 					"invalid update specified; cannot write to branch %q of repo %q "+
diff --git a/internal/controller/promotions/promotions.go b/internal/controller/promotions/promotions.go
index 109638f3..b646750d 100644
--- a/internal/controller/promotions/promotions.go
+++ b/internal/controller/promotions/promotions.go
@@ -25,6 +25,7 @@ import (
 	"github.com/akuity/kargo/internal/credentials"
 	"github.com/akuity/kargo/internal/kargo"
 	"github.com/akuity/kargo/internal/kubeclient"
+	libEvent "github.com/akuity/kargo/internal/kubernetes/event"
 	"github.com/akuity/kargo/internal/logging"
 )
 
@@ -93,7 +94,7 @@ func SetupReconcilerWithManager(
 	reconciler := newReconciler(
 		kargoMgr.GetClient(),
 		argocdClient,
-		kargoMgr.GetEventRecorderFor(cfg.Name()),
+		libEvent.NewRecorder(ctx, kargoMgr.GetScheme(), kargoMgr.GetClient(), cfg.Name()),
 		credentialsDB,
 		cfg,
 	)
diff --git a/internal/controller/stages/stages.go b/internal/controller/stages/stages.go
index e9c54342..6ada3b0d 100644
--- a/internal/controller/stages/stages.go
+++ b/internal/controller/stages/stages.go
@@ -32,6 +32,7 @@ import (
 	rollouts "github.com/akuity/kargo/internal/controller/rollouts/api/v1alpha1"
 	"github.com/akuity/kargo/internal/kargo"
 	"github.com/akuity/kargo/internal/kubeclient"
+	libEvent "github.com/akuity/kargo/internal/kubernetes/event"
 	"github.com/akuity/kargo/internal/logging"
 )
 
@@ -332,7 +333,7 @@ func SetupReconcilerWithManager(
 			newReconciler(
 				kargoMgr.GetClient(),
 				argocdClient,
-				kargoMgr.GetEventRecorderFor(cfg.Name()),
+				libEvent.NewRecorder(ctx, kargoMgr.GetScheme(), kargoMgr.GetClient(), cfg.Name()),
 				cfg,
 				shardRequirement,
 			),
diff --git a/internal/controller/warehouses/git.go b/internal/controller/warehouses/git.go
index f5e535ba..2d307c89 100644
--- a/internal/controller/warehouses/git.go
+++ b/internal/controller/warehouses/git.go
@@ -3,6 +3,7 @@ package warehouses
 import (
 	"context"
 	"fmt"
+	"path/filepath"
 	"regexp"
 	"sort"
 	"strings"
@@ -18,6 +19,7 @@ import (
 const (
 	regexpPrefix = "regexp:"
 	regexPrefix  = "regex:"
+	globPrefix   = "glob:"
 )
 
 type gitMeta struct {
@@ -27,6 +29,8 @@ type gitMeta struct {
 	Author  string
 }
 
+type pathSelector func(path string) (bool, error)
+
 func (r *reconciler) selectCommits(
 	ctx context.Context,
 	namespace string,
@@ -303,92 +307,91 @@ func ignores(tagName string, ignore []string) bool {
 	return false
 }
 
-// pathsFilterPositive returns true when IncludePaths and/or ExcludePaths
-// filters match one or more commit diffs and new Freight is
-// to be produced. It returns false otherwise.
+func getPathSelectors(selectorStrs []string) ([]pathSelector, error) {
+	selectors := make([]pathSelector, len(selectorStrs))
+	for i, selectorStr := range selectorStrs {
+		switch {
+		case strings.HasPrefix(selectorStr, regexpPrefix):
+			regex, err := regexp.Compile(strings.TrimPrefix(selectorStr, regexpPrefix))
+			if err != nil {
+				return nil, err
+			}
+			selectors[i] = func(path string) (bool, error) {
+				return regex.MatchString(path), nil
+			}
+		case strings.HasPrefix(selectorStr, regexPrefix):
+			regex, err := regexp.Compile(strings.TrimPrefix(selectorStr, regexPrefix))
+			if err != nil {
+				return nil, err
+			}
+			selectors[i] = func(path string) (bool, error) {
+				return regex.MatchString(path), nil
+			}
+		case strings.HasPrefix(selectorStr, globPrefix):
+			pattern := strings.TrimPrefix(selectorStr, globPrefix)
+			selectors[i] = func(path string) (bool, error) {
+				return filepath.Match(pattern, path)
+			}
+		default:
+			basePath := selectorStr
+			selectors[i] = func(path string) (bool, error) {
+				relPath, err := filepath.Rel(basePath, path)
+				if err != nil {
+					return false, err
+				}
+				return !strings.Contains(relPath, ".."), nil
+			}
+		}
+	}
+	return selectors, nil
+}
+
 func matchesPathsFilters(includePaths []string, excludePaths []string, diffs []string) (bool, error) {
-	includePathsRegexps, err := compileRegexps(includePaths)
+	includeSelectors, err := getPathSelectors(includePaths)
 	if err != nil {
-		return false, fmt.Errorf(
-			"error compiling includePaths regexps: %w",
-			err,
-		)
+		return false, err
 	}
-
-	excludePathsRegexps, err := compileRegexps(excludePaths)
+	excludeSelectors, err := getPathSelectors(excludePaths)
 	if err != nil {
-		return false, fmt.Errorf(
-			"error compiling excludePaths regexps: %w",
-			err,
-		)
+		return false, err
 	}
-
-	filteredDiffs := make([]string, 0, len(diffs))
-	for _, diffPath := range diffs {
-		// matchesIncludePaths case is a bit different from matchesExcludePaths
-		// in the way that if includePaths string array is empty - it matches
-		// ANY change so we need to have a check for that
-		matchesIncludePaths := len(includePaths) == 0 || matchesRegexpList(diffPath, includePathsRegexps)
-		matchesExcludePaths := matchesRegexpList(diffPath, excludePathsRegexps)
-		// combined filter decision, positive for matching includePaths and
-		// unmatching excludePaths
-		if matchesIncludePaths && !matchesExcludePaths {
-			filteredDiffs = append(filteredDiffs, diffPath)
+pathLoop:
+	for _, path := range diffs {
+		if len(includeSelectors) > 0 {
+			var selected bool
+			for _, selector := range includeSelectors {
+				if selected, err = selector(path); err != nil {
+					return false, err
+				}
+				if selected {
+					// Path was explicitly included, so we can move on to checking if
+					// it should be excluded
+					break
+				}
+			}
+			if !selected {
+				// Path was not explicitly included, so we can move on to the next path
+				continue pathLoop
+			}
 		}
-	}
-	if len(filteredDiffs) > 0 {
+		// If we reach this point, the path was either implicitly or explicitly
+		// included. Now check if it should be excluded.
+		for _, selector := range excludeSelectors {
+			selected, err := selector(path)
+			if err != nil {
+				return false, err
+			}
+			if selected {
+				// Path was explicitly excluded, so we can move on to the next path
+				continue pathLoop
+			}
+		}
+		// If we reach this point, the path was not explicitly excluded
 		return true, nil
 	}
 	return false, nil
 }
 
-// compileRegexps is a general purpose function taking a slice of strings
-// as argument and compiling them into a slice of *regexp.Regexp. It
-// returns the compiled regexps in case of success and if it fails to compile
-// a string - nil and error is returned
-func compileRegexps(regexpStrings []string) (regexps []*regexp.Regexp, err error) {
-	regexpsSlice := make([]*regexp.Regexp, 0, len(regexpStrings))
-	for _, regexpString := range regexpStrings {
-		switch {
-		case strings.HasPrefix(regexpString, regexpPrefix):
-			regexpString = strings.TrimPrefix(regexpString, regexpPrefix)
-		case strings.HasPrefix(regexpString, regexPrefix):
-			regexpString = strings.TrimPrefix(regexpString, regexPrefix)
-		default:
-			return nil, fmt.Errorf(
-				"error compiling %q into a regular expression: string must start with %q or %q",
-				regexpString, regexpPrefix, regexPrefix,
-			)
-		}
-
-		regex, err := regexp.Compile(regexpString)
-		if err != nil {
-			return nil, fmt.Errorf(
-				"error compiling string %q into a regular expression: %w",
-				regexpString,
-				err,
-			)
-		}
-		regexpsSlice = append(regexpsSlice, regex)
-	}
-	return regexpsSlice, nil
-}
-
-// matchesRegexpList is a general purpose function iterating given slice of
-// *regexp.Regexp (regexpList) to check if any of regexps match
-// stringToMatch string, if match is found it returns true and if match
-// is not found it returns false.
-func matchesRegexpList(stringToMatch string, regexpList []*regexp.Regexp) bool {
-	foundMatch := false
-	for _, regex := range regexpList {
-		if regex == nil || regex.MatchString(stringToMatch) {
-			foundMatch = true
-			break
-		}
-	}
-	return foundMatch
-}
-
 // selectLexicallyLastTag sorts the provided tag name in reverse lexicographic
 // order and returns the first tag name in the sorted list. If the list is
 // empty, it returns an empty string.
diff --git a/internal/controller/warehouses/images.go b/internal/controller/warehouses/images.go
index 27aab8ee..43d14869 100644
--- a/internal/controller/warehouses/images.go
+++ b/internal/controller/warehouses/images.go
@@ -88,7 +88,7 @@ func (r *reconciler) getImageSourceURL(gitRepoURL, tag string) string {
 }
 
 func getGithubImageSourceURL(gitRepoURL, tag string) string {
-	return fmt.Sprintf("%s/tree/%s", git.NormalizeGitURL(gitRepoURL), tag)
+	return fmt.Sprintf("%s/tree/%s", git.NormalizeURL(gitRepoURL), tag)
 }
 
 func getImageRefs(
diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go
index c1e4aff8..caaa231d 100644
--- a/internal/credentials/credentials.go
+++ b/internal/credentials/credentials.go
@@ -159,7 +159,7 @@ func (k *kubernetesDatabase) getCredentialsSecret(
 	repoURL string,
 ) (*corev1.Secret, error) {
 	repoURL = helm.NormalizeChartRepositoryURL( // This should be safe even on non-chart repo URLs
-		git.NormalizeGitURL(repoURL), // This should be safe even on non-Git URLs
+		git.NormalizeURL(repoURL), // This should be safe even on non-Git URLs
 	)
 
 	secrets := corev1.SecretList{}
@@ -195,7 +195,7 @@ func (k *kubernetesDatabase) getCredentialsSecret(
 			continue
 		}
 		url := helm.NormalizeChartRepositoryURL( // This should be safe even on non-chart repo URLs
-			git.NormalizeGitURL( // This should be safe even on non-Git URLs
+			git.NormalizeURL( // This should be safe even on non-Git URLs
 				string(urlBytes),
 			),
 		)
diff --git a/internal/git/git.go b/internal/git/git.go
index d1c564e4..f89a1da1 100644
--- a/internal/git/git.go
+++ b/internal/git/git.go
@@ -1,57 +1,78 @@
 package git
 
 import (
+	"fmt"
 	"net/url"
 	"regexp"
 	"strings"
 )
 
-// NormalizeGitURL normalizes a git URL for purposes of comparison, as well as preventing redundant
-// local clones (by normalizing various forms of a URL to a consistent location).
-// Prefer using SameURL() over this function when possible. This algorithm may change over time
-// and should not be considered stable from release to release
-func NormalizeGitURL(repo string) string {
-	repo = strings.ToLower(strings.TrimSpace(repo))
-	if yes, _ := IsSSHURL(repo); yes {
-		if !strings.HasPrefix(repo, "ssh://") {
-			// We need to replace the first colon in git@server... style SSH URLs with a slash, otherwise
-			// net/url.Parse will interpret it incorrectly as the port.
-			repo = strings.Replace(repo, ":", "/", 1)
-			repo = ensurePrefix(repo, "ssh://")
-		}
-	}
-	repo = removeSuffix(repo, ".git")
-	repoURL, err := url.Parse(repo)
-	if err != nil {
-		return ""
-	}
-	normalized := repoURL.String()
-	return strings.TrimPrefix(normalized, "ssh://")
-}
+var scpSyntaxRegex = regexp.MustCompile(`^((?:[\w-]+@)?[\w-]+(?:\.[\w-]+)*)(?::(.*))?$`)
 
-var sshURLRegex = regexp.MustCompile("^(ssh://)?([^/:]*?)@[^@]+$")
+// NormalizeURL normalizes Git URLs of the following forms:
+//
+//   - http[s]://[proxy-user:proxy-pass@]host.xz[:port][/path/to/repo[.git][/]]
+//   - ssh://[user@]host.xz[:port][/path/to/repo[.git][/]]
+//   - [user@]host.xz[:path/to/repo[.git][/]]
+//
+// This is useful for the purposes of comparison and also in cases where a
+// canonical representation of a Git URL is needed. Any URL that cannot be
+// normalized will be returned as-is.
+func NormalizeURL(repo string) string {
+	origRepo := repo
+	repo = strings.ToLower(repo)
 
-// IsSSHURL returns true if supplied URL is SSH URL
-func IsSSHURL(sshUrl string) (bool, string) {
-	matches := sshURLRegex.FindStringSubmatch(sshUrl)
-	if len(matches) > 2 {
-		return true, matches[2]
+	// HTTP/S URLs
+	if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") {
+		repoURL, err := url.Parse(repo)
+		if err != nil {
+			return origRepo
+		}
+		if len(repoURL.Query()) > 0 {
+			// Query parameters are not permitted
+			return origRepo
+		}
+		repoURL.User = nil // Remove user info if there is any
+		repoURL.Path = strings.TrimSuffix(repoURL.Path, "/")
+		repoURL.Path = strings.TrimSuffix(repoURL.Path, ".git")
+		return repoURL.String()
 	}
-	return false, ""
-}
 
-// removeSuffix idempotently removes a given suffix
-func removeSuffix(s, suffix string) string {
-	if strings.HasSuffix(s, suffix) {
-		return s[0 : len(s)-len(suffix)]
+	// URLS of the form ssh://[user@]host.xz[:port][/path/to/repo[.git][/]]
+	if strings.HasPrefix(repo, "ssh://") {
+		// repo = strings.TrimPrefix(repo, "ssh://")
+		repoURL, err := url.Parse(repo)
+		if err != nil {
+			return origRepo
+		}
+		if len(repoURL.Query()) > 0 {
+			// Query parameters are not permitted
+			return origRepo
+		}
+		repoURL.Path = strings.TrimSuffix(repoURL.Path, "/")
+		repoURL.Path = strings.TrimSuffix(repoURL.Path, ".git")
+		return repoURL.String()
 	}
-	return s
-}
 
-// EnsurePrefix idempotently ensures that a base string has a given prefix.
-func ensurePrefix(s, prefix string) string {
-	if !strings.HasPrefix(s, prefix) {
-		s = prefix + s
+	// URLS of the form [user@]host.xz[:path/to/repo[.git][/]]
+	matches := scpSyntaxRegex.FindStringSubmatch(repo)
+	if len(matches) != 2 && len(matches) != 3 {
+		// This URL doesn't appear to be in a format we recognize
+		return origRepo
+	}
+	userHost := matches[1]
+	var path string
+	if len(matches) == 3 {
+		path = matches[2]
+	}
+	pathURL, err := url.Parse(path)
+	if err != nil {
+		return origRepo
+	}
+	pathURL.Path = strings.TrimSuffix(pathURL.Path, "/")
+	pathURL.Path = strings.TrimSuffix(pathURL.Path, ".git")
+	if pathURL.Path == "" {
+		return fmt.Sprintf("ssh://%s", userHost)
 	}
-	return s
+	return fmt.Sprintf("ssh://%s/%s", userHost, pathURL.String())
 }
diff --git a/internal/kubernetes/event/event.go b/internal/kubernetes/event/event.go
new file mode 100644
index 00000000..0dd6362b
--- /dev/null
+++ b/internal/kubernetes/event/event.go
@@ -0,0 +1,139 @@
+package event
+
+import (
+	"context"
+	"errors"
+
+	log "github.com/sirupsen/logrus"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/tools/record"
+	"k8s.io/client-go/util/retry"
+	libClient "sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/akuity/kargo/internal/logging"
+)
+
+type recorder struct {
+	backoff wait.Backoff
+	sink    record.EventSink
+	logger  *log.Entry
+
+	newEventHandlerFn func(event *corev1.Event) func() error
+}
+
+// NewRecorder returns a new record.EventRecorder that records all events
+// without aggregation, even the given event is correlated.
+//
+// NOTE: This recorder must be used with caution as it creates a new Event
+// on every event without throttling / spam filtering features - which are
+// included in the event recorder from k8s.io/client-go. This could lead
+// to performance issues with the Kubernetes API server.
+func NewRecorder(
+	ctx context.Context,
+	scheme *runtime.Scheme,
+	client libClient.Client,
+	name string,
+) record.EventRecorder {
+	logger := logging.LoggerFromContext(ctx)
+	internalRecorder := newRecorder(ctx, client, logger)
+	b := record.NewBroadcaster()
+	b.StartEventWatcher(internalRecorder.handleEvent)
+	return b.NewRecorder(
+		scheme,
+		corev1.EventSource{
+			Component: name,
+		},
+	)
+}
+
+func newRecorder(
+	ctx context.Context,
+	client libClient.Client,
+	logger *log.Entry,
+) *recorder {
+	r := &recorder{
+		backoff: retry.DefaultRetry, // TODO: Make it configurable
+		sink:    newSink(ctx, client),
+		logger:  logger,
+	}
+	r.newEventHandlerFn = r.createEvent
+	return r
+}
+
+func (r *recorder) handleEvent(event *corev1.Event) {
+	if err := retry.OnError(
+		r.backoff,
+		r.newRetryDecider(event),
+		r.newEventHandlerFn(event),
+	); err != nil {
+		r.logger.WithError(err).Error("Unable to handle event", "event", event)
+	}
+}
+
+func (r *recorder) createEvent(event *corev1.Event) func() error {
+	return func() error {
+		// Always create event instead of patching correlated events
+		_, err := r.sink.Create(event)
+		return err
+	}
+}
+
+// newRetryDecider returns a function that decides whether
+// to re-record event or not on given error.
+func (r *recorder) newRetryDecider(event *corev1.Event) func(error) bool {
+	return func(err error) bool {
+		logger := r.logger.
+			WithField("event", event).
+			WithError(err)
+
+		var statusErr *apierrors.StatusError
+		if errors.As(err, &statusErr) {
+			if apierrors.IsAlreadyExists(err) ||
+				apierrors.HasStatusCause(err, corev1.NamespaceTerminatingCause) {
+				logger.Info("Server rejected event (will not retry!)")
+				return false
+			}
+			// Retry on other status errors
+		}
+		logger.Error("Unable to write event (may retry after backoff)")
+		return true
+	}
+}
+
+var (
+	_ record.EventSink = &sink{}
+)
+
+type sink struct {
+	ctx    context.Context
+	client libClient.Client
+}
+
+func newSink(
+	ctx context.Context,
+	client libClient.Client,
+) *sink {
+	return &sink{
+		ctx:    ctx,
+		client: client,
+	}
+}
+
+func (e *sink) Create(event *corev1.Event) (*corev1.Event, error) {
+	err := e.client.Create(e.ctx, event)
+	return event, err
+}
+
+func (e *sink) Update(event *corev1.Event) (*corev1.Event, error) {
+	err := e.client.Update(e.ctx, event)
+	return event, err
+}
+
+func (e *sink) Patch(event *corev1.Event, data []byte) (*corev1.Event, error) {
+	err := e.client.Patch(e.ctx, event, libClient.RawPatch(types.StrategicMergePatchType, data))
+	return event, err
+}
diff --git a/internal/kubeclient/fake/event.go b/internal/kubernetes/event/fake/event.go
similarity index 100%
rename from internal/kubeclient/fake/event.go
rename to internal/kubernetes/event/fake/event.go
diff --git a/internal/webhook/freight/webhook.go b/internal/webhook/freight/webhook.go
index e4409026..c94f1600 100644
--- a/internal/webhook/freight/webhook.go
+++ b/internal/webhook/freight/webhook.go
@@ -19,6 +19,7 @@ import (
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/kubeclient"
+	libEvent "github.com/akuity/kargo/internal/kubernetes/event"
 	libWebhook "github.com/akuity/kargo/internal/webhook"
 )
 
@@ -68,13 +69,14 @@ type webhook struct {
 }
 
 func SetupWebhookWithManager(
+	ctx context.Context,
 	cfg libWebhook.Config,
 	mgr ctrl.Manager,
 ) error {
 	w := newWebhook(
 		cfg,
 		mgr.GetClient(),
-		mgr.GetEventRecorderFor("freight-webhook"),
+		libEvent.NewRecorder(ctx, mgr.GetScheme(), mgr.GetClient(), "freight-webhook"),
 	)
 	return ctrl.NewWebhookManagedBy(mgr).
 		For(&kargoapi.Freight{}).
diff --git a/internal/webhook/promotion/webhook.go b/internal/webhook/promotion/webhook.go
index 4801a2ea..579f390a 100644
--- a/internal/webhook/promotion/webhook.go
+++ b/internal/webhook/promotion/webhook.go
@@ -18,6 +18,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	libEvent "github.com/akuity/kargo/internal/kubernetes/event"
 	"github.com/akuity/kargo/internal/logging"
 	libWebhook "github.com/akuity/kargo/internal/webhook"
 )
@@ -77,13 +78,14 @@ type webhook struct {
 }
 
 func SetupWebhookWithManager(
+	ctx context.Context,
 	cfg libWebhook.Config,
 	mgr ctrl.Manager,
 ) error {
 	w := newWebhook(
 		cfg,
 		mgr.GetClient(),
-		mgr.GetEventRecorderFor("promotion-webhook"),
+		libEvent.NewRecorder(ctx, mgr.GetScheme(), mgr.GetClient(), "promotion-webhook"),
 	)
 	return ctrl.NewWebhookManagedBy(mgr).
 		For(&kargoapi.Promotion{}).
@@ -137,6 +139,13 @@ func (w *webhook) Default(ctx context.Context, obj runtime.Object) error {
 			promo.Namespace,
 		)
 	}
+	if stage.Spec.PromotionMechanisms == nil {
+		return fmt.Errorf(
+			"Stage %q in namespace %q has no PromotionMechanisms",
+			promo.Spec.Stage,
+			promo.Namespace,
+		)
+	}
 
 	// Make sure the Promotion has the same shard as the Stage
 	if stage.Spec.Shard != "" {
diff --git a/internal/webhook/warehouse/webhook.go b/internal/webhook/warehouse/webhook.go
index 7039ead3..39a5e4ec 100644
--- a/internal/webhook/warehouse/webhook.go
+++ b/internal/webhook/warehouse/webhook.go
@@ -284,7 +284,7 @@ type subscriptionKey struct {
 type uniqueSubSet map[subscriptionKey]*field.Path
 
 func (s uniqueSubSet) addGit(sub kargoapi.GitSubscription, p *field.Path) error {
-	k := subscriptionKey{kind: "git", id: git.NormalizeGitURL(sub.RepoURL)}
+	k := subscriptionKey{kind: "git", id: git.NormalizeURL(sub.RepoURL)}
 	if _, exists := s[k]; exists {
 		return fmt.Errorf("subscription for Git repository already exists at %q", s[k])
 	}
diff --git a/ui/package.json b/ui/package.json
index 401a6a0e..6cfa186d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -43,7 +43,7 @@
     "vitest": "^0.34.6",
     "zx": "^7.2.3"
   },
-  "packageManager": "pnpm@8.1.0",
+  "packageManager": "pnpm@9.0.2",
   "dependencies": {
     "@bufbuild/protobuf": "1.8.0",
     "@connectrpc/connect": "1.4.0",
diff --git a/ui/src/features/stage/analysis-run-modal.tsx b/ui/src/features/stage/analysis-run-modal.tsx
index 910d07c8..c184748e 100644
--- a/ui/src/features/stage/analysis-run-modal.tsx
+++ b/ui/src/features/stage/analysis-run-modal.tsx
@@ -32,7 +32,7 @@ export const AnalysisRunModal = ({ visible, hide, name }: Props) => {
       {isLoading ? (
         <LoadingState />
       ) : (
-        <YamlEditor value={yaml.stringify(data?.analysisRun?.toJson())} height='500px' disabled />
+        <YamlEditor value={yaml.stringify(data?.result?.value?.toJson())} height='500px' disabled />
       )}
     </Modal>
   );
diff --git a/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json b/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json
index 897214d1..177b42e4 100644
--- a/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json
+++ b/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json
@@ -73,7 +73,7 @@
                     "type": "string"
                   },
                   "excludePaths": {
-                    "description": "ExcludePaths is an optional list of regular expressions used to specify paths\nin git repository, changes in which should never produce new freight. When used\nin conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes\nprecedence over IncludePaths.",
+                    "description": "ExcludePaths is an optional list of filter strings used to specify paths\nin git repository, changes in which should never produce new freight. When used\nin conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes\nprecedence over IncludePaths.\nFilter strings can be specified as prefix/exact match strings, globs or regular\nexpressions, default being prefix/exact match, for example \"helm/charts/\", for\nregular expressions strings need to be prepended by either \"regex:\" or \"regexp:\"\nas in \"regexp:^.*values\\.yaml$\", for globs with \"glob:\" as in \"glob:helm/*/*/values.*ml\"",
                     "items": {
                       "type": "string"
                     },
@@ -87,7 +87,7 @@
                     "type": "array"
                   },
                   "includePaths": {
-                    "description": "IncludePaths is a list of regular expressions that can optionally be used to\nlimit file paths in repository, changes in which will result in creation of\nnew freight. When not specified - changes in any path will produce new\nfreight, it is equivalent to having a IncludePaths with an entry of \".*\"\nWhen both IncludePaths and ExcludePaths are specified and match same path/paths,\nExcludePaths will prevail over IncludePaths.",
+                    "description": "IncludePaths is a list of filter strings that can optionally be used to\nlimit file paths in repository, changes in which will result in creation of\nnew freight. When not specified - changes in any path will produce new\nfreight, it is equivalent to having a IncludePaths with an entry of \"regex:.*\"\nWhen both IncludePaths and ExcludePaths are specified and match same path/paths,\nExcludePaths will prevail over IncludePaths.\nFilter strings can be specified as prefix/exact match strings, globs or regular\nexpressions, default being prefix/exact match, for example \"helm/charts/\", for\nregular expressions strings need to be prepended by either \"regex:\" or \"regexp:\"\nas in \"regexp:^.*values\\.yaml$\", for globs with \"glob:\" as in \"glob:helm/*/*/values.*ml\"",
                     "items": {
                       "type": "string"
                     },
diff --git a/ui/src/gen/v1alpha1/generated_pb.ts b/ui/src/gen/v1alpha1/generated_pb.ts
index bd9f8c76..e6b7ae24 100644
--- a/ui/src/gen/v1alpha1/generated_pb.ts
+++ b/ui/src/gen/v1alpha1/generated_pb.ts
@@ -1592,12 +1592,16 @@ export class GitSubscription extends Message<GitSubscription> {
   insecureSkipTLSVerify?: boolean;
 
   /**
-   * IncludePaths is a list of regular expressions that can optionally be used to
+   * IncludePaths is a list of filter strings that can optionally be used to
    * limit file paths in repository, changes in which will result in creation of
    * new freight. When not specified - changes in any path will produce new
-   * freight, it is equivalent to having a IncludePaths with an entry of ".*"
+   * freight, it is equivalent to having a IncludePaths with an entry of "regex:.*"
    * When both IncludePaths and ExcludePaths are specified and match same path/paths,
    * ExcludePaths will prevail over IncludePaths.
+   * Filter strings can be specified as prefix/exact match strings, globs or regular
+   * expressions, default being prefix/exact match, for example "helm/charts/", for
+   * regular expressions strings need to be prepended by either "regex:" or "regexp:"
+   * as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*\/*\/values.*ml"
    * +kubebuilder:validation:Optional
    *
    * @generated from field: repeated string includePaths = 8;
@@ -1605,10 +1609,14 @@ export class GitSubscription extends Message<GitSubscription> {
   includePaths: string[] = [];
 
   /**
-   * ExcludePaths is an optional list of regular expressions used to specify paths
+   * ExcludePaths is an optional list of filter strings used to specify paths
    * in git repository, changes in which should never produce new freight. When used
    * in conjuction with IncludePaths, both matching same path/paths, ExcludePaths takes
    * precedence over IncludePaths.
+   * Filter strings can be specified as prefix/exact match strings, globs or regular
+   * expressions, default being prefix/exact match, for example "helm/charts/", for
+   * regular expressions strings need to be prepended by either "regex:" or "regexp:"
+   * as in "regexp:^.*values\.yaml$", for globs with "glob:" as in "glob:helm/*\/*\/values.*ml"
    * +kubebuilder:validation:Optional
    *
    * @generated from field: repeated string excludePaths = 9;


Test patch:
diff --git a/internal/api/approve_freight_v1alpha1_test.go b/internal/api/approve_freight_v1alpha1_test.go
index 45477e8f..ac9b9516 100644
--- a/internal/api/approve_freight_v1alpha1_test.go
+++ b/internal/api/approve_freight_v1alpha1_test.go
@@ -13,7 +13,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
-	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
@@ -24,7 +24,7 @@ func TestApproveFreight(t *testing.T) {
 		server     *server
 		assertions func(
 			*testing.T,
-			*fakekubeclient.EventRecorder,
+			*fakeevent.EventRecorder,
 			*connect.Response[svcv1alpha1.ApproveFreightResponse],
 			error,
 		)
@@ -35,7 +35,7 @@ func TestApproveFreight(t *testing.T) {
 			server: &server{},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -59,7 +59,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -90,7 +90,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -121,7 +121,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -163,7 +163,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -201,7 +201,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -252,7 +252,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -306,7 +306,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -360,7 +360,7 @@ func TestApproveFreight(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.ApproveFreightResponse],
 				err error,
 			) {
@@ -374,7 +374,7 @@ func TestApproveFreight(t *testing.T) {
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			recorder := fakekubeclient.NewEventRecorder(1)
+			recorder := fakeevent.NewEventRecorder(1)
 			testCase.server.recorder = recorder
 			resp, err := testCase.server.ApproveFreight(
 				context.Background(),
diff --git a/internal/api/promote_to_stage_subscribers_v1alpha1_test.go b/internal/api/promote_to_stage_subscribers_v1alpha1_test.go
index a6c874f5..bad6d64c 100644
--- a/internal/api/promote_to_stage_subscribers_v1alpha1_test.go
+++ b/internal/api/promote_to_stage_subscribers_v1alpha1_test.go
@@ -13,7 +13,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
-	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
@@ -24,7 +24,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 		server     *server
 		assertions func(
 			*testing.T,
-			*fakekubeclient.EventRecorder,
+			*fakeevent.EventRecorder,
 			*connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 			error,
 		)
@@ -34,7 +34,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			server: &server{},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -58,7 +58,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -87,7 +87,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -116,7 +116,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -166,7 +166,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -212,7 +212,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -265,7 +265,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -324,7 +324,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -379,7 +379,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -449,7 +449,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -499,7 +499,13 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 					context.Context,
 					*kargoapi.Stage,
 				) ([]kargoapi.Stage, error) {
-					return []kargoapi.Stage{{}}, nil
+					return []kargoapi.Stage{
+						{
+							Spec: &kargoapi.StageSpec{
+								PromotionMechanisms: &kargoapi.PromotionMechanisms{},
+							},
+						},
+					}, nil
 				},
 				authorizeFn: func(
 					context.Context,
@@ -520,7 +526,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -573,7 +579,13 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 					context.Context,
 					*kargoapi.Stage,
 				) ([]kargoapi.Stage, error) {
-					return []kargoapi.Stage{{}}, nil
+					return []kargoapi.Stage{
+						{
+							Spec: &kargoapi.StageSpec{
+								PromotionMechanisms: &kargoapi.PromotionMechanisms{},
+							},
+						},
+					}, nil
 				},
 				authorizeFn: func(
 					context.Context,
@@ -594,7 +606,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				res *connect.Response[svcv1alpha1.PromoteToStageSubscribersResponse],
 				err error,
 			) {
@@ -610,7 +622,7 @@ func TestPromoteToStageSubscribers(t *testing.T) {
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			recorder := fakekubeclient.NewEventRecorder(1)
+			recorder := fakeevent.NewEventRecorder(1)
 			testCase.server.recorder = recorder
 			resp, err := testCase.server.PromoteToStageSubscribers(
 				context.Background(),
diff --git a/internal/api/promote_to_stage_v1alpha1_test.go b/internal/api/promote_to_stage_v1alpha1_test.go
index 47f9eb2f..771a4d22 100644
--- a/internal/api/promote_to_stage_v1alpha1_test.go
+++ b/internal/api/promote_to_stage_v1alpha1_test.go
@@ -13,7 +13,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
-	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
 	svcv1alpha1 "github.com/akuity/kargo/pkg/api/service/v1alpha1"
 )
 
@@ -24,7 +24,7 @@ func TestPromoteToStage(t *testing.T) {
 		server     *server
 		assertions func(
 			*testing.T,
-			*fakekubeclient.EventRecorder,
+			*fakeevent.EventRecorder,
 			*connect.Response[svcv1alpha1.PromoteToStageResponse],
 			error,
 		)
@@ -35,7 +35,7 @@ func TestPromoteToStage(t *testing.T) {
 			server: &server{},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -59,7 +59,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -88,7 +88,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -117,7 +117,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -167,7 +167,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -213,7 +213,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -266,7 +266,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -330,7 +330,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -395,7 +395,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				_ *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -460,7 +460,7 @@ func TestPromoteToStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				res *connect.Response[svcv1alpha1.PromoteToStageResponse],
 				err error,
 			) {
@@ -476,7 +476,7 @@ func TestPromoteToStage(t *testing.T) {
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			recorder := fakekubeclient.NewEventRecorder(1)
+			recorder := fakeevent.NewEventRecorder(1)
 			testCase.server.recorder = recorder
 			res, err := testCase.server.PromoteToStage(
 				context.Background(),
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 7953ed8b..5155800a 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -12,7 +12,7 @@ import (
 
 	"github.com/akuity/kargo/internal/api/config"
 	"github.com/akuity/kargo/internal/api/kubernetes"
-	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
 )
 
 func TestNewServer(t *testing.T) {
@@ -31,7 +31,7 @@ func TestNewServer(t *testing.T) {
 		},
 	)
 	require.NoError(t, err)
-	testRecorder := fakekubeclient.NewEventRecorder(0)
+	testRecorder := fakeevent.NewEventRecorder(0)
 
 	s, ok := NewServer(
 		testServerConfig,
diff --git a/internal/controller/promotions/promotions_test.go b/internal/controller/promotions/promotions_test.go
index e47d2bc5..463d39ff 100644
--- a/internal/controller/promotions/promotions_test.go
+++ b/internal/controller/promotions/promotions_test.go
@@ -15,7 +15,7 @@ import (
 	"github.com/akuity/kargo/api/v1alpha1"
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/credentials"
-	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
 )
 
 func TestNewPromotionReconciler(t *testing.T) {
@@ -23,7 +23,7 @@ func TestNewPromotionReconciler(t *testing.T) {
 	r := newReconciler(
 		kubeClient,
 		kubeClient,
-		&fakekubeclient.EventRecorder{},
+		&fakeevent.EventRecorder{},
 		&credentials.FakeDB{},
 		ReconcilerConfig{},
 	)
@@ -35,7 +35,7 @@ func TestNewPromotionReconciler(t *testing.T) {
 
 func newFakeReconciler(
 	t *testing.T,
-	recorder *fakekubeclient.EventRecorder,
+	recorder *fakeevent.EventRecorder,
 	objects ...client.Object,
 ) *reconciler {
 	scheme := k8sruntime.NewScheme()
@@ -151,7 +151,7 @@ func TestReconcile(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
 			ctx := context.TODO()
-			recorder := fakekubeclient.NewEventRecorder(1)
+			recorder := fakeevent.NewEventRecorder(1)
 			r := newFakeReconciler(t, recorder, tc.promos...)
 			promoteWasCalled := false
 			r.getStageFn = func(context.Context, client.Client, types.NamespacedName) (*kargoapi.Stage, error) {
@@ -204,7 +204,7 @@ func TestReconcileInitializeQueues(t *testing.T) {
 		newPromo("fake-namespace", "fake-promo1", "fake-stage", kargoapi.PromotionPhasePending, before),
 		newPromo("fake-namespace", "fake-promo2", "fake-stage", kargoapi.PromotionPhasePending, now),
 	}
-	recorder := &fakekubeclient.EventRecorder{}
+	recorder := &fakeevent.EventRecorder{}
 	r := newFakeReconciler(t, recorder, promos...)
 
 	// reconcile a non-existent promo to trigger initializeQueues
diff --git a/internal/controller/stages/stages_test.go b/internal/controller/stages/stages_test.go
index 11a81ec5..ec2cbc80 100644
--- a/internal/controller/stages/stages_test.go
+++ b/internal/controller/stages/stages_test.go
@@ -16,7 +16,7 @@ import (
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/controller"
 	rollouts "github.com/akuity/kargo/internal/controller/rollouts/api/v1alpha1"
-	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
 )
 
 var (
@@ -30,7 +30,7 @@ func TestNewReconciler(t *testing.T) {
 	kubeClient := fake.NewClientBuilder().Build()
 	requirement, err := controller.GetShardRequirement(testCfg.ShardName)
 	require.NoError(t, err)
-	recorder := &fakekubeclient.EventRecorder{Events: nil}
+	recorder := &fakeevent.EventRecorder{Events: nil}
 	r := newReconciler(
 		kubeClient,
 		kubeClient,
@@ -83,7 +83,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 		reconciler *reconciler
 		assertions func(
 			t *testing.T,
-			recorder *fakekubeclient.EventRecorder,
+			recorder *fakeevent.EventRecorder,
 			initialStatus kargoapi.StageStatus,
 			newStatus kargoapi.StageStatus,
 			err error,
@@ -112,7 +112,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -152,7 +152,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -204,7 +204,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -257,7 +257,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -279,7 +279,7 @@ func TestSyncControlFlowStage(t *testing.T) {
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			recorder := fakekubeclient.NewEventRecorder(1)
+			recorder := fakeevent.NewEventRecorder(1)
 			testCase.reconciler.nowFn = fakeNow
 			testCase.reconciler.recorder = recorder
 			newStatus, err := testCase.reconciler.syncControlFlowStage(
@@ -306,7 +306,7 @@ func TestSyncNormalStage(t *testing.T) {
 		reconciler *reconciler
 		assertions func(
 			t *testing.T,
-			recorder *fakekubeclient.EventRecorder,
+			recorder *fakeevent.EventRecorder,
 			initialStatus kargoapi.StageStatus,
 			newStatus kargoapi.StageStatus,
 			err error,
@@ -326,7 +326,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -355,7 +355,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -413,7 +413,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -484,7 +484,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -533,7 +533,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -606,7 +606,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -680,7 +680,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -754,7 +754,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -846,7 +846,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -951,7 +951,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -994,7 +994,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1050,7 +1050,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1116,7 +1116,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1183,7 +1183,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1256,7 +1256,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1319,7 +1319,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1391,7 +1391,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				_ *fakekubeclient.EventRecorder,
+				_ *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1467,7 +1467,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				initialStatus kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1597,7 +1597,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1736,7 +1736,7 @@ func TestSyncNormalStage(t *testing.T) {
 			},
 			assertions: func(
 				t *testing.T,
-				recorder *fakekubeclient.EventRecorder,
+				recorder *fakeevent.EventRecorder,
 				_ kargoapi.StageStatus,
 				newStatus kargoapi.StageStatus,
 				err error,
@@ -1782,7 +1782,7 @@ func TestSyncNormalStage(t *testing.T) {
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			recorder := fakekubeclient.NewEventRecorder(2)
+			recorder := fakeevent.NewEventRecorder(2)
 			testCase.reconciler.nowFn = fakeNow
 			testCase.reconciler.recorder = recorder
 			newStatus, err := testCase.reconciler.syncNormalStage(
diff --git a/internal/controller/warehouses/git_test.go b/internal/controller/warehouses/git_test.go
index 89bbdf68..b8dc9576 100644
--- a/internal/controller/warehouses/git_test.go
+++ b/internal/controller/warehouses/git_test.go
@@ -269,12 +269,12 @@ func TestSelectCommitID(t *testing.T) {
 				require.Contains(
 					t,
 					err.Error(),
-					"error checking includePaths/excludePaths match for commit \"fake-commit\"",
+					"error checking includePaths/excludePaths match for commit",
 				)
 				require.Contains(
 					t,
 					err.Error(),
-					"error compiling includePaths regexps: error compiling string \"[\" into a regular expression",
+					"error parsing regexp: missing closing ]",
 				)
 				require.Contains(t, err.Error(), "error parsing regexp: missing closing ]")
 			},
@@ -681,7 +681,7 @@ func TestMatchesPathsFilters(t *testing.T) {
 			},
 		},
 		{
-			name:         "success with a matching filters configuration",
+			name:         "success with a matching regexp filters configuration",
 			includePaths: []string{regexpPrefix + "values\\.ya?ml$"},
 			excludePaths: []string{regexPrefix + "nonexistent"},
 			diffs:        []string{"path1/values.yaml", "path2/_helpers.tpl"},
@@ -691,7 +691,7 @@ func TestMatchesPathsFilters(t *testing.T) {
 			},
 		},
 		{
-			name:         "success with unmatching filters configuration",
+			name:         "success with unmatching regexp filters configuration",
 			includePaths: []string{regexpPrefix + "values\\.ya?ml$"},
 			excludePaths: []string{regexPrefix + "nonexistent", regexpPrefix + ".*val.*"},
 			diffs:        []string{"path1/values.yaml", "path2/_helpers.tpl"},
@@ -701,32 +701,62 @@ func TestMatchesPathsFilters(t *testing.T) {
 			},
 		},
 		{
-			name:         "error with invalid regexp in excludePaths configuration",
-			includePaths: []string{regexPrefix + "values\\.ya?ml$"},
-			excludePaths: []string{regexpPrefix + "nonexistent", regexpPrefix + ".*val.*", regexPrefix + "["},
+			name:         "success with matching glob filters configuration",
+			includePaths: []string{"glob:path2/*.tpl"},
+			excludePaths: []string{"nonexistent"},
 			diffs:        []string{"path1/values.yaml", "path2/_helpers.tpl"},
-			assertions: func(t *testing.T, _ bool, err error) {
-				require.Error(t, err)
-				require.Contains(t, err.Error(), "error compiling excludePaths regexps:")
-				require.Contains(t, err.Error(), "error compiling string \"[\" into a regular expression")
+			assertions: func(t *testing.T, matchFound bool, err error) {
+				require.NoError(t, err)
+				require.Equal(t, true, matchFound)
+			},
+		},
+		{
+			name:         "success with unmatching glob filters configuration",
+			includePaths: []string{"path2/*.tpl"},
+			excludePaths: []string{regexPrefix + "nonexistent", "*/?helpers.tpl"},
+			diffs:        []string{"path1/values.yaml", "path2/_helpers.tpl"},
+			assertions: func(t *testing.T, matchFound bool, err error) {
+				require.NoError(t, err)
+				require.Equal(t, false, matchFound)
+			},
+		},
+		{
+			name:         "success with matching prefix filters configuration",
+			includePaths: []string{"path1/"},
+			excludePaths: []string{"nonexistent"},
+			diffs:        []string{"path1/values.yaml", "path2/_helpers.tpl"},
+			assertions: func(t *testing.T, matchFound bool, err error) {
+				require.NoError(t, err)
+				require.Equal(t, true, matchFound)
+			},
+		},
+		{
+			name:         "success with unmatching prefix filters configuration",
+			includePaths: []string{"path3/"},
+			excludePaths: []string{regexPrefix + "nonexistent", "*/?helpers.tpl"},
+			diffs:        []string{"path1/values.yaml", "path2/_helpers.tpl"},
+			assertions: func(t *testing.T, matchFound bool, err error) {
+				require.NoError(t, err)
+				require.Equal(t, false, matchFound)
 			},
 		},
 		{
-			name:         "error without prefix in includePaths configuration",
-			includePaths: []string{"values\\.ya?ml$"},
+			name:         "error with invalid regexp in excludePaths configuration",
+			includePaths: []string{regexPrefix + "values\\.ya?ml$"},
+			excludePaths: []string{regexpPrefix + "nonexistent", regexpPrefix + ".*val.*", regexPrefix + "["},
 			diffs:        []string{"path1/values.yaml", "path2/_helpers.tpl"},
 			assertions: func(t *testing.T, _ bool, err error) {
 				require.Error(t, err)
-				require.ErrorContains(t, err, "string must start with")
+				require.Contains(t, err.Error(), "error parsing regexp: missing closing ]: `[`")
 			},
 		},
 		{
-			name:         "error without prefix in excludePaths configuration",
-			excludePaths: []string{"values\\.ya?ml$"},
+			name:         "error with invalid glob syntax",
+			includePaths: []string{"glob:path2/*.tpl["},
 			diffs:        []string{"path1/values.yaml", "path2/_helpers.tpl"},
 			assertions: func(t *testing.T, _ bool, err error) {
 				require.Error(t, err)
-				require.ErrorContains(t, err, "string must start with")
+				require.ErrorContains(t, err, "syntax error in pattern")
 			},
 		},
 	}
diff --git a/internal/git/git_test.go b/internal/git/git_test.go
new file mode 100644
index 00000000..53f5c555
--- /dev/null
+++ b/internal/git/git_test.go
@@ -0,0 +1,90 @@
+package git
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestNormalizeGitURL(t *testing.T) {
+	testCases := map[string]string{
+		// Anything we can't normalize should be returned as-is
+		"https://not a url":                      "https://not a url",
+		"http://github.com/example/repo?foo=bar": "http://github.com/example/repo?foo=bar",
+		"ssh://not a url":                        "ssh://not a url",
+		"ssh://github.com/example/repo?foo=bar":  "ssh://github.com/example/repo?foo=bar",
+		"not even remotely a url":                "not even remotely a url",
+		// URLs of the form http[s]://[proxy-user:proxy-pass@]host.xz[:port][/path/to/repo[.git][/]]
+		"https://github.com":          "https://github.com",
+		"https://github.com/":         "https://github.com",
+		"https://foo:bar@github.com":  "https://github.com",
+		"https://foo:bar@github.com/": "https://github.com",
+		// Variable features of the following URLs:
+		//   1000. Outbound proxy or not
+		//   0100. Port number or not
+		//   0010. .git suffix or not
+		//   0001. Trailing slash or not
+		"https://github.com/example/repo":                  "https://github.com/example/repo",     // 0000
+		"https://github.com/example/repo/":                 "https://github.com/example/repo",     // 0001
+		"https://github.com/example/repo.git":              "https://github.com/example/repo",     // 0010
+		"https://github.com/example/repo.git/":             "https://github.com/example/repo",     // 0011
+		"https://localhost:8443/example/repo":              "https://localhost:8443/example/repo", // 0100
+		"https://localhost:8443/example/repo/":             "https://localhost:8443/example/repo", // 0101
+		"https://localhost:8443/example/repo.git":          "https://localhost:8443/example/repo", // 0110
+		"https://localhost:8443/example/repo.git/":         "https://localhost:8443/example/repo", // 0111
+		"https://foo:bar@github.com/example/repo":          "https://github.com/example/repo",     // 1000
+		"https://foo:bar@github.com/example/repo/":         "https://github.com/example/repo",     // 1001
+		"https://foo:bar@github.com/example/repo.git":      "https://github.com/example/repo",     // 1010
+		"https://foo:bar@github.com/example/repo.git/":     "https://github.com/example/repo",     // 1011
+		"https://foo:bar@localhost:8443/example/repo":      "https://localhost:8443/example/repo", // 1100
+		"https://foo:bar@localhost:8443/example/repo/":     "https://localhost:8443/example/repo", // 1101
+		"https://foo:bar@localhost:8443/example/repo.git":  "https://localhost:8443/example/repo", // 1110
+		"https://foo:bar@localhost:8443/example/repo.git/": "https://localhost:8443/example/repo", // 1111
+		// URLS of the form ssh://[user@]host.xz[:port][/path/to/repo[.git][/]]
+		"ssh://git.example.com":      "ssh://git.example.com",
+		"ssh://git.example.com/":     "ssh://git.example.com",
+		"ssh://git@git.example.com":  "ssh://git@git.example.com",
+		"ssh://git@git.example.com/": "ssh://git@git.example.com",
+		// Variable features of the following URLs:
+		//   1000. Username or not
+		//   0100. Port number or not
+		//   0010. .git suffix or not
+		//   0001. Trailing slash or not
+		"ssh://github.com/example/repo":              "ssh://github.com/example/repo",         // 0000
+		"ssh://github.com/example/repo/":             "ssh://github.com/example/repo",         // 0001
+		"ssh://github.com/example/repo.git":          "ssh://github.com/example/repo",         // 0010
+		"ssh://github.com/example/repo.git/":         "ssh://github.com/example/repo",         // 0011
+		"ssh://localhost:2222/example/repo":          "ssh://localhost:2222/example/repo",     // 0100
+		"ssh://localhost:2222/example/repo/":         "ssh://localhost:2222/example/repo",     // 0101
+		"ssh://localhost:2222/example/repo.git":      "ssh://localhost:2222/example/repo",     // 0110
+		"ssh://localhost:2222/example/repo.git/":     "ssh://localhost:2222/example/repo",     // 0111
+		"ssh://git@github.com/example/repo":          "ssh://git@github.com/example/repo",     // 1000
+		"ssh://git@github.com/example/repo/":         "ssh://git@github.com/example/repo",     // 1001
+		"ssh://git@github.com/example/repo.git":      "ssh://git@github.com/example/repo",     // 1010
+		"ssh://git@github.com/example/repo.git/":     "ssh://git@github.com/example/repo",     // 1011
+		"ssh://git@localhost:2222/example/repo":      "ssh://git@localhost:2222/example/repo", // 1100
+		"ssh://git@localhost:2222/example/repo/":     "ssh://git@localhost:2222/example/repo", // 1101
+		"ssh://git@localhost:2222/example/repo.git":  "ssh://git@localhost:2222/example/repo", // 1110
+		"ssh://git@localhost:2222/example/repo.git/": "ssh://git@localhost:2222/example/repo", // 1111
+		// SCP-style URLs of the form [user@]host.xz[:path/to/repo[.git][/]]
+		"git.example.com":     "ssh://git.example.com",
+		"git@git.example.com": "ssh://git@git.example.com",
+		// Variable features of the following URLs:
+		//   100. Username or not
+		//   010. .git suffix or not
+		//   001. Trailing slash or not
+		"github.com:example/repo":          "ssh://github.com/example/repo",     // 000
+		"github.com:example/repo/":         "ssh://github.com/example/repo",     // 001
+		"github.com:example/repo.git":      "ssh://github.com/example/repo",     // 010
+		"github.com:example/repo.git/":     "ssh://github.com/example/repo",     // 011
+		"git@github.com:example/repo":      "ssh://git@github.com/example/repo", // 100
+		"git@github.com:example/repo/":     "ssh://git@github.com/example/repo", // 101
+		"git@github.com:example/repo.git":  "ssh://git@github.com/example/repo", // 110
+		"git@github.com:example/repo.git/": "ssh://git@github.com/example/repo", // 111
+	}
+	for in, out := range testCases {
+		t.Run(in, func(t *testing.T) {
+			require.Equal(t, out, NormalizeURL(in))
+		})
+	}
+}
diff --git a/internal/kubernetes/event/event_test.go b/internal/kubernetes/event/event_test.go
new file mode 100644
index 00000000..365e0e62
--- /dev/null
+++ b/internal/kubernetes/event/event_test.go
@@ -0,0 +1,86 @@
+package event
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"testing"
+
+	testlog "github.com/sirupsen/logrus/hooks/test"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	"github.com/akuity/kargo/internal/logging"
+)
+
+func Test_newRecorder(t *testing.T) {
+	ctx := context.TODO()
+	client := fake.NewClientBuilder().Build()
+	logger := logging.LoggerFromContext(ctx)
+	r := newRecorder(ctx, client, logger)
+
+	require.NotNil(t, r.backoff)
+	require.NotNil(t, r.sink)
+	require.NotNil(t, r.logger)
+	require.NotNil(t, r.newEventHandlerFn)
+}
+
+func Test_retryDecider(t *testing.T) {
+	eventGR := schema.GroupResource{
+		Group:    corev1.GroupName,
+		Resource: "Event",
+	}
+	testCases := map[string]struct {
+		input       error
+		shouldRetry bool
+	}{
+		"event already exists": {
+			input:       apierrors.NewAlreadyExists(eventGR, "fake-event"),
+			shouldRetry: false,
+		},
+		"namespace is terminating": {
+			input: &apierrors.StatusError{
+				ErrStatus: metav1.Status{
+					Code: http.StatusForbidden,
+					Details: &metav1.StatusDetails{
+						Causes: []metav1.StatusCause{
+							{
+								Type: corev1.NamespaceTerminatingCause,
+							},
+						},
+					},
+				},
+			},
+			shouldRetry: false,
+		},
+		"unknown error": {
+			input:       errors.New("unknown error"),
+			shouldRetry: true,
+		},
+	}
+	for name, tc := range testCases {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			logger, _ := testlog.NewNullLogger()
+			r := &recorder{
+				logger: logger.WithFields(nil),
+			}
+			require.Equal(t, tc.shouldRetry, r.newRetryDecider(&corev1.Event{})(tc.input))
+		})
+	}
+}
+
+func Test_newSink(t *testing.T) {
+	s := newSink(
+		context.TODO(),
+		fake.NewClientBuilder().Build(),
+	)
+	require.NotNil(t, s.client)
+	require.NotNil(t, s.ctx)
+}
diff --git a/internal/webhook/freight/webhook_test.go b/internal/webhook/freight/webhook_test.go
index e652ebfb..80a16a35 100644
--- a/internal/webhook/freight/webhook_test.go
+++ b/internal/webhook/freight/webhook_test.go
@@ -20,7 +20,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
-	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
 	libWebhook "github.com/akuity/kargo/internal/webhook"
 )
 
@@ -29,7 +29,7 @@ func TestNewWebhook(t *testing.T) {
 	w := newWebhook(
 		libWebhook.Config{},
 		kubeClient,
-		&fakekubeclient.EventRecorder{},
+		&fakeevent.EventRecorder{},
 	)
 	require.NotNil(t, w.freightAliasGenerator)
 	// Assert that all overridable behaviors were initialized to a default:
@@ -311,7 +311,7 @@ func TestValidateUpdate(t *testing.T) {
 		webhook    *webhook
 		userInfo   *authnv1.UserInfo
 		setup      func() (*kargoapi.Freight, *kargoapi.Freight)
-		assertions func(*testing.T, *fakekubeclient.EventRecorder, error)
+		assertions func(*testing.T, *fakeevent.EventRecorder, error)
 	}{
 		{
 			name: "error listing freight",
@@ -341,7 +341,7 @@ func TestValidateUpdate(t *testing.T) {
 					return errors.New("something went wrong")
 				},
 			},
-			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, _ *fakeevent.EventRecorder, err error) {
 				statusErr, ok := err.(*apierrors.StatusError)
 				require.True(t, ok)
 				require.Equal(
@@ -382,7 +382,7 @@ func TestValidateUpdate(t *testing.T) {
 					return nil
 				},
 			},
-			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, _ *fakeevent.EventRecorder, err error) {
 				statusErr, ok := err.(*apierrors.StatusError)
 				require.True(t, ok)
 				require.Equal(t, int32(http.StatusConflict), statusErr.Status().Code)
@@ -417,7 +417,7 @@ func TestValidateUpdate(t *testing.T) {
 					return nil
 				},
 			},
-			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, _ *fakeevent.EventRecorder, err error) {
 				require.Error(t, err)
 				require.Contains(t, err.Error(), "is invalid")
 				require.Contains(t, err.Error(), "freight is immutable")
@@ -447,7 +447,7 @@ func TestValidateUpdate(t *testing.T) {
 					return nil
 				},
 			},
-			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, _ *fakeevent.EventRecorder, err error) {
 				require.Error(t, err)
 				require.Contains(t, err.Error(), "is invalid")
 				require.Contains(t, err.Error(), "freight is immutable")
@@ -488,7 +488,7 @@ func TestValidateUpdate(t *testing.T) {
 			userInfo: &authnv1.UserInfo{
 				Username: "fake-user",
 			},
-			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, r *fakeevent.EventRecorder, err error) {
 				require.NoError(t, err)
 				// Recorder should not record non-freight approval events
 				require.Empty(t, r.Events)
@@ -533,7 +533,7 @@ func TestValidateUpdate(t *testing.T) {
 			userInfo: &authnv1.UserInfo{
 				Username: "fake-user",
 			},
-			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, r *fakeevent.EventRecorder, err error) {
 				require.NoError(t, err)
 				require.Len(t, r.Events, 1)
 				event := <-r.Events
@@ -579,7 +579,7 @@ func TestValidateUpdate(t *testing.T) {
 			userInfo: &authnv1.UserInfo{
 				Username: serviceaccount.ServiceAccountUsernamePrefix + "kargo:kargo-api",
 			},
-			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, r *fakeevent.EventRecorder, err error) {
 				require.NoError(t, err)
 				require.Empty(t, r.Events)
 			},
@@ -589,7 +589,7 @@ func TestValidateUpdate(t *testing.T) {
 		t.Run(testCase.name, func(t *testing.T) {
 			oldFreight, newFreight := testCase.setup()
 
-			recorder := fakekubeclient.NewEventRecorder(1)
+			recorder := fakeevent.NewEventRecorder(1)
 			testCase.webhook.recorder = recorder
 
 			var req admission.Request
diff --git a/internal/webhook/promotion/webhook_test.go b/internal/webhook/promotion/webhook_test.go
index 8b02b316..33479792 100644
--- a/internal/webhook/promotion/webhook_test.go
+++ b/internal/webhook/promotion/webhook_test.go
@@ -18,7 +18,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
-	fakekubeclient "github.com/akuity/kargo/internal/kubeclient/fake"
+	fakeevent "github.com/akuity/kargo/internal/kubernetes/event/fake"
 	libWebhook "github.com/akuity/kargo/internal/webhook"
 )
 
@@ -27,7 +27,7 @@ func TestNewWebhook(t *testing.T) {
 	w := newWebhook(
 		libWebhook.Config{},
 		kubeClient,
-		&fakekubeclient.EventRecorder{},
+		&fakeevent.EventRecorder{},
 	)
 	// Assert that all overridable behaviors were initialized to a default:
 	require.NotNil(t, w.getFreightFn)
@@ -77,6 +77,24 @@ func TestDefault(t *testing.T) {
 				require.Contains(t, err.Error(), "could not find Stage")
 			},
 		},
+		{
+			name: "stage without promotion mechanisms",
+			webhook: &webhook{
+				getStageFn: func(
+					context.Context,
+					client.Client,
+					types.NamespacedName,
+				) (*kargoapi.Stage, error) {
+					return &kargoapi.Stage{
+						Spec: &kargoapi.StageSpec{},
+					}, nil
+				},
+			},
+			assertions: func(t *testing.T, _ *kargoapi.Promotion, err error) {
+				require.Error(t, err)
+				require.Contains(t, err.Error(), "has no PromotionMechanisms")
+			},
+		},
 		{
 			name: "success",
 			webhook: &webhook{
@@ -87,7 +105,8 @@ func TestDefault(t *testing.T) {
 				) (*kargoapi.Stage, error) {
 					return &kargoapi.Stage{
 						Spec: &kargoapi.StageSpec{
-							Shard: "fake-shard",
+							PromotionMechanisms: &kargoapi.PromotionMechanisms{},
+							Shard:               "fake-shard",
 						},
 					}, nil
 				},
@@ -117,7 +136,7 @@ func TestValidateCreate(t *testing.T) {
 		name       string
 		webhook    *webhook
 		userInfo   *authnv1.UserInfo
-		assertions func(*testing.T, *fakekubeclient.EventRecorder, error)
+		assertions func(*testing.T, *fakeevent.EventRecorder, error)
 	}{
 		{
 			name: "error validating project",
@@ -131,7 +150,7 @@ func TestValidateCreate(t *testing.T) {
 					return errors.New("something went wrong")
 				},
 			},
-			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, _ *fakeevent.EventRecorder, err error) {
 				require.Error(t, err)
 				require.Equal(t, "something went wrong", err.Error())
 			},
@@ -151,7 +170,7 @@ func TestValidateCreate(t *testing.T) {
 					return errors.New("something went wrong")
 				},
 			},
-			assertions: func(t *testing.T, _ *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, _ *fakeevent.EventRecorder, err error) {
 				require.Error(t, err)
 				require.Equal(t, "something went wrong", err.Error())
 			},
@@ -185,7 +204,7 @@ func TestValidateCreate(t *testing.T) {
 			userInfo: &authnv1.UserInfo{
 				Username: "fake-user",
 			},
-			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, r *fakeevent.EventRecorder, err error) {
 				require.NoError(t, err)
 				require.Len(t, r.Events, 1)
 				event := <-r.Events
@@ -214,7 +233,7 @@ func TestValidateCreate(t *testing.T) {
 			userInfo: &authnv1.UserInfo{
 				Username: serviceaccount.ServiceAccountUsernamePrefix + "kargo:kargo-api",
 			},
-			assertions: func(t *testing.T, r *fakekubeclient.EventRecorder, err error) {
+			assertions: func(t *testing.T, r *fakeevent.EventRecorder, err error) {
 				require.NoError(t, err)
 				require.Empty(t, r.Events)
 			},
@@ -222,7 +241,7 @@ func TestValidateCreate(t *testing.T) {
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			recorder := fakekubeclient.NewEventRecorder(1)
+			recorder := fakeevent.NewEventRecorder(1)
 			testCase.webhook.recorder = recorder
 
 			var req admission.Request
diff --git a/internal/webhook/warehouse/webhook_test.go b/internal/webhook/warehouse/webhook_test.go
index 271016ee..88a2533f 100644
--- a/internal/webhook/warehouse/webhook_test.go
+++ b/internal/webhook/warehouse/webhook_test.go
@@ -14,6 +14,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
+	"github.com/akuity/kargo/internal/git"
 )
 
 func TestNewWebhook(t *testing.T) {
@@ -461,7 +462,7 @@ func TestValidateSub(t *testing.T) {
 			seen: uniqueSubSet{
 				subscriptionKey{
 					kind: "git",
-					id:   "bogus",
+					id:   git.NormalizeURL("bogus"),
 				}: field.NewPath("spec.subscriptions[0].git"),
 			},
 			assertions: func(t *testing.T, sub kargoapi.RepoSubscription, errs field.ErrorList) {
@@ -540,7 +541,7 @@ func TestValidateGitSub(t *testing.T) {
 			seen: uniqueSubSet{
 				subscriptionKey{
 					kind: "git",
-					id:   "bogus",
+					id:   git.NormalizeURL("bogus"),
 				}: field.NewPath("spec.subscriptions[0].git"),
 			},
 			assertions: func(t *testing.T, errs field.ErrorList) {