~gitpocket/gitpocket

4

feat(import): import repositories over https and claim matching commit authors

commit
b0461c8f8942977ade0020d163974ec804692297 browse tree
parent
9108144c05c0d4a3db1a367645caaf4b5601b4c1
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-13 17:45 UTC

10 files changed, +593 −19

README.md modified

@@ -20,7 +20,8 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS
2020 - Markdown in pull requests, issues and comments with a preview tab and images you paste, drop or attach
2121 - Pull requests from a branch or a fork with inline review, range diffs between force pushes and merge, squash or rebase without a working tree
2222 - Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push
23-- Profiles with a pinned repository list, an activity graph and a README from your `~you/you` repository
23+- Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository
24+- Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account
2425 - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests
2526 - Site search over repositories and people with language facets
2627 - Light and dark themes, light by default, with an option to follow the device

forge/extra.go modified

@@ -529,18 +529,19 @@ func (s *Server) activityCounts(owner User) map[string]int {
529529 func (s *Server) countActivity(owner User) map[string]int {
530530 counts := map[string]int{}
531531 org := s.store.IsOrg(owner.ID)
532- email := strings.ToLower(s.store.VerifiedEmail(owner.ID))
533- if email == "" && !org {
534- return counts
532+ emails := []string{strings.ToLower(s.noreply(owner)), strings.ToLower(owner.Name + "@users.noreply." + s.host)}
533+ if e := s.store.VerifiedEmail(owner.ID); e != "" {
534+ emails = append(emails, strings.ToLower(e))
535535 }
536- want := int32(-1)
537- if !org {
536+ want := map[int32]bool{}
537+ lookup := func() {
538538 histories.Lock()
539- id, ok := histories.emails[email]
540- histories.Unlock()
541- if ok {
542- want = id
539+ for _, e := range emails {
540+ if id, ok := histories.emails[e]; ok {
541+ want[id] = true
542+ }
543543 }
544+ histories.Unlock()
544545 }
545546 seen := map[uint64]bool{}
546547 repos, _ := s.store.PublicRepos("", "")
@@ -549,15 +550,11 @@ func (s *Server) countActivity(owner User) map[string]int {
549550 continue
550551 }
551552 commits := s.history(repo)
552- if !org && want < 0 {
553- histories.Lock()
554- if id, ok := histories.emails[email]; ok {
555- want = id
556- }
557- histories.Unlock()
553+ if !org {
554+ lookup()
558555 }
559556 for _, c := range commits {
560- if (!org && c.email != want) || seen[c.hash] {
557+ if (!org && !want[c.email]) || seen[c.hash] {
561558 continue
562559 }
563560 seen[c.hash] = true

forge/flow_test.go modified

@@ -591,3 +591,16 @@ func TestRename(t *testing.T) {
591591 t.Error("old repository path still resolves after the name was taken")
592592 }
593593 }
594+
595+func TestNoreplyPerson(t *testing.T) {
596+ s, _ := testServer(t)
597+ alice, _ := s.store.CreateUser("alice", "correct horse")
598+ for _, e := range []string{s.noreply(alice), "alice@users.noreply." + s.host, strings.ToUpper(s.noreply(alice))} {
599+ if u, ok := s.personByEmail(e); !ok || u.ID != alice.ID {
600+ t.Errorf("%s not linked to ~alice", e)
601+ }
602+ }
603+ if _, ok := s.personByEmail("alice@users.noreply.example.org"); ok && s.host != "example.org" {
604+ t.Error("foreign noreply address linked")
605+ }
606+}

forge/import.go added

@@ -0,0 +1,475 @@
1+package forge
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "html/template"
11+ "io"
12+ "log"
13+ "net"
14+ "net/http"
15+ "net/url"
16+ "os"
17+ "os/exec"
18+ "path"
19+ "slices"
20+ "strconv"
21+ "strings"
22+ "sync"
23+ "time"
24+)
25+
26+type importState struct {
27+ Source string
28+ Done bool
29+}
30+
31+var imports = struct {
32+ sync.Mutex
33+ m map[int64]*importState
34+}{m: map[int64]*importState{}}
35+
36+var importSlots = make(chan struct{}, 2)
37+
38+var failedImports = struct {
39+ sync.Mutex
40+ m map[string]string
41+}{m: map[string]string{}}
42+
43+func failedImport(path string) string {
44+ failedImports.Lock()
45+ defer failedImports.Unlock()
46+ return failedImports.m[path]
47+}
48+
49+func importStatus(repo int64) (importState, bool) {
50+ imports.Lock()
51+ defer imports.Unlock()
52+ st, ok := imports.m[repo]
53+ if !ok {
54+ return importState{}, false
55+ }
56+ return *st, true
57+}
58+
59+func setImport(repo int64, f func(*importState)) {
60+ imports.Lock()
61+ defer imports.Unlock()
62+ if st, ok := imports.m[repo]; ok {
63+ f(st)
64+ }
65+}
66+
67+func publicAddr(ip net.IP) bool {
68+ if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() || ip.IsInterfaceLocalMulticast() {
69+ return false
70+ }
71+ _, cgnat, _ := net.ParseCIDR("100.64.0.0/10")
72+ return !cgnat.Contains(ip)
73+}
74+
75+func importSource(raw string) (*url.URL, string, error) {
76+ u, err := url.Parse(strings.TrimSpace(raw))
77+ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
78+ return nil, "", errors.New("Use an https clone URL such as https://github.com/owner/repo")
79+ }
80+ port := u.Port()
81+ if port == "" {
82+ port = "443"
83+ }
84+ ips, err := net.LookupIP(u.Hostname())
85+ if err != nil || len(ips) == 0 {
86+ return nil, "", errors.New("Could not resolve " + u.Hostname())
87+ }
88+ for _, ip := range ips {
89+ if !publicAddr(ip) {
90+ return nil, "", errors.New("That host is not reachable from this server")
91+ }
92+ }
93+ ip := ips[0].String()
94+ if strings.Contains(ip, ":") {
95+ ip = "[" + ip + "]"
96+ }
97+ return u, u.Hostname() + ":" + port + ":" + ip, nil
98+}
99+
100+func importName(u *url.URL) string {
101+ name := strings.ToLower(strings.TrimSuffix(path.Base(strings.TrimSuffix(u.Path, "/")), ".git"))
102+ if validName(name) && !reservedRepo(name) {
103+ return name
104+ }
105+ return ""
106+}
107+
108+func remoteGit(ctx context.Context, dir, pin, token string, args ...string) ([]byte, error) {
109+ cfg := [][2]string{{"http.curloptResolve", pin}, {"http.followRedirects", "false"}, {"protocol.allow", "never"}, {"protocol.https.allow", "always"}, {"credential.helper", ""}}
110+ if token != "" {
111+ cfg = append(cfg, [2]string{"http.extraHeader", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte("oauth2:"+token))})
112+ }
113+ env := []string{"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=/nonexistent", "LC_ALL=C.UTF-8", "PATH=" + os.Getenv("PATH"), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false", "GIT_ALLOW_PROTOCOL=https", "GIT_CONFIG_COUNT=" + strconv.Itoa(len(cfg))}
114+ for i, kv := range cfg {
115+ env = append(env, fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, kv[0]), fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, kv[1]))
116+ }
117+ cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
118+ cmd.Env = env
119+ var out, errb bytes.Buffer
120+ cmd.Stdout, cmd.Stderr = &out, &errb
121+ if err := cmd.Run(); err != nil {
122+ msg := strings.TrimSpace(errb.String())
123+ if token != "" {
124+ msg = strings.ReplaceAll(msg, token, "…")
125+ }
126+ if i := strings.LastIndex(msg, "\n"); i >= 0 {
127+ msg = msg[i+1:]
128+ }
129+ if msg == "" {
130+ msg = err.Error()
131+ }
132+ return nil, errors.New(msg)
133+ }
134+ return out.Bytes(), nil
135+}
136+
137+func (s *Server) importForm(w http.ResponseWriter, r *http.Request) {
138+ u := s.require(w, r)
139+ if u == nil {
140+ return
141+ }
142+ s.render(w, r, "import", page{Title: "Import repository", User: u, Data: map[string]any{"Orgs": s.store.Orgs(u.ID), "Owner": r.URL.Query().Get("owner")}})
143+}
144+
145+func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) {
146+ u := s.require(w, r)
147+ if u == nil {
148+ return
149+ }
150+ form := map[string]any{"Orgs": s.store.Orgs(u.ID), "Owner": r.FormValue("owner"), "Source": r.FormValue("source"), "Name": r.FormValue("name"), "Description": r.FormValue("description")}
151+ fail := func(msg string) {
152+ w.WriteHeader(400)
153+ s.render(w, r, "import", page{Title: "Import repository", User: u, Error: msg, Data: form})
154+ }
155+ owner := *u
156+ if o := r.FormValue("owner"); o != "" && o != u.Name {
157+ org, err := s.store.UserByName(o)
158+ if err != nil || !s.store.IsOrg(org.ID) || !s.store.IsMember(org.ID, u.ID) {
159+ fail("You are not a member of that organization")
160+ return
161+ }
162+ owner = org
163+ }
164+ src, pin, err := importSource(r.FormValue("source"))
165+ if err != nil {
166+ fail(err.Error())
167+ return
168+ }
169+ name := strings.TrimSpace(r.FormValue("name"))
170+ if name == "" {
171+ name = importName(src)
172+ }
173+ if s.uploads.blocked("import:" + u.Name) {
174+ fail("Too many imports, try again later")
175+ return
176+ }
177+ if over, used, quota := overQuota(s.root, owner.Name); over {
178+ fail(quotaMessage(used, quota))
179+ return
180+ }
181+ repo, err := s.store.CreateRepo(owner, name, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), "")
182+ if err != nil {
183+ fail(err.Error())
184+ return
185+ }
186+ dir := s.repoDir(repo)
187+ if err := os.MkdirAll(path.Dir(dir), 0o755); err == nil {
188+ err = initRepo(dir)
189+ }
190+ if err != nil {
191+ os.RemoveAll(dir)
192+ s.store.DeleteRepo(repo.ID)
193+ fail("Could not create the repository")
194+ return
195+ }
196+ installHook(s.root, dir)
197+ s.uploads.fail("import:" + u.Name)
198+ imports.Lock()
199+ imports.m[repo.ID] = &importState{Source: src.Redacted()}
200+ imports.Unlock()
201+ go s.runImport(repo, src.String(), pin, strings.TrimSpace(r.FormValue("token")))
202+ http.Redirect(w, r, "/"+repo.Path()+"/import", http.StatusSeeOther)
203+}
204+
205+func (s *Server) runImport(repo Repo, src, pin, token string) {
206+ importSlots <- struct{}{}
207+ defer func() { <-importSlots }()
208+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
209+ defer cancel()
210+ dir := s.repoDir(repo)
211+ err := func() error {
212+ head, err := remoteGit(ctx, dir, pin, token, "ls-remote", "--symref", src, "HEAD")
213+ if err != nil {
214+ return err
215+ }
216+ if _, err := remoteGit(ctx, dir, pin, token, "fetch", "--quiet", "--no-tags", src, "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"); err != nil {
217+ return err
218+ }
219+ for line := range strings.Lines(string(head)) {
220+ if ref, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
221+ branch, _, _ := strings.Cut(ref, "\t")
222+ Git{Dir: dir}.run(nil, "symbolic-ref", "HEAD", "refs/heads/"+branch)
223+ }
224+ }
225+ if over, used, quota := overQuota(s.root, repo.Owner.Name); over {
226+ return errors.New(quotaMessage(used, quota))
227+ }
228+ return nil
229+ }()
230+ if err != nil {
231+ log.Printf("import %s: %v", repo.Path(), err)
232+ os.RemoveAll(dir)
233+ s.store.DeleteRepo(repo.ID)
234+ imports.Lock()
235+ delete(imports.m, repo.ID)
236+ imports.Unlock()
237+ failedImports.Lock()
238+ failedImports.m[repo.Path()] = err.Error()
239+ failedImports.Unlock()
240+ time.AfterFunc(30*time.Minute, func() {
241+ failedImports.Lock()
242+ delete(failedImports.m, repo.Path())
243+ failedImports.Unlock()
244+ })
245+ return
246+ }
247+ setImport(repo.ID, func(st *importState) { st.Done = true })
248+}
249+
250+func (s *Server) importProgress(w http.ResponseWriter, r *http.Request, repo Repo) {
251+ rp, _, _ := s.repoPage(r, repo, "")
252+ st, ok := importStatus(repo.ID)
253+ if st.Done {
254+ imports.Lock()
255+ delete(imports.m, repo.ID)
256+ imports.Unlock()
257+ }
258+ if !ok || st.Done {
259+ target := "/" + repo.Path()
260+ if rp.IsOwner && len(s.claimable(repo, rp.User)) > 0 {
261+ target += "/claim"
262+ }
263+ http.Redirect(w, r, target, http.StatusSeeOther)
264+ return
265+ }
266+ w.Header().Set("Refresh", "3")
267+ s.render(w, r, "message", page{Title: "Importing", Data: map[string]any{"Heading": "Importing " + repo.Path(), "Text": "Fetching branches and tags from " + st.Source + ". This page refreshes on its own."}})
268+}
269+
270+type identity struct {
271+ Name, Email string
272+ Commits int
273+}
274+
275+func (i identity) Key() string { return i.Name + " <" + i.Email + ">" }
276+
277+func (s *Server) claimable(repo Repo, u *User) []identity {
278+ if u == nil || s.store.OpenPullCount(repo.ID) > 0 {
279+ return nil
280+ }
281+ g := Git{Dir: s.repoDir(repo)}
282+ out, err := g.run(nil, "log", "--all", "--format=%an%x00%ae%x00%cn%x00%ce")
283+ if err != nil {
284+ return nil
285+ }
286+ names := map[string]bool{strings.ToLower(u.Name): true}
287+ if d := strings.TrimSpace(s.store.Profile(u.ID).Display); d != "" {
288+ names[strings.ToLower(d)] = true
289+ }
290+ email := strings.ToLower(s.store.Account(u.ID).Email)
291+ mine := func(name, mail string) bool {
292+ mail = strings.ToLower(mail)
293+ local, domain, _ := strings.Cut(mail, "@")
294+ if _, after, ok := strings.Cut(local, "+"); ok && strings.HasPrefix(domain, "users.noreply.") {
295+ local = after
296+ }
297+ return names[strings.ToLower(strings.TrimSpace(name))] || (email != "" && mail == email) || (strings.HasPrefix(domain, "users.noreply.") && local == strings.ToLower(u.Name))
298+ }
299+ seen := map[string]*identity{}
300+ var order []string
301+ for line := range strings.Lines(string(out)) {
302+ f := strings.Split(strings.TrimRight(line, "\n"), "\x00")
303+ if len(f) != 4 {
304+ continue
305+ }
306+ for _, p := range [][2]string{{f[0], f[1]}, {f[2], f[3]}} {
307+ if !mine(p[0], p[1]) {
308+ continue
309+ }
310+ k := p[0] + " <" + p[1] + ">"
311+ if seen[k] == nil {
312+ seen[k] = &identity{Name: p[0], Email: p[1]}
313+ order = append(order, k)
314+ }
315+ if p == [2]string{f[0], f[1]} {
316+ seen[k].Commits++
317+ }
318+ }
319+ }
320+ out2 := make([]identity, 0, len(order))
321+ for _, k := range order {
322+ out2 = append(out2, *seen[k])
323+ }
324+ return out2
325+}
326+
327+func (s *Server) claimIdentity(u User) (string, string) {
328+ name := strings.TrimSpace(s.store.Profile(u.ID).Display)
329+ if name == "" {
330+ name = u.Name
331+ }
332+ email := s.store.VerifiedEmail(u.ID)
333+ if email == "" {
334+ email = s.noreply(u)
335+ }
336+ return name, email
337+}
338+
339+func (s *Server) noreply(u User) string {
340+ return strconv.FormatInt(u.ID, 10) + "+" + u.Name + "@users.noreply." + s.host
341+}
342+
343+func (s *Server) personByEmail(email string) (User, bool) {
344+ if u, err := s.store.UserByEmail(email); err == nil {
345+ return u, true
346+ }
347+ local, ok := strings.CutSuffix(strings.ToLower(strings.TrimSpace(email)), "@users.noreply."+strings.ToLower(s.host))
348+ if !ok {
349+ return User{}, false
350+ }
351+ if id, name, ok := strings.Cut(local, "+"); ok {
352+ n, err := strconv.ParseInt(id, 10, 64)
353+ if u, uerr := s.store.userByID(n); err == nil && uerr == nil {
354+ return u, true
355+ }
356+ local = name
357+ }
358+ if u, err := s.store.UserByName(local); err == nil {
359+ return u, true
360+ }
361+ if u, ok := s.store.Renamed(local); ok {
362+ return u, true
363+ }
364+ return User{}, false
365+}
366+
367+func (s *Server) claim(w http.ResponseWriter, r *http.Request, repo Repo) {
368+ rp, _, _ := s.repoPage(r, repo, "")
369+ if !rp.IsOwner {
370+ s.fail(w, r, 403, "Not your repository")
371+ return
372+ }
373+ ids := s.claimable(repo, rp.User)
374+ name, email := s.claimIdentity(*rp.User)
375+ if r.Method == "POST" {
376+ r.ParseForm()
377+ picked := map[string]bool{}
378+ for _, id := range ids {
379+ if slices.Contains(r.Form["id"], id.Key()) {
380+ picked[id.Key()] = true
381+ }
382+ }
383+ if len(picked) > 0 {
384+ if err := rewriteAuthors(s.repoDir(repo), picked, name+" <"+email+">"); err != nil {
385+ log.Printf("claim %s: %v", repo.Path(), err)
386+ http.Redirect(w, r, "/"+repo.Path()+"/claim?error="+template.URLQueryEscaper("Could not rewrite the history"), http.StatusSeeOther)
387+ return
388+ }
389+ }
390+ http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
391+ return
392+ }
393+ rp.Tab = "settings"
394+ s.render(w, r, "claim", page{Title: "Claim commits", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Identities": ids, "Name": name, "Email": email}})
395+}
396+
397+var signedCommitsFlag = sync.OnceValue(func() bool {
398+ out, _ := exec.Command("git", "fast-export", "-h").CombinedOutput()
399+ return bytes.Contains(out, []byte("signed-commits"))
400+})
401+
402+func rewriteAuthors(dir string, picked map[string]bool, to string) error {
403+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
404+ defer cancel()
405+ env := []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "LC_ALL=C.UTF-8", "PATH=" + os.Getenv("PATH")}
406+ args := []string{"-C", dir, "fast-export", "--all", "--no-data", "--signed-tags=strip", "--reencode=yes"}
407+ if signedCommitsFlag() {
408+ args = append(args, "--signed-commits=strip")
409+ }
410+ export := exec.CommandContext(ctx, "git", args...)
411+ imp := exec.CommandContext(ctx, "git", "-C", dir, "fast-import", "--quiet", "--force")
412+ export.Env, imp.Env = env, env
413+ var errb bytes.Buffer
414+ export.Stderr, imp.Stderr = &errb, &errb
415+ out, err := export.StdoutPipe()
416+ if err != nil {
417+ return err
418+ }
419+ in, err := imp.StdinPipe()
420+ if err != nil {
421+ return err
422+ }
423+ if err := export.Start(); err != nil {
424+ return err
425+ }
426+ if err := imp.Start(); err != nil {
427+ export.Process.Kill()
428+ return err
429+ }
430+ ferr := filterIdents(bufio.NewReader(out), in, picked, to)
431+ in.Close()
432+ eerr, ierr := export.Wait(), imp.Wait()
433+ for _, e := range []error{ferr, eerr, ierr} {
434+ if e != nil {
435+ return fmt.Errorf("%v: %s", e, strings.TrimSpace(errb.String()))
436+ }
437+ }
438+ return nil
439+}
440+
441+func filterIdents(rd *bufio.Reader, w io.Writer, picked map[string]bool, to string) error {
442+ bw := bufio.NewWriter(w)
443+ defer bw.Flush()
444+ for {
445+ line, err := rd.ReadString('\n')
446+ if err == io.EOF && line == "" {
447+ return nil
448+ }
449+ if err != nil && err != io.EOF {
450+ return err
451+ }
452+ if n, ok := strings.CutPrefix(line, "data "); ok {
453+ size, perr := strconv.Atoi(strings.TrimSpace(n))
454+ if perr != nil {
455+ return perr
456+ }
457+ bw.WriteString(line)
458+ if _, err := io.CopyN(bw, rd, int64(size)); err != nil {
459+ return err
460+ }
461+ continue
462+ }
463+ for _, kind := range []string{"author ", "committer "} {
464+ rest, ok := strings.CutPrefix(line, kind)
465+ if !ok {
466+ continue
467+ }
468+ end := strings.LastIndex(rest, "> ")
469+ if end >= 0 && picked[rest[:end+1]] {
470+ line = kind + to + rest[end+1:]
471+ }
472+ }
473+ bw.WriteString(line)
474+ }
475+}

forge/store.go modified

@@ -1122,7 +1122,7 @@ func validEmail(email string) bool {
11221122 const maxPins = 6
11231123
11241124 func reservedRepo(name string) bool {
1125- return name == "avatar" || name == "stars" || name == "feed" || name == "repos" || name == "follow" || name == "followers" || name == "following"
1125+ return name == "avatar" || name == "stars" || name == "feed" || name == "repos" || name == "follow" || name == "followers" || name == "following" || name == "import" || name == "claim"
11261126 }
11271127
11281128 func (s *Store) Pins(owner int64) map[int64]int {

forge/unit_test.go modified

@@ -3,6 +3,10 @@ package forge
33 import (
44 "net"
55 "net/http/httptest"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strconv"
610 "strings"
711 "testing"
812 )
@@ -147,3 +151,44 @@ func TestTrustedProxy(t *testing.T) {
147151 t.Errorf("untrusted peer could choose its address: %s", got)
148152 }
149153 }
154+
155+func TestImportSource(t *testing.T) {
156+ for _, raw := range []string{"http://github.com/a/b", "https://127.0.0.1/a", "https://localhost/a", "https://user:pw@github.com/a/b", "file:///etc", "https://10.0.0.1/x", "ssh://git@github.com/a/b"} {
157+ if _, _, err := importSource(raw); err == nil {
158+ t.Errorf("%s accepted", raw)
159+ }
160+ }
161+}
162+
163+func TestRewriteAuthors(t *testing.T) {
164+ dir := t.TempDir()
165+ run := func(env []string, args ...string) string {
166+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
167+ cmd.Env = append(os.Environ(), env...)
168+ out, err := cmd.CombinedOutput()
169+ if err != nil {
170+ t.Fatalf("git %v: %v %s", args, err, out)
171+ }
172+ return string(out)
173+ }
174+ run(nil, "init", "-q", "-b", "main")
175+ for i, who := range []string{"Old Me", "Someone Else", "Old Me"} {
176+ os.WriteFile(filepath.Join(dir, "f"), []byte(strconv.Itoa(i)), 0o644)
177+ run(nil, "add", "f")
178+ run([]string{"GIT_AUTHOR_NAME=" + who, "GIT_AUTHOR_EMAIL=" + strings.ReplaceAll(strings.ToLower(who), " ", ".") + "@example.org", "GIT_COMMITTER_NAME=" + who, "GIT_COMMITTER_EMAIL=c@example.org"}, "commit", "-q", "-m", "data 99\nmessage that mentions author Old Me <old.me@example.org>")
179+ }
180+ run(nil, "tag", "v1")
181+ if err := rewriteAuthors(dir, map[string]bool{"Old Me <old.me@example.org>": true}, "New Me <new@example.org>"); err != nil {
182+ t.Fatal(err)
183+ }
184+ got := run(nil, "log", "--format=%an <%ae>", "main")
185+ if strings.Count(got, "New Me <new@example.org>") != 2 || strings.Count(got, "Someone Else") != 1 || strings.Contains(got, "Old Me <old.me") {
186+ t.Errorf("history after rewrite:\n%s", got)
187+ }
188+ if !strings.Contains(run(nil, "log", "-1", "--format=%B", "main"), "author Old Me <old.me@example.org>") {
189+ t.Error("commit message was rewritten")
190+ }
191+ if run(nil, "rev-parse", "v1^{commit}") != run(nil, "rev-parse", "main") {
192+ t.Error("tag not moved to the rewritten commit")
193+ }
194+}

forge/web.go modified

@@ -112,7 +112,7 @@ func newServer(store *Store, root, host, name string, sshUser string) *Server {
112112 "add": func(a, b int) int { return a + b },
113113 "sub": func(a, b int) int { return a - b },
114114 "person": func(email string) string {
115- if u, err := s.store.UserByEmail(email); err == nil {
115+ if u, ok := s.personByEmail(email); ok {
116116 return u.Name
117117 }
118118 return ""
@@ -267,6 +267,8 @@ func (s *Server) handler() http.Handler {
267267 mux.HandleFunc("POST /register", s.register)
268268 mux.HandleFunc("GET /create", s.createForm)
269269 mux.HandleFunc("POST /create", s.create)
270+ mux.HandleFunc("GET /import", s.importForm)
271+ mux.HandleFunc("POST /import", s.importRepo)
270272 mux.HandleFunc("GET /settings", s.settings)
271273 mux.HandleFunc("GET /settings/security", s.security)
272274 mux.HandleFunc("GET /settings/keys", s.keysPage)
@@ -986,6 +988,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
986988 }
987989 repo, err := s.store.RepoByPath(owner.Name, name)
988990 if err != nil {
991+ if msg := failedImport(owner.Name + "/" + name); msg != "" && rest == "import" {
992+ s.render(w, r, "message", page{Title: "Import failed", User: user, Data: map[string]any{"Heading": "Import of ~" + owner.Name + "/" + name + " failed", "Text": msg + ". Nothing was kept, so you can try again from the import page."}})
993+ return
994+ }
989995 if !s.redirectMoved(w, r, owner.Name, name, user, http.StatusMovedPermanently) {
990996 s.fail(w, r, 404, "No such repository")
991997 }
@@ -1068,6 +1074,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
10681074 s.createPull(w, r, repo)
10691075 case strings.HasPrefix(rest, "pulls/"):
10701076 s.pull(w, r, repo, strings.TrimPrefix(rest, "pulls/"))
1077+ case rest == "import" && r.Method == "GET":
1078+ s.importProgress(w, r, repo)
1079+ case rest == "claim":
1080+ s.claim(w, r, repo)
10711081 case rest == "fork" && r.Method == "POST":
10721082 s.fork(w, r, repo)
10731083 default:

web/templates/claim.tmpl added

@@ -0,0 +1,12 @@
1+{{define "content"}}
2+{{$p := .Data.Page}}
3+<h3>Claim your commits</h3>
4+{{if .Data.Identities}}
5+<p>These authors in the history of {{$p.Repo.Path}} look like you. Pick the ones that are yours and their commits will be credited to <b>{{.Data.Name}} &lt;{{.Data.Email}}&gt;</b>.</p>
6+<form method="post" class="stack wide claim">
7+{{range .Data.Identities}}<label class="check"><input type="checkbox" name="id" value="{{.Key}}" checked> {{.Name}} <span class="muted">&lt;{{.Email}}&gt;</span>{{if .Commits}} <span class="muted small">{{.Commits}} commit{{if ne .Commits 1}}s{{end}}</span>{{end}}</label>{{end}}
8+<p class="muted small">This rewrites the history: every affected commit gets a new hash and loses its signature. Anyone who already cloned the repository has to clone it again.</p>
9+<div class="actions left"><button class="btn primary">Rewrite and claim ▸</button><a class="btn" href="/{{$p.Repo.Path}}">Keep the history as it is</a></div>
10+</form>
11+{{else}}<p class="muted">No author in this history matches your username, name or email.</p><p><a class="btn" href="/{{$p.Repo.Path}}">Back to the repository ▸</a></p>{{end}}
12+{{end}}

web/templates/create.tmpl modified

@@ -1,5 +1,6 @@
11 {{define "content"}}
22 <h3>Create new repository</h3>
3+<p class="muted">Already have it somewhere else? <a href="/import{{with .Data.Owner}}?owner={{.}}{{end}}">Import a repository</a> from GitHub, GitLab, Gitea, Forgejo or any https git URL.</p>
34 <form method="post" action="/create" class="twocol">
45 <div>
56 {{with .Data.Orgs}}<label>Owner <select name="owner"><option value="{{$.User.Name}}">~{{$.User.Name}}</option>{{range .}}<option value="{{.Name}}"{{if eq .Name $.Data.Owner}} selected{{end}}>~{{.Name}}</option>{{end}}</select></label>{{end}}

web/templates/import.tmpl added

@@ -0,0 +1,20 @@
1+{{define "content"}}
2+<h3>Import a repository</h3>
3+<p class="muted">Branches and tags are copied from GitHub, GitLab, Gitea, Forgejo, Codeberg or any https clone URL. Issues and pull requests stay where they are.</p>
4+<form method="post" action="/import" class="twocol">
5+<div>
6+<label>Clone URL <input name="source" type="url" required autofocus placeholder="https://github.com/owner/repo" value="{{.Data.Source}}"></label>
7+<label>Access token <span class="muted">(private sources only)</span><input name="token" type="password" autocomplete="off" placeholder="Personal access token with read access"><small>Used once for this import and never stored.</small></label>
8+{{with .Data.Orgs}}<label>Owner <select name="owner"><option value="{{$.User.Name}}">~{{$.User.Name}}</option>{{range .}}<option value="{{.Name}}"{{if eq .Name $.Data.Owner}} selected{{end}}>~{{.Name}}</option>{{end}}</select></label>{{end}}
9+<label>Repository name <input name="name" pattern="[a-z0-9][a-z0-9_.-]{0,38}" placeholder="Taken from the URL when left empty" value="{{.Data.Name}}"></label>
10+<label>Description <input name="description" maxlength="200" value="{{.Data.Description}}"></label>
11+<div class="actions"><button class="btn primary">Import repository ▸</button></div>
12+</div>
13+<div>
14+<span class="legend">Visibility</span>
15+<label class="radio"><input type="radio" name="visibility" value="public" checked> Public<small>Shown on your profile and listed in the public index</small></label>
16+<label class="radio"><input type="radio" name="visibility" value="unlisted"> Unlisted<small>Visible to anyone who knows the URL, but not shown on your profile</small></label>
17+<label class="radio"><input type="radio" name="visibility" value="private"> Private<small>Only visible to you</small></label>
18+</div>
19+</form>
20+{{end}}