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 | 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. |
| 103 | func (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. |
| 139 | func (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 |