feat(commands): add mirror command

Kieran Klukas created

Change summary

README.md                        |  2 
pkg/backend/repo.go              | 63 ++++++++++++++++++++++++++++++++++
pkg/ssh/cmd/mirror.go            | 29 +++++++++++----
pkg/store/database/repo.go       |  8 ++++
pkg/store/repo.go                |  1 
testscript/testdata/mirror.txtar | 23 +++++++++++-
6 files changed, 115 insertions(+), 11 deletions(-)

Detailed changes

README.md 🔗

@@ -501,8 +501,8 @@ Available Commands:
   hide         Hide or unhide a repository
   import       Import a new repository from remote
   info         Get information about a repository
-  is-mirror    Whether a repository is a mirror
   list         List repositories
+  mirror       Set or get a repository mirror property
   private      Set or get a repository private property
   project-name Set or get the project name for a repository
   rename       Rename an existing repository

pkg/backend/repo.go 🔗

@@ -553,6 +553,69 @@ func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error
 	}))
 }
 
+// SetMirror sets the mirror flag of a repository.
+// Note: enabling mirror mode requires the repository to have been imported
+// with a remote URL. Use ImportRepository to create a new mirror.
+func (d *Backend) SetMirror(ctx context.Context, name string, mirror bool) error {
+	name = utils.SanitizeRepo(name)
+	rp := filepath.Join(d.repoPath(name))
+
+	// Delete cache
+	d.cache.Delete(name)
+
+	return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+		// Update git config
+		r, err := git.Open(rp)
+		if err != nil {
+			return err
+		}
+
+		rcfg, err := r.Config()
+		if err != nil {
+			return err
+		}
+
+		// Update mirror option for all remotes
+		remoteSection := rcfg.Section("remote")
+		hasRemote := false
+		for _, sub := range remoteSection.Subsections {
+			// Check if this remote has a URL
+			for _, opt := range sub.Options {
+				if opt.Key == "url" && opt.Value != "" {
+					hasRemote = true
+					found := false
+					for i, opt := range sub.Options {
+						if opt.Key == "mirror" {
+							found = true
+							if mirror {
+								sub.Options[i].Value = "true"
+							} else {
+								sub.Options = append(sub.Options[:i], sub.Options[i+1:]...)
+							}
+							break
+						}
+					}
+					if !found && mirror {
+						sub.SetOption("mirror", "true")
+					}
+					break
+				}
+			}
+		}
+
+		if mirror && !hasRemote {
+			return errors.New("cannot enable mirror mode: repository has no remote URL configured")
+		}
+
+		if err := r.SetConfig(rcfg); err != nil {
+			d.logger.Error("failed to set repository config", "err", err, "path", rp)
+			return err
+		}
+
+		return d.store.SetRepoIsMirrorByName(ctx, tx, name, mirror)
+	}))
+}
+
 // SetDescription sets the description of a repository.
 //
 // It implements backend.Backend.

pkg/ssh/cmd/mirror.go 🔗

@@ -7,21 +7,34 @@ import (
 
 func mirrorCommand() *cobra.Command {
 	cmd := &cobra.Command{
-		Use:               "is-mirror REPOSITORY",
-		Short:             "Whether a repository is a mirror",
-		Args:              cobra.ExactArgs(1),
+		Use:               "mirror REPOSITORY [true|false]",
+		Short:             "Set or get a repository mirror property",
+		Args:              cobra.RangeArgs(1, 2),
 		PersistentPreRunE: checkIfReadable,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
 			be := backend.FromContext(ctx)
 			rn := args[0]
-			rr, err := be.Repository(ctx, rn)
-			if err != nil {
-				return err
+
+			switch len(args) {
+			case 1:
+				isMirror, err := be.IsMirror(ctx, rn)
+				if err != nil {
+					return err
+				}
+
+				cmd.Println(isMirror)
+			case 2:
+				if err := checkIfCollab(cmd, args); err != nil {
+					return err
+				}
+
+				isMirror := args[1] == "true"
+				if err := be.SetMirror(ctx, rn, isMirror); err != nil {
+					return err
+				}
 			}
 
-			isMirror := rr.IsMirror()
-			cmd.Println(isMirror)
 			return nil
 		},
 	}

pkg/store/database/repo.go 🔗

@@ -92,6 +92,14 @@ func (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx db.Handler, name
 	return isMirror, db.WrapError(err)
 }
 
+// SetRepoIsMirrorByName implements store.RepositoryStore.
+func (*repoStore) SetRepoIsMirrorByName(ctx context.Context, tx db.Handler, name string, isMirror bool) error {
+	name = utils.SanitizeRepo(name)
+	query := tx.Rebind("UPDATE repos SET mirror = ? WHERE name = ?;")
+	_, err := tx.ExecContext(ctx, query, isMirror, name)
+	return db.WrapError(err)
+}
+
 // GetRepoIsPrivateByName implements store.RepositoryStore.
 func (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx db.Handler, name string) (bool, error) {
 	var isPrivate bool

pkg/store/repo.go 🔗

@@ -25,4 +25,5 @@ type RepositoryStore interface {
 	GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error)
 	SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error
 	GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error)
+	SetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string, isMirror bool) error
 }

testscript/testdata/mirror.txtar 🔗

@@ -22,9 +22,28 @@ cmp stdout info1.txt
 soft repo list
 stdout charmbracelet/wizard-tutorial
 
-# is-mirror?
-soft repo is-mirror charmbracelet/wizard-tutorial
+# create a non-mirror repo
+soft repo create test-normal
+soft repo mirror test-normal
+stdout false
+# try to enable mirror on repo without remote - should fail
+! soft repo mirror test-normal true
+
+# disable mirror
+soft repo mirror charmbracelet/wizard-tutorial false
+soft repo mirror charmbracelet/wizard-tutorial
+stdout false
+# verify git config no longer has mirror
+readfile $DATA_PATH/repos/charmbracelet/wizard-tutorial.git/config
+! stdout 'mirror = true'
+
+# re-enable mirror
+soft repo mirror charmbracelet/wizard-tutorial true
+soft repo mirror charmbracelet/wizard-tutorial
 stdout true
+# verify git config has mirror again
+readfile $DATA_PATH/repos/charmbracelet/wizard-tutorial.git/config
+stdout 'mirror = true'
 
 # set project name
 soft repo project-name charmbracelet/wizard-tutorial wizard-tutorial