Revert "[CD] Reland changes to use gitiles/gerrit"

This reverts commit 968d68c94d96110044833465d080a47c77bf433e.

Reason for revert: Still working on quota.

Original change's description:
> [CD] Reland changes to use gitiles/gerrit
>
> This reverts commit 80dcc5fbc3b068a4a1056cb71f693d7dc9c269d4.
>
> Change-Id: I2532718516e6c03e21476ff0f81d5347c57440aa
> Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/631316
> Commit-Queue: Eric Boren <borenet@google.com>
> Reviewed-by: Ravi Mistry <rmistry@google.com>

Change-Id: I409ff02e416003842a03530b8b91d7f9c16c2f3e
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/633996
Reviewed-by: Ravi Mistry <rmistry@google.com>
Bot-Commit: Rubber Stamper <rubber-stamper@appspot.gserviceaccount.com>
Commit-Queue: Eric Boren <borenet@google.com>
diff --git a/autoroll/go/autoroll-config-converter/BUILD.bazel b/autoroll/go/autoroll-config-converter/BUILD.bazel
index dc3d683..11d6a58 100644
--- a/autoroll/go/autoroll-config-converter/BUILD.bazel
+++ b/autoroll/go/autoroll-config-converter/BUILD.bazel
@@ -14,19 +14,21 @@
         "//autoroll/go/config",
         "//autoroll/go/config_vars",
         "//cd/go/cd",
-        "//go/auth",
         "//go/chrome_branch",
-        "//go/gerrit",
+        "//go/exec",
         "//go/git",
+        "//go/gitauth",
         "//go/gitiles",
         "//go/httputils",
         "//go/skerr",
         "//go/sklog",
         "//kube/go/kube_conf_gen_lib",
+        "//task_driver/go/lib/git_steps",
         "//task_driver/go/td",
+        "@org_golang_google_api//oauth2/v2:oauth2",
+        "@org_golang_google_api//option",
         "@org_golang_google_protobuf//encoding/protojson",
         "@org_golang_google_protobuf//encoding/prototext",
-        "@org_golang_x_oauth2//google",
         "@org_golang_x_sync//errgroup",
     ],
 )
diff --git a/autoroll/go/autoroll-config-converter/main.go b/autoroll/go/autoroll-config-converter/main.go
index 4f0c91a..7826f4e 100644
--- a/autoroll/go/autoroll-config-converter/main.go
+++ b/autoroll/go/autoroll-config-converter/main.go
@@ -8,7 +8,9 @@
 	"encoding/json"
 	"flag"
 	"fmt"
-	"path"
+	"io/fs"
+	"io/ioutil"
+	"os"
 	"path/filepath"
 	"sort"
 	"strings"
@@ -18,18 +20,20 @@
 	"go.skia.org/infra/autoroll/go/config"
 	"go.skia.org/infra/autoroll/go/config_vars"
 	"go.skia.org/infra/cd/go/cd"
-	"go.skia.org/infra/go/auth"
 	"go.skia.org/infra/go/chrome_branch"
-	"go.skia.org/infra/go/gerrit"
+	"go.skia.org/infra/go/exec"
 	"go.skia.org/infra/go/git"
+	"go.skia.org/infra/go/gitauth"
 	"go.skia.org/infra/go/gitiles"
 	"go.skia.org/infra/go/httputils"
 	"go.skia.org/infra/go/skerr"
 	"go.skia.org/infra/go/sklog"
 	"go.skia.org/infra/kube/go/kube_conf_gen_lib"
+	"go.skia.org/infra/task_driver/go/lib/git_steps"
 	"go.skia.org/infra/task_driver/go/td"
-	"golang.org/x/oauth2/google"
 	"golang.org/x/sync/errgroup"
+	"google.golang.org/api/oauth2/v2"
+	"google.golang.org/api/option"
 	"google.golang.org/protobuf/encoding/protojson"
 	"google.golang.org/protobuf/encoding/prototext"
 )
@@ -63,12 +67,16 @@
 
 func main() {
 	// Flags.
+	src := flag.String("src", "", "Source directory.")
+	dst := flag.String("dst", "", "Destination directory. Outputs will mimic the structure of the source.")
 	privacySandboxAndroidRepoURL := flag.String("privacy_sandbox_android_repo_url", "", "Repo URL for privacy sandbox on Android.")
 	privacySandboxAndroidVersionsPath := flag.String("privacy_sandbox_android_versions_path", "", "Path to the file containing the versions of privacy sandbox on Android.")
-	srcRepo := flag.String("source-repo", "https://skia.googlesource.com/skia-autoroll-internal-config.git", "URL of the repo which triggered this run.")
+	createCL := flag.Bool("create-cl", false, "If true, creates a CL if any changes were made.")
+	srcRepo := flag.String("source-repo", "", "URL of the repo which triggered this run.")
 	srcCommit := flag.String("source-commit", "", "Commit hash which triggered this run.")
 	louhiExecutionID := flag.String("louhi-execution-id", "", "Execution ID of the Louhi flow.")
 	louhiPubsubProject := flag.String("louhi-pubsub-project", "", "GCP project used for sending Louhi pub/sub notifications.")
+	local := flag.Bool("local", false, "True if running locally.")
 
 	flag.Parse()
 
@@ -82,18 +90,35 @@
 	ctx := td.StartRun(&fakeProjectId, &fakeTaskId, &fakeTaskName, &output, &tdLocal)
 	defer td.EndRun(ctx)
 
+	if *src == "" {
+		td.Fatalf(ctx, "--src is required.")
+	}
+	if *dst == "" {
+		td.Fatalf(ctx, "--dst is required.")
+	}
 	if backendTemplate == "" {
 		td.Fatalf(ctx, "internal error; embedded template is empty.")
 	}
-	if *srcCommit == "" {
-		td.Fatalf(ctx, "--source-commit is required.")
-	}
 
 	// Set up auth, load config variables.
-	ts, err := google.DefaultTokenSource(ctx, auth.ScopeUserinfoEmail, gerrit.AuthScope)
+	ts, err := git_steps.Init(ctx, true)
 	if err != nil {
 		td.Fatal(ctx, err)
 	}
+	if !*local {
+		srv, err := oauth2.NewService(ctx, option.WithTokenSource(ts))
+		if err != nil {
+			td.Fatal(ctx, err)
+		}
+		info, err := srv.Userinfo.V2.Me.Get().Do()
+		if err != nil {
+			td.Fatal(ctx, err)
+		}
+		sklog.Infof("Authenticated as %s", info.Email)
+		if _, err := gitauth.New(ts, "/tmp/.gitcookies", true, info.Email); err != nil {
+			td.Fatal(ctx, err)
+		}
+	}
 	client := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client()
 	reg, err := config_vars.NewRegistry(ctx, chrome_branch.NewClient(client))
 	if err != nil {
@@ -155,87 +180,62 @@
 	}
 	sklog.Infof("Using variables: %s", string(b))
 
-	// Walk through all files from the k8s-config repo. Read autoroll-related
-	// config files.
-	dst := gitiles.NewRepo("https://skia.googlesource.com/k8s-config.git", client)
-	dstBaseCommit, err := dst.ResolveRef(ctx, git.DefaultRef)
-	if err != nil {
-		td.Fatal(ctx, err)
-	}
-	dstFiles, err := dst.ListFilesRecursiveAtRef(ctx, ".", dstBaseCommit)
-	if err != nil {
-		td.Fatal(ctx, err)
-	}
-	dstExistingContents := map[string][]byte{}
-	for _, dstFile := range dstFiles {
-		if strings.HasSuffix(dstFile, "-autoroll-ns.yaml") || (strings.HasPrefix(dstFile, "autoroll-be-") && strings.HasSuffix(dstFile, ".yaml")) {
-			contents, err := dst.ReadFileAtRef(ctx, dstFile, dstBaseCommit)
-			if err != nil {
-				td.Fatal(ctx, err)
-			}
-			dstExistingContents[dstFile] = contents
-		}
-	}
-
-	// Walk through the autoroller config repo. Create roller configs from
+	// Walk through the autoroller config directory. Create roller configs from
 	// templates and convert roller configs to k8s configs.
-	src := gitiles.NewRepo(*srcRepo, client)
-	srcFiles, err := src.ListFilesRecursiveAtRef(ctx, ".", *srcCommit)
-	if err != nil {
-		td.Fatal(ctx, err)
-	}
-	generatedContents := map[string][]byte{}
-	for _, srcFile := range srcFiles {
-		if strings.HasSuffix(srcFile, ".cfg") {
-			sklog.Infof("Converting %s", srcFile)
-			cfgBytes, err := src.ReadFileAtRef(ctx, srcFile, *srcCommit)
+	fsys := os.DirFS(*src)
+	if err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if d.IsDir() {
+			return nil
+		}
+		if strings.HasSuffix(d.Name(), ".cfg") {
+			srcPath := filepath.Join(*src, path)
+			sklog.Infof("Converting %s", srcPath)
+			cfgBytes, err := ioutil.ReadFile(srcPath)
 			if err != nil {
-				td.Fatalf(ctx, "failed to read roller config %s: %s", srcFile, err)
+				return skerr.Wrapf(err, "failed to read roller config %s", srcPath)
 			}
-			if err := convertConfig(ctx, cfgBytes, srcFile, generatedContents); err != nil {
-				td.Fatalf(ctx, "failed to convert config %s: %s", srcFile, err)
+
+			if err := convertConfig(ctx, cfgBytes, path, *dst); err != nil {
+				return skerr.Wrapf(err, "failed to convert config %s", path)
 			}
-		} else if strings.HasSuffix(srcFile, ".tmpl") {
-			sklog.Infof("Processing %s", srcFile)
-			tmplBytes, err := src.ReadFileAtRef(ctx, srcFile, *srcCommit)
+		} else if strings.HasSuffix(d.Name(), ".tmpl") {
+			tmplPath := filepath.Join(*src, path)
+			sklog.Infof("Processing %s", tmplPath)
+			tmplContents, err := ioutil.ReadFile(tmplPath)
 			if err != nil {
-				td.Fatalf(ctx, "failed to read template file %s: %s", srcFile, err)
+				return skerr.Wrapf(err, "failed to read template file %s", tmplPath)
 			}
-			generatedConfigs, err := processTemplate(ctx, srcFile, string(tmplBytes), vars)
+			generatedConfigs, err := processTemplate(ctx, path, string(tmplContents), vars)
 			if err != nil {
-				td.Fatalf(ctx, "failed to process template file %s: %s", srcFile, err)
+				return skerr.Wrapf(err, "failed to process template file %s", path)
 			}
 			for path, cfgBytes := range generatedConfigs {
-				if err := convertConfig(ctx, cfgBytes, path, generatedContents); err != nil {
-					td.Fatalf(ctx, "failed to convert config %s: %s", srcFile, err)
+				if err := convertConfig(ctx, cfgBytes, path, *dst); err != nil {
+					return skerr.Wrapf(err, "failed to convert config %s", path)
 				}
 			}
 		}
+		return nil
+	}); err != nil {
+		td.Fatalf(ctx, "Failed to read configs: %s", err)
 	}
 
-	// Find the actual changes between the existing and the generated configs.
-	changes := make(map[string]string, len(generatedContents))
-	// First, "delete" all of the old contents, to ensure that we remove any
-	// no-longer-generated rollers.
-	for path := range dstExistingContents {
-		changes[path] = ""
+	// "git add" the directory.
+	gitExec, err := git.Executable(ctx)
+	if err != nil {
+		td.Fatal(ctx, err)
 	}
-	// Next, overwrite the old contents with the generated contents.
-	for path, newContents := range generatedContents {
-		changes[path] = string(newContents)
-	}
-	// Finally, remove any files which didn't actually change.
-	for path, newContents := range generatedContents {
-		oldContents, ok := dstExistingContents[path]
-		if ok && bytes.Equal(oldContents, newContents) {
-			delete(changes, path)
-		}
+	if _, err := exec.RunCwd(ctx, *dst, gitExec, "add", "-A"); err != nil {
+		td.Fatal(ctx, err)
 	}
 
 	// Upload a CL.
-	if len(changes) > 0 {
+	if *createCL {
 		commitSubject := "Update autoroll k8s configs"
-		if err := cd.UploadCL(ctx, changes, "https://skia.googlesource.com/k8s-config.git", dstBaseCommit, commitSubject, *srcRepo, *srcCommit, *louhiPubsubProject, *louhiExecutionID); err != nil {
+		if err := cd.MaybeUploadCL(ctx, *dst, commitSubject, *srcRepo, *srcCommit, *louhiPubsubProject, *louhiExecutionID); err != nil {
 			td.Fatalf(ctx, "Failed to create CL: %s", err)
 		}
 	}
@@ -305,11 +305,11 @@
 	PrivacySandboxVersions []*PrivacySandboxVersion
 }
 
-func convertConfig(ctx context.Context, cfgBytes []byte, relPath string, generatedContents map[string][]byte) error {
+func convertConfig(ctx context.Context, cfgBytes []byte, relPath, dstDir string) error {
 	// Decode the config file.
 	var cfg config.Config
 	if err := prototext.Unmarshal(cfgBytes, &cfg); err != nil {
-		return skerr.Wrapf(err, "failed to parse roller config: %s", string(cfgBytes))
+		return skerr.Wrapf(err, "failed to parse roller config")
 	}
 	// Google3 uses a different type of backend.
 	if cfg.ParentDisplayName == google3ParentName {
@@ -358,24 +358,22 @@
 	cfgMap["configBase64"] = cfgFileBase64
 
 	// Run kube-conf-gen to generate the backend config file.
-	relDir, baseName := path.Split(relPath)
-	dstCfgPath := path.Join(relDir, fmt.Sprintf("autoroll-be-%s.yaml", strings.Split(baseName, ".")[0]))
-	rollerCfg, err := kube_conf_gen_lib.GenerateOutputFromTemplateString(backendTemplate, false, cfgMap)
-	if err != nil {
+	relDir, baseName := filepath.Split(relPath)
+	dstPath := filepath.Join(dstDir, relDir, fmt.Sprintf("autoroll-be-%s.yaml", strings.Split(baseName, ".")[0]))
+	if err := kube_conf_gen_lib.GenerateOutputFromTemplateString(backendTemplate, false, cfgMap, dstPath); err != nil {
 		return skerr.Wrapf(err, "failed to write output")
 	}
-	generatedContents[dstCfgPath] = rollerCfg
+	sklog.Infof("Wrote %s", dstPath)
 
 	// Run kube-conf-gen to generate the namespace config file. Note that we'll
 	// overwrite this file for every roller in the namespace, but that shouldn't
 	// be a problem, since the generated files will be the same.
 	namespace := strings.Split(cfg.ServiceAccount, "@")[0]
-	dstNsPath := path.Join(relDir, fmt.Sprintf("%s-ns.yaml", namespace))
-	nsCfg, err := kube_conf_gen_lib.GenerateOutputFromTemplateString(namespaceTemplate, false, cfgMap)
-	if err != nil {
+	dstNsPath := filepath.Join(dstDir, relDir, fmt.Sprintf("%s-ns.yaml", namespace))
+	if err := kube_conf_gen_lib.GenerateOutputFromTemplateString(namespaceTemplate, false, cfgMap, dstNsPath); err != nil {
 		return skerr.Wrapf(err, "failed to write output")
 	}
-	generatedContents[dstNsPath] = nsCfg
+	sklog.Infof("Wrote %s", dstNsPath)
 
 	return nil
 }
diff --git a/cd/go/build-images/BUILD.bazel b/cd/go/build-images/BUILD.bazel
index c71b8f8..a26eefd 100644
--- a/cd/go/build-images/BUILD.bazel
+++ b/cd/go/build-images/BUILD.bazel
@@ -12,19 +12,14 @@
     visibility = ["//visibility:private"],
     deps = [
         "//cd/go/cd",
-        "//go/auth",
         "//go/exec",
-        "//go/gerrit",
         "//go/git",
         "//go/gitauth",
-        "//go/gitiles",
-        "//go/httputils",
         "//go/skerr",
         "//go/util",
         "//task_driver/go/lib/git_steps",
         "//task_driver/go/td",
         "@com_github_urfave_cli_v2//:cli",
-        "@org_golang_x_oauth2//google",
     ],
 )
 
diff --git a/cd/go/build-images/update-refs.go b/cd/go/build-images/update-refs.go
index 462c73d..3fe1703 100644
--- a/cd/go/build-images/update-refs.go
+++ b/cd/go/build-images/update-refs.go
@@ -3,33 +3,34 @@
 import (
 	"context"
 	"fmt"
+	"io/fs"
+	"io/ioutil"
 	"path"
+	"path/filepath"
 	"regexp"
 	"strings"
 
 	"go.skia.org/infra/cd/go/cd"
-	"go.skia.org/infra/go/auth"
 	"go.skia.org/infra/go/exec"
-	"go.skia.org/infra/go/gerrit"
 	"go.skia.org/infra/go/git"
-	"go.skia.org/infra/go/gitiles"
-	"go.skia.org/infra/go/httputils"
+	"go.skia.org/infra/go/gitauth"
 	"go.skia.org/infra/go/skerr"
+	"go.skia.org/infra/task_driver/go/lib/git_steps"
 	"go.skia.org/infra/task_driver/go/td"
-	"golang.org/x/oauth2/google"
 )
 
-func updateRefs(ctx context.Context, repoURL, workspace, username, email, louhiPubsubProject, louhiExecutionID, srcRepo, srcCommit string) error {
+func updateRefs(ctx context.Context, repo, workspace, username, email, louhiPubsubProject, executionID, srcRepo, srcCommit string) error {
 	ctx = td.StartStep(ctx, td.Props("Update References"))
 	defer td.EndStep(ctx)
 
-	// Create the git repo.
-	ts, err := google.DefaultTokenSource(ctx, auth.ScopeUserinfoEmail, gerrit.AuthScope)
+	// Initialize git authentication.
+	ts, err := git_steps.Init(ctx, true)
 	if err != nil {
 		return td.FailStep(ctx, err)
 	}
-	client := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client()
-	repo := gitiles.NewRepo(repoURL, client)
+	if _, err := gitauth.New(ts, "/tmp/.gitcookies", true, email); err != nil {
+		return td.FailStep(ctx, err)
+	}
 
 	imageInfo, err := readBuildImagesJSON(ctx, workspace)
 	if err != nil {
@@ -53,58 +54,75 @@
 		image.Sha256 = strings.TrimSuffix(strings.TrimPrefix(split[1], "sha256:"), "'")
 	}
 
-	// Obtain the current contents of all files in the repo.
-	baseCommit, err := repo.ResolveRef(ctx, git.DefaultRef)
+	// Create a shallow clone of the repo.
+	checkoutDir, err := shallowClone(ctx, repo, git.DefaultRef)
 	if err != nil {
 		return td.FailStep(ctx, err)
 	}
-	oldFiles, err := repo.ListFilesRecursiveAtRef(ctx, ".", baseCommit)
-	if err != nil {
-		return td.FailStep(ctx, err)
-	}
-	oldContents := map[string][]byte{}
-	for _, f := range oldFiles {
-		contents, err := repo.ReadFileAtRef(ctx, f, baseCommit)
-		if err != nil {
-			return td.FailStep(ctx, err)
-		}
-		oldContents[f] = contents
-	}
 
-	// Create regexes for each of the images.
-	imageRegexes := make([]*regexp.Regexp, 0, len(imageInfo.Images))
-	imageReplace := make([]string, 0, len(imageInfo.Images))
-	for _, image := range imageInfo.Images {
-		imageRegexes = append(imageRegexes, regexp.MustCompile(fmt.Sprintf(`%s@sha256:[a-f0-9]+`, image.Image)))
-		imageReplace = append(imageReplace, fmt.Sprintf("%s@sha256:%s", image.Image, image.Sha256))
+	// Create a branch.
+	gitExec, err := git.Executable(ctx)
+	if err != nil {
+		return td.FailStep(ctx, err)
+	}
+	if _, err := exec.RunCwd(ctx, checkoutDir, gitExec, "checkout", "-b", "update", "-t", git.DefaultRemoteBranch); err != nil {
+		return td.FailStep(ctx, err)
 	}
 
 	// Find-and-replace each of the image references.
-	changes := map[string]string{}
-	for f, oldFileContents := range oldContents {
-		// Replace all instances of the old image specification with the new.
-		contentsStr := string(oldFileContents)
-		for idx, re := range imageRegexes {
-			contentsStr = re.ReplaceAllString(contentsStr, imageReplace[idx])
-		}
+	if err := td.Do(ctx, td.Props("Update Image References"), func(ctx context.Context) error {
+		imageRegexes := make([]*regexp.Regexp, 0, len(imageInfo.Images))
+		imageReplace := make([]string, 0, len(imageInfo.Images))
+		for _, image := range imageInfo.Images {
+			// Update instances of "image/path@sha256:digest"
+			imageRegexes = append(imageRegexes, regexp.MustCompile(fmt.Sprintf(`%s@sha256:[a-f0-9]+`, image.Image)))
+			imageReplace = append(imageReplace, fmt.Sprintf("%s@sha256:%s", image.Image, image.Sha256))
 
-		// Write out the updated file.
-		newFileContents := contentsStr
-		if string(oldFileContents) != newFileContents {
-			changes[f] = newFileContents
+			// Replace Bazel container_pull specifications.
+			bazelRegex, bazelReplace := bazelRegexAndReplaceForImage(image)
+			imageRegexes = append(imageRegexes, bazelRegex)
+			imageReplace = append(imageReplace, bazelReplace)
 		}
+		return filepath.WalkDir(checkoutDir, func(path string, d fs.DirEntry, err error) error {
+			if err != nil {
+				return err
+			} else if d.IsDir() {
+				if d.Name() == ".git" {
+					return fs.SkipDir
+				} else {
+					return nil
+				}
+			}
+			// Read the file.
+			contents, err := ioutil.ReadFile(path)
+			if err != nil {
+				return err
+			}
+			contentsStr := string(contents)
+
+			// Replace all instances of the old image specification with the new.
+			for idx, re := range imageRegexes {
+				contentsStr = re.ReplaceAllString(contentsStr, imageReplace[idx])
+			}
+
+			// Write out the updated file.
+			contents = []byte(contentsStr)
+			if err := ioutil.WriteFile(path, contents, d.Type().Perm()); err != nil {
+				return err
+			}
+			return nil
+		})
+	}); err != nil {
+		return td.FailStep(ctx, err)
 	}
 
 	// Upload a CL.
-	if len(changes) > 0 {
-		imageList := make([]string, 0, len(imageInfo.Images))
-		for _, image := range imageInfo.Images {
-			imageList = append(imageList, path.Base(image.Image))
-		}
-		commitSubject := fmt.Sprintf("Update %s", strings.Join(imageList, ", "))
-		return cd.UploadCL(ctx, changes, repoURL, baseCommit, commitSubject, srcRepo, srcCommit, louhiPubsubProject, louhiExecutionID)
+	imageList := make([]string, 0, len(imageInfo.Images))
+	for _, image := range imageInfo.Images {
+		imageList = append(imageList, path.Base(image.Image))
 	}
-	return nil
+	commitSubject := fmt.Sprintf("Update %s", strings.Join(imageList, ", "))
+	return cd.MaybeUploadCL(ctx, checkoutDir, commitSubject, srcRepo, srcCommit, louhiPubsubProject, executionID)
 }
 
 func bazelRegexAndReplaceForImage(image *SingleImageInfo) (*regexp.Regexp, string) {
diff --git a/cd/go/cd/BUILD.bazel b/cd/go/cd/BUILD.bazel
index aab1a0c..780d35a 100644
--- a/cd/go/cd/BUILD.bazel
+++ b/cd/go/cd/BUILD.bazel
@@ -1,3 +1,4 @@
+load("//bazel/go:go_test.bzl", "go_test")
 load("@io_bazel_rules_go//go:def.bzl", "go_library")
 
 go_library(
@@ -7,7 +8,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//go/auth",
-        "//go/gerrit",
+        "//go/exec",
         "//go/gerrit/rubberstamper",
         "//go/git",
         "//go/gitiles",
@@ -19,3 +20,10 @@
         "@org_golang_x_oauth2//google",
     ],
 )
+
+go_test(
+    name = "cd_test",
+    srcs = ["cd_test.go"],
+    embed = [":cd"],
+    deps = ["@com_github_stretchr_testify//require"],
+)
diff --git a/cd/go/cd/cd.go b/cd/go/cd/cd.go
index 5e2e36d..0115b2e 100644
--- a/cd/go/cd/cd.go
+++ b/cd/go/cd/cd.go
@@ -3,9 +3,10 @@
 import (
 	"context"
 	"fmt"
+	"regexp"
 
 	"go.skia.org/infra/go/auth"
-	"go.skia.org/infra/go/gerrit"
+	"go.skia.org/infra/go/exec"
 	"go.skia.org/infra/go/gerrit/rubberstamper"
 	"go.skia.org/infra/go/git"
 	"go.skia.org/infra/go/gitiles"
@@ -17,73 +18,79 @@
 	"golang.org/x/oauth2/google"
 )
 
-// UploadCL uploads a CL with the given changes. It builds the commit message
-// starting with the given commitSubject. If srcRepo and srcCommit are provided,
-// a link back to the source commit is added to the commit message.  If
-// louhiPubsubProject and louhiExecutionID are provided, a pub/sub message is
-// sent after the CL is uploaded.
-func UploadCL(ctx context.Context, changes map[string]string, dstRepo, baseCommit, commitSubject, srcRepo, srcCommit, louhiPubsubProject, louhiExecutionID string) error {
-	ctx = td.StartStep(ctx, td.Props("UploadCL"))
+var uploadedCLRegex = regexp.MustCompile(`https://.*review\.googlesource\.com.*\d+`)
+
+// MaybeUploadCL uploads a CL if there are any diffs in checkoutDir. It builds
+// the commit message starting with the given commitSubject. If srcRepo and
+// srcCommit are provided, a link back to the source commit is added to the
+// commit message.  If louhiPubsubProject and louhiExecutionID are provided,
+// a pub/sub message is sent after the CL is uploaded.
+func MaybeUploadCL(ctx context.Context, checkoutDir, commitSubject, srcRepo, srcCommit, louhiPubsubProject, louhiExecutionID string) error {
+	ctx = td.StartStep(ctx, td.Props("MaybeUploadCL"))
 	defer td.EndStep(ctx)
 
-	// Build the commit message.
-	commitMsg := commitSubject
-	if srcCommit != "" {
-		shortCommit := srcCommit
-		if len(shortCommit) > 12 {
-			shortCommit = shortCommit[:12]
-		}
-		commitMsg += " for " + shortCommit
+	gitExec, err := git.Executable(ctx)
+	if err != nil {
+		return skerr.Wrap(err)
 	}
-	commitMsg += "\n\n"
-	if srcRepo != "" && srcCommit != "" {
-		ts, err := google.DefaultTokenSource(ctx, auth.ScopeUserinfoEmail)
-		if err != nil {
-			return skerr.Wrap(err)
+
+	// Did we change anything?
+	if _, err := exec.RunCwd(ctx, checkoutDir, gitExec, "diff", "HEAD", "--exit-code"); err != nil {
+		// If so, create a CL.
+
+		// Build the commit message.
+		commitMsg := commitSubject
+		if srcCommit != "" {
+			shortCommit := srcCommit
+			if len(shortCommit) > 12 {
+				shortCommit = shortCommit[:12]
+			}
+			commitMsg += " for " + shortCommit
 		}
-		client := httputils.DefaultClientConfig().WithTokenSource(ts).Client()
-		gitilesRepo := gitiles.NewRepo(srcRepo, client)
-		commitDetails, err := gitilesRepo.Details(ctx, srcCommit)
-		if err != nil {
-			return skerr.Wrap(err)
-		}
-		commitMsg += fmt.Sprintf("%s/+/%s\n\n", srcRepo, srcCommit)
-		commitMsg += commitDetails.Subject
 		commitMsg += "\n\n"
-	}
+		if srcRepo != "" && srcCommit != "" {
+			ts, err := google.DefaultTokenSource(ctx, auth.ScopeUserinfoEmail)
+			if err != nil {
+				return skerr.Wrap(err)
+			}
+			client := httputils.DefaultClientConfig().WithTokenSource(ts).Client()
+			gitilesRepo := gitiles.NewRepo(srcRepo, client)
+			commitDetails, err := gitilesRepo.Details(ctx, srcCommit)
+			if err != nil {
+				return skerr.Wrap(err)
+			}
+			commitMsg += fmt.Sprintf("%s/+/%s\n\n", srcRepo, srcCommit)
+			commitMsg += commitDetails.Subject
+			commitMsg += "\n\n"
+		}
+		commitMsg += rubberstamper.RandomChangeID()
 
-	// Create the CL.
-	gerritURL, gerritProject, err := gerrit.ParseGerritURLAndProject(dstRepo)
-	if err != nil {
-		return td.FailStep(ctx, err)
-	}
-	ts, err := google.DefaultTokenSource(ctx, gerrit.AuthScope)
-	if err != nil {
-		return td.FailStep(ctx, err)
-	}
-	client := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client()
-	g, err := gerrit.NewGerrit(gerritURL, client)
-	if err != nil {
-		return td.FailStep(ctx, err)
-	}
-	reviewers := []string{rubberstamper.RubberStamperUser}
-	ci, err := gerrit.CreateCLWithChanges(ctx, g, gerritProject, git.MainBranch, commitMsg, baseCommit, changes, reviewers)
-	if err != nil {
-		return td.FailStep(ctx, err)
-	}
-
-	// Send a pub/sub message.
-	if louhiPubsubProject != "" && louhiExecutionID != "" {
-		sender, err := pubsub.NewPubSubSender(ctx, louhiPubsubProject)
+		// Commit and push.
+		if _, err := exec.RunCwd(ctx, checkoutDir, gitExec, "commit", "-a", "-m", commitMsg); err != nil {
+			return skerr.Wrap(err)
+		}
+		output, err := exec.RunCwd(ctx, checkoutDir, gitExec, "push", git.DefaultRemote, rubberstamper.PushRequestAutoSubmit)
 		if err != nil {
 			return skerr.Wrap(err)
 		}
-		if err := sender.Send(ctx, &louhi.Notification{
-			EventAction:         louhi.EventAction_CREATED_ARTIFACT,
-			GeneratedCls:        []string{g.Url(ci.Issue)},
-			PipelineExecutionId: louhiExecutionID,
-		}); err != nil {
-			return skerr.Wrap(err)
+
+		// Send a pub/sub message.
+		if louhiPubsubProject != "" && louhiExecutionID != "" {
+			match := uploadedCLRegex.FindString(output)
+			if match == "" {
+				return skerr.Fmt("Failed to parse CL link from:\n%s", output)
+			}
+			sender, err := pubsub.NewPubSubSender(ctx, louhiPubsubProject)
+			if err != nil {
+				return skerr.Wrap(err)
+			}
+			if err := sender.Send(ctx, &louhi.Notification{
+				EventAction:         louhi.EventAction_CREATED_ARTIFACT,
+				GeneratedCls:        []string{match},
+				PipelineExecutionId: louhiExecutionID,
+			}); err != nil {
+				return skerr.Wrap(err)
+			}
 		}
 	}
 	return nil
diff --git a/cd/go/cd/cd_test.go b/cd/go/cd/cd_test.go
new file mode 100644
index 0000000..bdb6bc6
--- /dev/null
+++ b/cd/go/cd/cd_test.go
@@ -0,0 +1,36 @@
+package cd
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestUploadedCLRegex(t *testing.T) {
+	const logOutput = `
+ path/to/my-file.txt  |   2 +
+1 files changed, 376 insertions(+), 172 deletions(-)
+Waiting for editor...
+
+Enumerating objects: 68, done.
+Counting objects: 100% (68/68), done.
+Delta compression using up to 48 threads
+Compressing objects: 100% (53/53), done.
+Writing objects: 100% (54/54), 18.60 KiB | 3.72 MiB/s, done.
+Total 54 (delta 29), reused 0 (delta 0), pack-reused 0
+remote: Resolving deltas: 100% (29/29)
+remote: Waiting for private key checker: 5/30 objects left
+remote: Processing changes: refs: 1, new: 1, done
+remote: commit 321f209: warning: subject >50 characters; use shorter first paragraph
+remote: commit 321f209: warning: too many message lines longer than 72 characters; manually wrap lines
+remote:
+remote: SUCCESS
+remote:
+remote:   https://my-project-review.googlesource.com/c/my-repo/+/12345 Commit Message [WIP] [NEW]
+remote:
+To https://my-project.googlesource.com/my-repo.git
+* [new reference]       321f209aecdaf8f0a276d7d8413fd5b524f1c985 -> refs/for/refs/heads/main%wip,m=Initial_upload,cc=reviews@my-project.org,l=Commit-Queue+1,hashtag=blah
+`
+	const expect = "https://my-project-review.googlesource.com/c/my-repo/+/12345"
+	require.Equal(t, expect, uploadedCLRegex.FindString(logOutput))
+}
diff --git a/go/gerrit/gerrit.go b/go/gerrit/gerrit.go
index e9fcc67..f2e0aa5 100644
--- a/go/gerrit/gerrit.go
+++ b/go/gerrit/gerrit.go
@@ -1681,19 +1681,3 @@
 	}
 	return resp, err
 }
-
-// ParseGerritURLAndProject extracts the Gerrit URL and project name from the
-// given Gitiles repo URL.
-func ParseGerritURLAndProject(gitilesRepoURL string) (string, string, error) {
-	parsed, err := url.Parse(gitilesRepoURL)
-	if err != nil {
-		return "", "", skerr.Wrap(err)
-	}
-	project := strings.TrimPrefix(strings.TrimSuffix(parsed.Path, ".git"), "/")
-	splitHost := strings.Split(parsed.Host, ".")
-	splitHost[0] = splitHost[0] + "-review"
-	parsed.Host = strings.Join(splitHost, ".")
-	parsed.Path = ""
-	gerritURL := parsed.String()
-	return gerritURL, project, nil
-}
diff --git a/go/gerrit/gerrit_test.go b/go/gerrit/gerrit_test.go
index 6013b92..47e177f 100644
--- a/go/gerrit/gerrit_test.go
+++ b/go/gerrit/gerrit_test.go
@@ -646,14 +646,3 @@
 	ci.Branch = "refs/heads/chrome/m90"
 	require.Equal(t, "chromium%2Fsrc~chrome%2Fm90~abc", FullChangeId(ci))
 }
-
-func TestParseGerritURLAndProject(t *testing.T) {
-	test := func(repoURL, expectGerritURL, expectProject string) {
-		actualGerritURL, actualProject, err := ParseGerritURLAndProject(repoURL)
-		require.NoError(t, err)
-		require.Equal(t, expectGerritURL, actualGerritURL)
-		require.Equal(t, expectProject, actualProject)
-	}
-	test("https://skia.googlesource.com/k8s-config.git", "https://skia-review.googlesource.com", "k8s-config")
-	test("https://chromium.googlesource.com/chromium/src", "https://chromium-review.googlesource.com", "chromium/src")
-}
diff --git a/go/gitiles/gitiles.go b/go/gitiles/gitiles.go
index 5bf464a..80f1b39 100644
--- a/go/gitiles/gitiles.go
+++ b/go/gitiles/gitiles.go
@@ -12,7 +12,6 @@
 	"net/http"
 	"net/url"
 	"os"
-	"path"
 	"sort"
 	"strconv"
 	"strings"
@@ -187,9 +186,6 @@
 // and FileInfo.
 func (r *Repo) ReadObject(ctx context.Context, path, ref string) (os.FileInfo, []byte, error) {
 	path = strings.TrimSuffix(path, "/")
-	if path == "." {
-		path = ""
-	}
 	resp, err := r.get(ctx, fmt.Sprintf(DownloadURL, r.url, ref, path))
 	if err != nil {
 		return nil, nil, skerr.Wrap(err)
@@ -292,13 +288,12 @@
 			return err
 		}
 		for _, fi := range infos {
-			fullPath := path.Join(dir, fi.Name())
 			if fi.IsDir() {
-				if err := helper(fullPath); err != nil {
+				if err := helper(dir + "/" + fi.Name()); err != nil {
 					return err
 				}
 			} else {
-				rv = append(rv, strings.TrimPrefix(fullPath, topDir+"/"))
+				rv = append(rv, strings.TrimPrefix(dir+"/"+fi.Name(), topDir+"/"))
 			}
 		}
 		return nil
diff --git a/go/gitiles/testutils/testutils.go b/go/gitiles/testutils/testutils.go
index d45b032..c16622b 100644
--- a/go/gitiles/testutils/testutils.go
+++ b/go/gitiles/testutils/testutils.go
@@ -53,11 +53,7 @@
 	require.NoError(mr.t, err)
 	body := make([]byte, base64.StdEncoding.EncodedLen(len(contents)))
 	base64.StdEncoding.Encode(body, contents)
-	mockURLPath := srcPath
-	if srcPath == "." {
-		mockURLPath = ""
-	}
-	url := fmt.Sprintf(gitiles.DownloadURL, mr.url, ref, mockURLPath)
+	url := fmt.Sprintf(gitiles.DownloadURL, mr.url, ref, srcPath)
 	md := mockhttpclient.MockGetDialogue(body)
 	typ := git.ObjectTypeBlob
 	if st.IsDir() {
diff --git a/kube/go/kube-conf-gen/main.go b/kube/go/kube-conf-gen/main.go
index abd9ee6..f35835d 100644
--- a/kube/go/kube-conf-gen/main.go
+++ b/kube/go/kube-conf-gen/main.go
@@ -47,7 +47,7 @@
 	}
 
 	// Generate the output.
-	if err := kube_conf_gen_lib.WriteOutputFromTemplateFile(*templateFileName, *strict, config, *outputFileName); err != nil {
+	if err := kube_conf_gen_lib.GenerateOutputFromTemplateFile(*templateFileName, *strict, config, *outputFileName); err != nil {
 		sklog.Fatal(err)
 	}
 }
diff --git a/kube/go/kube_conf_gen_lib/kube_conf_gen_lib.go b/kube/go/kube_conf_gen_lib/kube_conf_gen_lib.go
index dc8ca30..4f1a946 100644
--- a/kube/go/kube_conf_gen_lib/kube_conf_gen_lib.go
+++ b/kube/go/kube_conf_gen_lib/kube_conf_gen_lib.go
@@ -97,59 +97,37 @@
 }
 
 // GenerateOutputFromTemplateString executes the template string with config as
-// its environment and returns the generated contents.
-func GenerateOutputFromTemplateString(tmplString string, strict bool, config map[string]interface{}) ([]byte, error) {
+// its environment and writes the result to outFile.
+func GenerateOutputFromTemplateString(tmplString string, strict bool, config map[string]interface{}, outFile string) error {
 	tmpl, err := template.New("kube-conf-gen-tmpl").Funcs(sprig.TxtFuncMap()).Parse(tmplString)
 	if err != nil {
-		return nil, skerr.Wrapf(err, "error parsing template %s", tmplString)
+		return skerr.Wrapf(err, "error parsing template %s", tmplString)
 	}
-	return generateOutputHelper(tmpl, strict, config)
+	return generateOutputHelper(tmpl, strict, config, outFile)
 }
 
 // GenerateOutputFromTemplateFile executes the template file with config as its
-// environment and returns the generated contents.
-func GenerateOutputFromTemplateFile(templateFileName string, strict bool, config map[string]interface{}) ([]byte, error) {
+// environment and writes the result to outFile.
+func GenerateOutputFromTemplateFile(templateFileName string, strict bool, config map[string]interface{}, outFile string) error {
 	tmpl, err := template.New(path.Base(templateFileName)).Funcs(sprig.TxtFuncMap()).ParseFiles(templateFileName)
 	if err != nil {
-		return nil, skerr.Wrapf(err, "error parsing template '%s'", templateFileName)
+		return skerr.Wrapf(err, "error parsing template '%s'", templateFileName)
 	}
-	return generateOutputHelper(tmpl, strict, config)
+	return generateOutputHelper(tmpl, strict, config, outFile)
 }
 
-// WriteOutputFromTemplateString executes the template string with config as its
-// environment and writes the result to outFile.
-func WriteOutputFromTemplateString(tmplString string, strict bool, config map[string]interface{}, outFile string) error {
-	contents, err := GenerateOutputFromTemplateString(tmplString, strict, config)
-	if err != nil {
-		return err
-	}
-	return writeOutputHelper(outFile, contents)
-}
-
-// WriteOutputFromTemplateFile executes the template file with config as its
-// environment and writes the result to outFile.
-func WriteOutputFromTemplateFile(templateFileName string, strict bool, config map[string]interface{}, outFile string) error {
-	contents, err := GenerateOutputFromTemplateFile(templateFileName, strict, config)
-	if err != nil {
-		return err
-	}
-	return writeOutputHelper(outFile, contents)
-}
-
-func generateOutputHelper(tmpl *template.Template, strict bool, config map[string]interface{}) ([]byte, error) {
+func generateOutputHelper(tmpl *template.Template, strict bool, config map[string]interface{}, outFile string) error {
 	if strict {
 		tmpl.Option("missingkey=error")
 	}
+
 	var buf bytes.Buffer
 	if err := tmpl.Execute(&buf, config); err != nil {
-		return nil, skerr.Wrap(err)
+		return skerr.Wrap(err)
 	}
-	return buf.Bytes(), nil
-}
 
-func writeOutputHelper(outFile string, contents []byte) error {
 	if outFile == "_" {
-		fmt.Println(string(contents))
+		fmt.Println(string(buf.Bytes()))
 		return nil
 	} else {
 		dir, _ := filepath.Split(outFile)
@@ -158,6 +136,6 @@
 				return skerr.Wrapf(err, "failed to create destination directory")
 			}
 		}
-		return skerr.Wrap(ioutil.WriteFile(outFile, contents, 0644))
+		return skerr.Wrap(ioutil.WriteFile(outFile, buf.Bytes(), 0644))
 	}
 }