krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

f6cb7d04f133ff5d911efe3f741e3b95330ffc1d

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T23:36:08Z

repo import: server-side mirror from a foreign URL

- repo import <owner/name> --from <url> [--private] [--token-stdin]:
  fetches heads, tags, and notes into a fresh bare repo (hooks wired
  via core.hooksPath), detects the remote default branch via ls-remote
  --symref and sets HEAD accordingly; clone_timeout applies
- credentials only via --token-stdin: the token reaches git through a
  GIT_ASKPASS helper reading the environment — never argv, never the
  URL, never the database; URLs with embedded credentials are refused
- scheme allowlist https/http/git (file:// and ssh:// refused); import
  lands only under the caller's own account; git data only, stated in
  the output; failed imports clean up both the row and the directory
- CLI passthrough with stdin wired for --token-stdin
- e2e: import over http and git:// from the instance's own transports,
  branch/tag/HEAD fidelity, hooks functional on the imported repo
  (protected-branch force-push refused), all refusal cases, failure
  cleanup, and token non-leakage into repo config; verified against a
  real GitHub repository manually
 cmd/forge/main.go           |  36 +++++++++++
 e2e/import_test.go          | 135 ++++++++++++++++++++++++++++++++++++++
 internal/control/import.go  | 154 ++++++++++++++++++++++++++++++++++++++++++++
 internal/gitutil/gitutil.go |  46 +++++++++++++
 internal/store/repos.go     |   5 ++
 5 files changed, 376 insertions(+)

diff --git a/cmd/forge/main.go b/cmd/forge/main.go
index 7849ece..ad6ed79 100644
--- a/cmd/forge/main.go
+++ b/cmd/forge/main.go
@@ -194,6 +194,7 @@ func repoCmd() *cobra.Command {
 		pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
 		pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
 		local("clone", "clone via ssh: forge repo clone <owner/name> [dir]", cmdRepoClone),
+		importCmd(),
 		group("access", "manage access grants",
 			pass("grant", "grant access: ... <user> read|write|admin", passOpts{server: []string{"repo", "access", "grant"}, needsRepo: true}),
 			pass("revoke", "revoke access: ... <user>", passOpts{server: []string{"repo", "access", "revoke"}, needsRepo: true}),
@@ -239,6 +240,41 @@ func mrCmd() *cobra.Command {
 	)
 }
 
+// importCmd passes repo import through with stdin wired for --token-stdin.
+func importCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:                "import",
+		Short:              "server-side mirror of a foreign repo: forge repo import <owner/name> --from <url> [--private] [--token-stdin]",
+		DisableFlagParsing: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			for _, a := range args {
+				if a == "--help" || a == "-h" {
+					return cmd.Help()
+				}
+			}
+			t, err := resolveTarget()
+			if err != nil {
+				return err
+			}
+			var stdin io.Reader = strings.NewReader("")
+			if usesTokenStdin(args) {
+				stdin = os.Stdin
+			}
+			os.Exit(runSSH(t, append([]string{"repo", "import"}, args...), stdin))
+			return nil
+		},
+	}
+}
+
+func usesTokenStdin(args []string) bool {
+	for _, a := range args {
+		if a == "--token-stdin" {
+			return true
+		}
+	}
+	return false
+}
+
 func webCmd() *cobra.Command {
 	return group("web", "browser session",
 		pass("login", "mint a one-time browser login URL over ssh", passOpts{server: []string{"web", "login"}}),
diff --git a/e2e/import_test.go b/e2e/import_test.go
new file mode 100644
index 0000000..9eca5c7
--- /dev/null
+++ b/e2e/import_test.go
@@ -0,0 +1,135 @@
+package e2e
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestRepoImport(t *testing.T) {
+	inst := startInstance(t)
+	aliceKey := inst.newKey(t, "alice")
+	inst.admin(t, "admin", "user", "create", "alice",
+		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
+
+	// Source repo with a non-"main" default branch, a tag, and two commits.
+	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/src"); code != 0 {
+		t.Fatalf("repo create: %s", errOut)
+	}
+	work := t.TempDir()
+	env := inst.gitEnv(aliceKey)
+	mustGit(t, work, env, "clone", inst.sshURL("alice/src"), "w")
+	dir := filepath.Join(work, "w")
+	os.WriteFile(filepath.Join(dir, "code.txt"), []byte("v1\n"), 0o644)
+	mustGit(t, dir, env, "checkout", "-q", "-b", "trunk")
+	mustGit(t, dir, env, "add", ".")
+	mustGit(t, dir, env, "commit", "-q", "-m", "first")
+	mustGit(t, dir, env, "tag", "v1.0")
+	mustGit(t, dir, env, "commit", "-q", "--allow-empty", "-m", "second")
+	mustGit(t, dir, env, "push", "-q", "origin", "trunk", "v1.0")
+
+	// Point the source repo's HEAD at trunk so the remote advertises it.
+	srcBare := filepath.Join(inst.root, "repos", "alice", "src.git")
+	mustGit(t, srcBare, env, "symbolic-ref", "HEAD", "refs/heads/trunk")
+
+	// Import over HTTP from our own instance (public smart HTTP).
+	httpURL := fmt.Sprintf("http://127.0.0.1:%d/alice/src.git", inst.httpPort)
+	out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "import", "alice/mirror", "--from", httpURL, "--private")
+	if code != 0 {
+		t.Fatalf("import: exit %d\n%s%s", code, out, errOut)
+	}
+	if !strings.Contains(out, "imported alice/mirror (private, default trunk)") {
+		t.Fatalf("import output: %s", out)
+	}
+	if !strings.Contains(out, "git data only") {
+		t.Fatalf("import note missing: %s", out)
+	}
+
+	// Everything came across: both commits, the tag, and the default branch.
+	logOut, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/mirror")
+	if code != 0 || !strings.Contains(logOut, "second") || !strings.Contains(logOut, "first") {
+		t.Fatalf("imported log: %d\n%s", code, logOut)
+	}
+	mirrorBare := filepath.Join(inst.root, "repos", "alice", "mirror.git")
+	tags := mustGit(t, mirrorBare, env, "tag", "--list")
+	if !strings.Contains(tags, "v1.0") {
+		t.Fatalf("tag not imported: %q", tags)
+	}
+	head := strings.TrimSpace(mustGit(t, mirrorBare, env, "symbolic-ref", "HEAD"))
+	if head != "refs/heads/trunk" {
+		t.Fatalf("imported HEAD = %s", head)
+	}
+	showOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/mirror")
+	if !strings.Contains(showOut, "private") || !strings.Contains(showOut, "default: trunk") {
+		t.Fatalf("repo show after import: %s", showOut)
+	}
+
+	// The imported repo has working hooks (core.hooksPath was set): a
+	// protected-branch force-push is refused.
+	if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "alice/mirror", "trunk"); code != 0 {
+		t.Fatalf("protect: %s", errOut)
+	}
+	mWork := t.TempDir()
+	mustGit(t, mWork, env, "clone", inst.sshURL("alice/mirror"), "m")
+	mDir := filepath.Join(mWork, "m")
+	mustGit(t, mDir, env, "commit", "-q", "--amend", "--allow-empty", "-m", "rewrite")
+	pushOut, pushCode := gitRun(t, mDir, env, "push", "--force", "origin", "trunk")
+	if pushCode == 0 || !strings.Contains(pushOut, "force-push refused") {
+		t.Fatalf("hooks not wired on imported repo:\n%s", pushOut)
+	}
+
+	// Import over git:// too.
+	if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "settings", "git-daemon", "alice/src", "on"); code != 0 {
+		t.Fatalf("git-daemon on: %s", errOut)
+	}
+	gitURL := fmt.Sprintf("git://127.0.0.1:%d/alice/src.git", inst.gitPort)
+	if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "import", "alice/mirror2", "--from", gitURL); code != 0 {
+		t.Fatalf("git:// import: %s", errOut)
+	}
+
+	// Refusals: bad scheme, credentials in URL, existing name, foreign owner.
+	cases := []struct {
+		args []string
+		want string
+	}{
+		{[]string{"repo", "import", "alice/x", "--from", "file:///etc"}, "https://, http://, and git://"},
+		{[]string{"repo", "import", "alice/x", "--from", "https://token@github.com/a/b"}, "--token-stdin"},
+		{[]string{"repo", "import", "alice/mirror", "--from", httpURL}, "already exists"},
+		{[]string{"repo", "import", "bob/x", "--from", httpURL}, "your own account"},
+	}
+	for _, tc := range cases {
+		_, errOut, code := inst.ssh(t, aliceKey, "", tc.args...)
+		if code == 0 || !strings.Contains(errOut, tc.want) {
+			t.Errorf("%v: exit %d, stderr %q (want %q)", tc.args, code, errOut, tc.want)
+		}
+	}
+
+	// A failed import leaves nothing behind.
+	_, _, code = inst.ssh(t, aliceKey, "", "repo", "import", "alice/gone", "--from",
+		fmt.Sprintf("http://127.0.0.1:%d/alice/nonexistent.git", inst.httpPort))
+	if code == 0 {
+		t.Fatal("import of nonexistent source succeeded")
+	}
+	if _, _, code = inst.ssh(t, aliceKey, "", "repo", "show", "alice/gone"); code != 3 {
+		t.Fatalf("failed import left a repo behind: exit %d, want 3", code)
+	}
+	if _, err := os.Stat(filepath.Join(inst.root, "repos", "alice", "gone.git")); !os.IsNotExist(err) {
+		t.Fatal("failed import left a directory behind")
+	}
+
+	// --token-stdin consumes a token from stdin without leaking it: the
+	// fetch works (token unused by our anonymous endpoint, but the askpass
+	// plumbing must not break it) and the token string appears nowhere in
+	// the repo config.
+	_, errOut, code = inst.ssh(t, aliceKey, "s3cr3t-token\n", "repo", "import", "alice/mirror3",
+		"--from", httpURL, "--token-stdin")
+	if code != 0 {
+		t.Fatalf("token-stdin import: %s", errOut)
+	}
+	cfgRaw, _ := os.ReadFile(filepath.Join(inst.root, "repos", "alice", "mirror3.git", "config"))
+	if strings.Contains(string(cfgRaw), "s3cr3t") {
+		t.Fatal("token leaked into repo config")
+	}
+}
diff --git a/internal/control/import.go b/internal/control/import.go
new file mode 100644
index 0000000..61a0edd
--- /dev/null
+++ b/internal/control/import.go
@@ -0,0 +1,154 @@
+package control
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/krazywarez/forge/internal/gitutil"
+	"github.com/krazywarez/forge/internal/policy"
+	"github.com/krazywarez/forge/internal/protocol"
+)
+
+func init() {
+	register(Command{Path: []string{"repo", "import"},
+		Summary:    "server-side mirror of a foreign repository: repo import <owner/name> --from <url> [--private] [--token-stdin]",
+		ReadsStdin: true, Run: runRepoImport})
+}
+
+// askpassScript answers git's credential prompts from the environment, so
+// the token never appears on a command line or in a URL. Username prompts
+// get a placeholder (GitHub and GitLab ignore it for token auth).
+const askpassScript = `#!/bin/sh
+case "$1" in
+  Username*) echo "x-access-token" ;;
+  *)         echo "${FORGE_IMPORT_TOKEN}" ;;
+esac
+`
+
+func runRepoImport(c *Ctx, args []string) int {
+	var path, from string
+	private := false
+	tokenStdin := false
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--from":
+			if i+1 >= len(args) {
+				return c.fail(protocol.ExitUsage, "--from requires a URL")
+			}
+			from = args[i+1]
+			i++
+		case "--private":
+			private = true
+		case "--token-stdin":
+			tokenStdin = true
+		default:
+			if path != "" {
+				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
+			}
+			path = args[i]
+		}
+	}
+	if path == "" || from == "" {
+		return c.fail(protocol.ExitUsage, "usage: repo import <owner/name> --from <url> [--private] [--token-stdin]")
+	}
+	owner, name, ok := strings.Cut(path, "/")
+	if !ok || owner != c.User.Username {
+		return c.fail(protocol.ExitDenied, "imports land under your own account: %s/<name>", c.User.Username)
+	}
+	if err := policy.ValidateName(name); err != nil {
+		return c.fail(protocol.ExitUsage, "%v", err)
+	}
+
+	// Scheme allowlist. file:// (and anything else local) would read the
+	// server's filesystem; ssh:// would use the server's own keys.
+	switch {
+	case strings.HasPrefix(from, "https://"), strings.HasPrefix(from, "http://"), strings.HasPrefix(from, "git://"):
+	default:
+		return c.fail(protocol.ExitUsage, "import supports https://, http://, and git:// URLs only")
+	}
+	if strings.ContainsAny(from, "@") {
+		// Credentials belong on stdin, not in the URL where they would
+		// land in process listings and logs.
+		return c.fail(protocol.ExitUsage, "do not embed credentials in the URL; use --token-stdin")
+	}
+
+	// The token is read from stdin and handed to git via GIT_ASKPASS and
+	// the environment — never argv, never the database, never a log line.
+	var env []string
+	if tokenStdin {
+		token, err := bufio.NewReader(io.LimitReader(c.Stdin, 4096)).ReadString('\n')
+		if err != nil && err != io.EOF {
+			return c.fail(protocol.ExitFailure, "reading token: %v", err)
+		}
+		token = strings.TrimSpace(token)
+		if token == "" {
+			return c.fail(protocol.ExitUsage, "--token-stdin given but stdin held no token")
+		}
+		askpass := filepath.Join(c.Cfg.Server.Root, "askpass.sh")
+		if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
+			return c.fail(protocol.ExitFailure, "%v", err)
+		}
+		env = []string{
+			"GIT_ASKPASS=" + askpass,
+			"FORGE_IMPORT_TOKEN=" + token,
+			"GIT_TERMINAL_PROMPT=0",
+		}
+	} else {
+		env = []string{"GIT_TERMINAL_PROMPT=0"}
+	}
+
+	visibility := "public"
+	if private {
+		visibility = "private"
+	}
+	id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	dir := RepoDir(c.Cfg.Server.Root, owner, name)
+	cleanup := func() {
+		c.Store.DeleteRepo(id)
+		os.RemoveAll(dir)
+	}
+	if err := gitutil.InitBare(dir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
+		cleanup()
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+
+	timeout := time.Duration(c.Cfg.Limits.CloneTimeoutSec) * time.Second
+	ctx, cancel := context.WithTimeout(context.Background(), timeout)
+	defer cancel()
+
+	fmt.Fprintf(c.Stderr, "importing %s into %s ...\n", from, path)
+	if err := gitutil.FetchMirror(ctx, dir, from, c.Stderr, env); err != nil {
+		cleanup()
+		return c.fail(protocol.ExitFailure, "import failed: %v", err)
+	}
+
+	branch, err := gitutil.RemoteDefaultBranch(ctx, from, env)
+	if err != nil {
+		branch = "main" // remote gone quiet after the fetch; keep the default
+	}
+	if _, rerr := gitutil.ResolveRef(dir, "refs/heads/"+branch); rerr == nil {
+		gitutil.SetHead(dir, branch)
+		c.Store.UpdateDefaultBranch(id, branch)
+	}
+
+	c.Store.RecordEvent(id, c.User.ID, "repo.imported", fmt.Sprintf(`{"from":%q}`, from))
+	type out struct {
+		Path          string `json:"path"`
+		Visibility    string `json:"visibility"`
+		DefaultBranch string `json:"default_branch"`
+	}
+	d := out{path, visibility, branch}
+	return c.emit(d, func(w io.Writer) {
+		fmt.Fprintf(w, "imported %s (%s, default %s)\nnote: git data only — issues and pull requests do not transfer\n",
+			d.Path, d.Visibility, d.DefaultBranch)
+	})
+}
diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go
index b773b37..0e868f7 100644
--- a/internal/gitutil/gitutil.go
+++ b/internal/gitutil/gitutil.go
@@ -3,6 +3,7 @@
 package gitutil
 
 import (
+	"context"
 	"fmt"
 	"io"
 	"os"
@@ -100,3 +101,48 @@ func ReadCommit(dir, sha string) ([]byte, error) {
 	}
 	return out, nil
 }
+
+// FetchMirror pulls all branches, tags, and notes from a foreign URL into
+// the bare repository at dir, forcing updates. Progress streams to errW so
+// an interactive caller can watch. extraEnv carries credentials via
+// GIT_ASKPASS; the URL itself must never contain them.
+func FetchMirror(ctx context.Context, dir, url string, errW io.Writer, extraEnv []string) error {
+	cmd := exec.CommandContext(ctx, "git", "-C", dir, "fetch", "--progress", "--no-write-fetch-head", url,
+		"+refs/heads/*:refs/heads/*",
+		"+refs/tags/*:refs/tags/*",
+		"+refs/notes/*:refs/notes/*")
+	cmd.Env = append(os.Environ(), extraEnv...)
+	cmd.Stderr = errW
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("fetch from %s: %w", url, err)
+	}
+	return nil
+}
+
+// RemoteDefaultBranch asks the remote which branch HEAD points at.
+func RemoteDefaultBranch(ctx context.Context, url string, extraEnv []string) (string, error) {
+	cmd := exec.CommandContext(ctx, "git", "ls-remote", "--symref", url, "HEAD")
+	cmd.Env = append(os.Environ(), extraEnv...)
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("ls-remote %s: %w", url, err)
+	}
+	// "ref: refs/heads/<branch>\tHEAD"
+	for _, line := range strings.Split(string(out), "\n") {
+		if rest, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
+			if branch, _, ok := strings.Cut(rest, "\t"); ok {
+				return branch, nil
+			}
+		}
+	}
+	return "", fmt.Errorf("remote %s did not advertise a default branch", url)
+}
+
+// SetHead points the bare repo's HEAD at a branch.
+func SetHead(dir, branch string) error {
+	cmd := exec.Command("git", "-C", dir, "symbolic-ref", "HEAD", "refs/heads/"+branch)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("symbolic-ref: %v\n%s", err, out)
+	}
+	return nil
+}
diff --git a/internal/store/repos.go b/internal/store/repos.go
index b30f42c..2d6e334 100644
--- a/internal/store/repos.go
+++ b/internal/store/repos.go
@@ -219,3 +219,8 @@ func (s *Store) ListPublicRepos() ([]Repo, error) {
 	}
 	return out, rows.Err()
 }
+
+func (s *Store) UpdateDefaultBranch(repoID int64, branch string) error {
+	_, err := s.DB.Exec("UPDATE repos SET default_branch = ? WHERE id = ?", branch, repoID)
+	return err
+}