sync.go
| 1 | package gitcmd |
| 2 | |
| 3 | import ( |
| 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". |
| 17 | func (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. |
| 35 | func (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. |
| 60 | func (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. |
| 81 | func (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. |
| 113 | func (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 | |
| 147 | const 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. |
| 151 | func (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. |
| 168 | func (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 |