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
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(+) @@ -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"}}), new file mode 100644 @@ -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") + } +} new file mode 100644 @@ -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) + }) +} @@ -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 +} @@ -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 +}