sync.go
⎇
Raw
1package gitcmd
2
3import (
4 "context"
5 "fmt"
6 "log"
7 "net/url"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "time"
13)
14
15// ListDiskRepoNames returns the names of all bare repos under ReposDir.
16// A read error is returned, so a caller cannot mistake it for "no repos".
17func (g *Git) ListDiskRepoNames() ([]string, error) {
18 entries, err := os.ReadDir(g.cfg.ReposDir())
19 if err != nil {
20 return nil, err
21 }
22 var names []string
23 for _, e := range entries {
24 if e.IsDir() && strings.HasSuffix(e.Name(), ".git") {
25 names = append(names, strings.TrimSuffix(e.Name(), ".git"))
26 }
27 }
28 return names, nil
29}
30
31// SyncStartup runs the disk-side startup work: signing setup, stale lock
32// cleanup, and conversion of non-bare repos. It returns the valid repo names
33// found on disk. The caller reconciles those against the repositories table,
34// because this package does not touch the database.
35func (g *Git) SyncStartup(ctx context.Context) ([]string, error) {
36 if err := g.EnsureSigningSetup(); err != nil {
37 return nil, err
38 }
39 g.ClearStaleConfigLocks()
40 g.ConvertNonBareRepos()
41 names, err := g.ListDiskRepoNames()
42 if err != nil {
43 return nil, err
44 }
45 var valid []string
46 for _, n := range names {
47 // A name that fails validation can never be served, so skip it.
48 if !ValidRepoName(n) {
49 continue
50 }
51 if err := g.EnsureBare(ctx, n); err != nil {
52 log.Printf("[git] ensureBare failed for %s: %v", n, err)
53 }
54 valid = append(valid, n)
55 }
56 return valid, nil
57}
58
59// ClearStaleConfigLocks removes config.lock files left behind by a crash.
60func (g *Git) ClearStaleConfigLocks() {
61 entries, err := os.ReadDir(g.cfg.ReposDir())
62 if err != nil {
63 return
64 }
65 for _, e := range entries {
66 if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
67 continue
68 }
69 lock := filepath.Join(g.cfg.ReposDir(), e.Name(), "config.lock")
70 st, err := os.Stat(lock)
71 if err != nil || time.Since(st.ModTime()) < staleLockAge {
72 continue
73 }
74 if os.Remove(lock) == nil {
75 log.Printf("Removed stale config lock: %s", e.Name())
76 }
77 }
78}
79
80// ConvertNonBareRepos turns any repo with a .git subdirectory into a bare one.
81func (g *Git) ConvertNonBareRepos() {
82 entries, err := os.ReadDir(g.cfg.ReposDir())
83 if err != nil {
84 return
85 }
86 for _, e := range entries {
87 if !e.IsDir() {
88 continue
89 }
90 dir := filepath.Join(g.cfg.ReposDir(), e.Name())
91 if st, err := os.Stat(filepath.Join(dir, ".git")); err != nil || !st.IsDir() {
92 continue
93 }
94 if err := g.convertNonBareRepo(e.Name(), dir); err != nil {
95 log.Printf("Failed to convert non-bare repo %s: %v", e.Name(), err)
96 }
97 }
98}
99
100// convertNonBareRepo moves entry/.git into place as entry.git and drops the
101// work tree. When the entry is already named *.git the move needs a temporary
102// name, because source and target would be the same path.
103func (g *Git) convertNonBareRepo(entryName, entryPath string) error {
104 dotGit := filepath.Join(entryPath, ".git")
105 baseName := entryName
106 if !strings.HasSuffix(entryName, ".git") {
107 baseName += ".git"
108 }
109 target := filepath.Join(g.cfg.ReposDir(), baseName)
110
111 if strings.HasSuffix(entryName, ".git") {
112 tmp := filepath.Join(g.cfg.ReposDir(), "."+entryName+".bare_tmp")
113 if err := os.Rename(dotGit, tmp); err != nil {
114 return err
115 }
116 if err := os.RemoveAll(entryPath); err != nil {
117 return err
118 }
119 if err := os.Rename(tmp, target); err != nil {
120 return err
121 }
122 } else {
123 if err := os.Rename(dotGit, target); err != nil {
124 return err
125 }
126 if err := os.RemoveAll(entryPath); err != nil {
127 return err
128 }
129 }
130 if err := os.RemoveAll(filepath.Join(target, "worktrees")); err != nil {
131 return err
132 }
133 log.Printf("Converted non-bare repo to bare: %s", baseName)
134 return nil
135}
136
137// EnsureSigningSetup generates the ssh host key if missing and writes the
138// allowed_signers file used to verify commit signatures.
139func (g *Git) EnsureSigningSetup() error {
140 if err := os.MkdirAll(g.cfg.DataDir, 0o700); err != nil {
141 return err
142 }
143 if err := os.MkdirAll(g.cfg.ReposDir(), 0o700); err != nil {
144 return err
145 }
146 hostname := ""
147 if u, err := url.Parse(g.cfg.BaseURL); err == nil {
148 hostname = u.Hostname()
149 }
150 keyPath := g.cfg.SSHHostKeyPath
151 pubPath := keyPath + ".pub"
152
153 if _, err := os.Stat(keyPath); err != nil {
154 cmd := exec.CommandContext(context.Background(), "ssh-keygen", "-t", "ed25519", "-N", "", "-f", keyPath, "-C", hostname)
155 if out, err := cmd.CombinedOutput(); err != nil {
156 return fmt.Errorf("ssh-keygen: %w: %s", err, out)
157 }
158 log.Printf("Generated SSH host key at %s", keyPath)
159 }
160
161 pub, err := os.ReadFile(pubPath)
162 if err != nil {
163 log.Printf("Could not read SSH public key at %s", pubPath)
164 return nil
165 }
166 pubKey := strings.TrimSpace(string(pub))
167 // The comment field holds the hostname the key was made for. A mismatch
168 // means signatures will show an unexpected identity.
169 if fields := strings.Fields(pubKey); len(fields) > 2 && fields[2] != hostname {
170 log.Printf("Warning: SSH host key comment %q does not match hostname %q", fields[2], hostname)
171 }
172
173 content := "* namespaces=\"git\" " + pubKey + "\n"
174 if g.cfg.ExtraAllowedSigners != "" {
175 extra, err := os.ReadFile(g.cfg.ExtraAllowedSigners)
176 if err != nil {
177 log.Printf("Could not read EXTRA_ALLOWED_SIGNERS_PATH: %s", g.cfg.ExtraAllowedSigners)
178 } else {
179 content += strings.TrimRight(string(extra), "\n") + "\n"
180 }
181 }
182 return os.WriteFile(g.cfg.AllowedSignersPath(), []byte(content), 0o600)
183}
184