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 if strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), bareTmpSuffix) {
91 g.recoverBareTmp(e.Name())
92 continue
93 }
94 dir := filepath.Join(g.cfg.ReposDir(), e.Name())
95 // Lstat, because the removal of the work tree must never follow a
96 // symlinked .git into the real git data.
97 st, err := os.Lstat(filepath.Join(dir, ".git"))
98 if err != nil || !st.IsDir() {
99 if err == nil && st.Mode()&os.ModeSymlink != 0 {
100 log.Printf("Skipping bare conversion of %s: .git is a symlink", e.Name())
101 }
102 continue
103 }
104 if err := g.convertNonBareRepo(e.Name(), dir); err != nil {
105 log.Printf("Failed to convert non-bare repo %s: %v", e.Name(), err)
106 }
107 }
108}
109
110// convertNonBareRepo moves entry/.git into place as entry.git and drops the
111// work tree. When the entry is already named *.git the move needs a temporary
112// name, because source and target would be the same path.
113func (g *Git) convertNonBareRepo(entryName, entryPath string) error {
114 dotGit := filepath.Join(entryPath, ".git")
115 baseName := entryName
116 if !strings.HasSuffix(entryName, ".git") {
117 baseName += ".git"
118 }
119 target := filepath.Join(g.cfg.ReposDir(), baseName)
120
121 if strings.HasSuffix(entryName, ".git") {
122 tmp := filepath.Join(g.cfg.ReposDir(), "."+entryName+bareTmpSuffix)
123 if err := os.Rename(dotGit, tmp); err != nil {
124 return err
125 }
126 if err := os.RemoveAll(entryPath); err != nil {
127 return err
128 }
129 if err := os.Rename(tmp, target); err != nil {
130 return err
131 }
132 } else {
133 if err := os.Rename(dotGit, target); err != nil {
134 return err
135 }
136 if err := os.RemoveAll(entryPath); err != nil {
137 return err
138 }
139 }
140 if err := os.RemoveAll(filepath.Join(target, "worktrees")); err != nil {
141 return err
142 }
143 log.Printf("Converted non-bare repo to bare: %s", baseName)
144 return nil
145}
146
147const bareTmpSuffix = ".bare_tmp"
148
149// recoverBareTmp finishes a *.git conversion that stopped after the git data
150// was moved aside to the hidden temporary name.
151func (g *Git) recoverBareTmp(tmpName string) {
152 tmp := filepath.Join(g.cfg.ReposDir(), tmpName)
153 target := filepath.Join(g.cfg.ReposDir(), strings.TrimSuffix(tmpName[1:], bareTmpSuffix))
154 if _, err := os.Lstat(target); err == nil {
155 log.Printf("WARNING: %s holds the git data of %s, but %s still exists. Resolve by hand.",
156 tmp, filepath.Base(target), target)
157 return
158 }
159 if err := os.Rename(tmp, target); err != nil {
160 log.Printf("WARNING: could not restore %s to %s: %v", tmp, target, err)
161 return
162 }
163 log.Printf("Recovered interrupted bare conversion: %s", filepath.Base(target))
164}
165
166// EnsureSigningSetup generates the ssh host key if missing and writes the
167// allowed_signers file used to verify commit signatures.
168func (g *Git) EnsureSigningSetup() error {
169 if err := os.MkdirAll(g.cfg.DataDir, 0o700); err != nil {
170 return err
171 }
172 if err := os.MkdirAll(g.cfg.ReposDir(), 0o700); err != nil {
173 return err
174 }
175 hostname := ""
176 if u, err := url.Parse(g.cfg.BaseURL); err == nil {
177 hostname = u.Hostname()
178 }
179 keyPath := g.cfg.SSHHostKeyPath
180 pubPath := keyPath + ".pub"
181
182 if _, err := os.Stat(keyPath); err != nil {
183 cmd := exec.CommandContext(context.Background(), "ssh-keygen", "-t", "ed25519", "-N", "", "-f", keyPath, "-C", hostname)
184 if out, err := cmd.CombinedOutput(); err != nil {
185 return fmt.Errorf("ssh-keygen: %w: %s", err, out)
186 }
187 log.Printf("Generated SSH host key at %s", keyPath)
188 }
189
190 pub, err := os.ReadFile(pubPath)
191 if err != nil {
192 log.Printf("Could not read SSH public key at %s", pubPath)
193 return nil
194 }
195 pubKey := strings.TrimSpace(string(pub))
196 // The comment field holds the hostname the key was made for. A mismatch
197 // means signatures will show an unexpected identity.
198 if fields := strings.Fields(pubKey); len(fields) > 2 && fields[2] != hostname {
199 log.Printf("Warning: SSH host key comment %q does not match hostname %q", fields[2], hostname)
200 }
201
202 content := "* namespaces=\"git\" " + pubKey + "\n"
203 if g.cfg.ExtraAllowedSigners != "" {
204 extra, err := os.ReadFile(g.cfg.ExtraAllowedSigners)
205 if err != nil {
206 log.Printf("Could not read EXTRA_ALLOWED_SIGNERS_PATH: %s", g.cfg.ExtraAllowedSigners)
207 } else {
208 content += strings.TrimRight(string(extra), "\n") + "\n"
209 }
210 }
211 return os.WriteFile(g.cfg.AllowedSignersPath(), []byte(content), 0o600)
212}
213