@@ -174,6 +174,59 @@ func (r *RepoFS) InitBare(ctx context.Context, path string) error { |
| 174 | 174 | return nil |
| 175 | 175 | } |
| 176 | 176 | |
| 177 | +// CloneBareShared clones src → dst as a bare repo with object |
| 178 | +// alternates pointing back at src. Disk usage of the result is |
| 179 | +// essentially refs + a small overhead; objects live in src's |
| 180 | +// `objects/` until the fork is detached (S16 hard-delete cascade |
| 181 | +// repacks each fork before removing the source). |
| 182 | +// |
| 183 | +// Both paths must be contained in r.root and on the same volume — |
| 184 | +// the same-volume requirement is what makes alternates safe (S04). |
| 185 | +// |
| 186 | +// On success the dst directory exists with `git init --bare` shape |
| 187 | +// plus an `objects/info/alternates` file pointing at src/objects. |
| 188 | +// On failure the dst directory is removed so a retry sees a clean |
| 189 | +// slate. |
| 190 | +func (r *RepoFS) CloneBareShared(ctx context.Context, src, dst string) error { |
| 191 | + if err := r.containedInRoot(src); err != nil { |
| 192 | + return err |
| 193 | + } |
| 194 | + if err := r.containedInRoot(dst); err != nil { |
| 195 | + return err |
| 196 | + } |
| 197 | + if entries, err := os.ReadDir(dst); err == nil && len(entries) > 0 { |
| 198 | + return fmt.Errorf("%w: %s", ErrAlreadyExists, dst) |
| 199 | + } |
| 200 | + if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { |
| 201 | + return fmt.Errorf("storage: repofs: mkdir parent: %w", err) |
| 202 | + } |
| 203 | + // G204: src/dst are RepoPath-derived, both verified under r.root. |
| 204 | + cmd := exec.CommandContext(ctx, "git", "clone", "--bare", "--shared", src, dst) //nolint:gosec |
| 205 | + out, err := cmd.CombinedOutput() |
| 206 | + if err != nil { |
| 207 | + // Best-effort cleanup; if removal fails too, surface the |
| 208 | + // original clone error since that's the actionable signal. |
| 209 | + _ = os.RemoveAll(dst) |
| 210 | + return fmt.Errorf("storage: repofs: git clone --bare --shared: %w (output: %s)", err, strings.TrimSpace(string(out))) |
| 211 | + } |
| 212 | + return nil |
| 213 | +} |
| 214 | + |
| 215 | +// SetPreciousObjects marks a bare repo's objects as not-prunable. The |
| 216 | +// canonical foot-gun for forks is source-repo `git gc` removing |
| 217 | +// objects that forks reach via alternates; setting this on the source |
| 218 | +// after a fork is created prevents that. Idempotent. |
| 219 | +func (r *RepoFS) SetPreciousObjects(ctx context.Context, path string) error { |
| 220 | + if err := r.containedInRoot(path); err != nil { |
| 221 | + return err |
| 222 | + } |
| 223 | + cmd := exec.CommandContext(ctx, "git", "-C", path, "config", "extensions.preciousObjects", "true") //nolint:gosec |
| 224 | + if out, err := cmd.CombinedOutput(); err != nil { |
| 225 | + return fmt.Errorf("storage: repofs: set preciousObjects: %w (output: %s)", err, strings.TrimSpace(string(out))) |
| 226 | + } |
| 227 | + return nil |
| 228 | +} |
| 229 | + |
| 177 | 230 | // Move atomically renames oldPath to newPath. Both must be under root. |
| 178 | 231 | // If newPath already exists, returns ErrAlreadyExists rather than |
| 179 | 232 | // overwriting (avoids silent corruption on concurrent moves). |