Skip to content

Commit fcdfc2e

Browse files
alecthomasclaude
andauthored
feat: cachew git restore that restores snapshots and applies bundles (#205)
Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 2f3c5c3 commit fcdfc2e

5 files changed

Lines changed: 417 additions & 10 deletions

File tree

cmd/cachew/git.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"os/exec"
10+
"strings"
11+
12+
"github.com/alecthomas/errors"
13+
14+
"github.com/block/cachew/internal/gitclone"
15+
"github.com/block/cachew/internal/snapshot"
16+
)
17+
18+
// GitCmd groups git-aware subcommands that talk directly to cachew's
19+
// /git/ strategy endpoints (not the generic object-store API).
20+
type GitCmd struct {
21+
Restore GitRestoreCmd `cmd:"" help:"Restore a repository from a cachew git snapshot."`
22+
}
23+
24+
// GitRestoreCmd fetches a git snapshot, extracts it, and optionally applies
25+
// a delta bundle to bring the working copy up to the mirror's current HEAD.
26+
type GitRestoreCmd struct {
27+
RepoURL string `arg:"" help:"Repository URL (e.g. https://github.com/org/repo)."`
28+
Directory string `arg:"" help:"Target directory for the clone." type:"path"`
29+
NoBundle bool `help:"Skip applying delta bundle."`
30+
ZstdThreads int `help:"Threads for zstd decompression (0 = all CPU cores)." default:"0"`
31+
}
32+
33+
func (c *GitRestoreCmd) Run(ctx context.Context, cli *CLI, client *http.Client) error {
34+
repoPath, err := gitclone.RepoPathFromURL(c.RepoURL)
35+
if err != nil {
36+
return errors.Wrap(err, "invalid repository URL")
37+
}
38+
39+
snapshotURL := fmt.Sprintf("%s/git/%s/snapshot.tar.zst", cli.URL, repoPath)
40+
fmt.Fprintf(os.Stderr, "Fetching snapshot from %s\n", snapshotURL) //nolint:forbidigo
41+
42+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, snapshotURL, nil)
43+
if err != nil {
44+
return errors.Wrap(err, "create snapshot request")
45+
}
46+
47+
resp, err := client.Do(req) //nolint:gosec // URL constructed from CLI flags
48+
if err != nil {
49+
return errors.Wrap(err, "fetch snapshot")
50+
}
51+
defer resp.Body.Close()
52+
53+
if resp.StatusCode != http.StatusOK {
54+
_, _ = io.Copy(io.Discard, resp.Body) //nolint:errcheck
55+
return errors.Errorf("snapshot request failed with status %d", resp.StatusCode)
56+
}
57+
58+
fmt.Fprintf(os.Stderr, "Extracting to %s...\n", c.Directory) //nolint:forbidigo
59+
if err := snapshot.Extract(ctx, resp.Body, c.Directory, c.ZstdThreads); err != nil {
60+
return errors.Wrap(err, "extract snapshot")
61+
}
62+
fmt.Fprintf(os.Stderr, "Snapshot restored to %s\n", c.Directory) //nolint:forbidigo
63+
64+
bundleURL := resp.Header.Get("X-Cachew-Bundle-Url")
65+
if bundleURL == "" || c.NoBundle {
66+
return nil
67+
}
68+
69+
fmt.Fprintf(os.Stderr, "Applying delta bundle...\n") //nolint:forbidigo
70+
if err := applyBundle(ctx, cli.URL, client, bundleURL, c.Directory); err != nil {
71+
fmt.Fprintf(os.Stderr, "Warning: failed to apply delta bundle: %v\n", err) //nolint:forbidigo
72+
return nil
73+
}
74+
fmt.Fprintf(os.Stderr, "Delta bundle applied\n") //nolint:forbidigo
75+
76+
return nil
77+
}
78+
79+
func applyBundle(ctx context.Context, baseURL string, client *http.Client, bundlePath, directory string) error {
80+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+bundlePath, nil)
81+
if err != nil {
82+
return errors.Wrap(err, "create bundle request")
83+
}
84+
85+
resp, err := client.Do(req) //nolint:gosec // URL constructed from CLI flags
86+
if err != nil {
87+
return errors.Wrap(err, "fetch bundle")
88+
}
89+
defer resp.Body.Close()
90+
91+
if resp.StatusCode != http.StatusOK {
92+
_, _ = io.Copy(io.Discard, resp.Body) //nolint:errcheck
93+
return errors.Errorf("bundle request failed with status %d", resp.StatusCode)
94+
}
95+
96+
tmpFile, err := os.CreateTemp("", "cachew-bundle-*.bundle")
97+
if err != nil {
98+
return errors.Wrap(err, "create temp bundle file")
99+
}
100+
defer os.Remove(tmpFile.Name()) //nolint:errcheck
101+
102+
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
103+
_ = tmpFile.Close()
104+
return errors.Wrap(err, "download bundle")
105+
}
106+
if err := tmpFile.Close(); err != nil {
107+
return errors.Wrap(err, "close temp bundle file")
108+
}
109+
110+
// Determine the current branch so we can pull from the bundle.
111+
branchCmd := exec.CommandContext(ctx, "git", "-C", directory, "symbolic-ref", "--short", "HEAD") //nolint:gosec
112+
branchOut, err := branchCmd.Output()
113+
if err != nil {
114+
return errors.Wrap(err, "determine current branch")
115+
}
116+
branch := strings.TrimSpace(string(branchOut))
117+
118+
// Pull the bundle's branch into the working tree via fast-forward.
119+
cmd := exec.CommandContext(ctx, "git", "-C", directory, "pull", "--ff-only", tmpFile.Name(), branch) //nolint:gosec
120+
if output, err := cmd.CombinedOutput(); err != nil {
121+
return errors.Wrapf(err, "git pull from bundle: %s", string(output))
122+
}
123+
124+
return nil
125+
}

cmd/cachew/git_test.go

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"strings"
12+
"testing"
13+
14+
"github.com/alecthomas/assert/v2"
15+
)
16+
17+
func initGitRepo(t *testing.T, dir string, files map[string]string) {
18+
t.Helper()
19+
run := func(args ...string) {
20+
t.Helper()
21+
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) //nolint:gosec
22+
cmd.Env = append(os.Environ(),
23+
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com",
24+
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
25+
)
26+
out, err := cmd.CombinedOutput()
27+
assert.NoError(t, err, string(out))
28+
}
29+
30+
run("init", "-b", "main")
31+
for name, content := range files {
32+
path := filepath.Join(dir, name)
33+
assert.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
34+
assert.NoError(t, os.WriteFile(path, []byte(content), 0o644))
35+
}
36+
run("add", "-A")
37+
run("commit", "-m", "initial")
38+
}
39+
40+
func createTarZst(t *testing.T, dir string) []byte {
41+
t.Helper()
42+
var buf bytes.Buffer
43+
tarCmd := exec.Command("tar", "-cpf", "-", "-C", dir, ".")
44+
zstdCmd := exec.Command("zstd", "-c")
45+
46+
tarOut, err := tarCmd.StdoutPipe()
47+
assert.NoError(t, err)
48+
zstdCmd.Stdin = tarOut
49+
zstdCmd.Stdout = &buf
50+
51+
assert.NoError(t, tarCmd.Start())
52+
assert.NoError(t, zstdCmd.Start())
53+
assert.NoError(t, tarCmd.Wait())
54+
assert.NoError(t, zstdCmd.Wait())
55+
return buf.Bytes()
56+
}
57+
58+
func gitRevParse(t *testing.T, dir, ref string) string {
59+
t.Helper()
60+
out, err := exec.Command("git", "-C", dir, "rev-parse", ref).Output() //nolint:gosec
61+
assert.NoError(t, err)
62+
return strings.TrimSpace(string(out))
63+
}
64+
65+
func createBundle(t *testing.T, dir, baseCommit string) []byte {
66+
t.Helper()
67+
bundlePath := filepath.Join(t.TempDir(), "delta.bundle")
68+
// Use refs/heads/main (not HEAD) to match server behaviour.
69+
cmd := exec.Command("git", "-C", dir, "bundle", "create", bundlePath, "refs/heads/main", "^"+baseCommit) //nolint:gosec
70+
out, err := cmd.CombinedOutput()
71+
assert.NoError(t, err, string(out))
72+
73+
data, err := os.ReadFile(bundlePath)
74+
assert.NoError(t, err)
75+
return data
76+
}
77+
78+
func TestGitRestoreSnapshot(t *testing.T) {
79+
srcDir := t.TempDir()
80+
initGitRepo(t, srcDir, map[string]string{
81+
"hello.txt": "hello world",
82+
"subdir/nested.txt": "nested content",
83+
})
84+
85+
snapshotData := createTarZst(t, srcDir)
86+
87+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88+
if strings.HasSuffix(r.URL.Path, "/snapshot.tar.zst") {
89+
w.Header().Set("Content-Type", "application/zstd")
90+
w.Write(snapshotData) //nolint:errcheck
91+
return
92+
}
93+
http.NotFound(w, r)
94+
}))
95+
defer srv.Close()
96+
97+
dstDir := filepath.Join(t.TempDir(), "restored")
98+
cmd := &GitRestoreCmd{
99+
RepoURL: "https://github.com/test/repo",
100+
Directory: dstDir,
101+
}
102+
cli := &CLI{URL: srv.URL}
103+
err := cmd.Run(context.Background(), cli, srv.Client())
104+
assert.NoError(t, err)
105+
106+
content, err := os.ReadFile(filepath.Join(dstDir, "hello.txt"))
107+
assert.NoError(t, err)
108+
assert.Equal(t, "hello world", string(content))
109+
110+
content, err = os.ReadFile(filepath.Join(dstDir, "subdir", "nested.txt"))
111+
assert.NoError(t, err)
112+
assert.Equal(t, "nested content", string(content))
113+
}
114+
115+
func TestGitRestoreWithBundle(t *testing.T) {
116+
srcDir := t.TempDir()
117+
initGitRepo(t, srcDir, map[string]string{"file.txt": "v1"})
118+
baseCommit := gitRevParse(t, srcDir, "HEAD")
119+
120+
snapshotData := createTarZst(t, srcDir)
121+
122+
// Add a second commit for the bundle.
123+
assert.NoError(t, os.WriteFile(filepath.Join(srcDir, "file.txt"), []byte("v2"), 0o644))
124+
assert.NoError(t, os.WriteFile(filepath.Join(srcDir, "new.txt"), []byte("new"), 0o644))
125+
cmd := exec.Command("git", "-C", srcDir, "add", "-A")
126+
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com",
127+
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com")
128+
out, err := cmd.CombinedOutput()
129+
assert.NoError(t, err, string(out))
130+
131+
cmd = exec.Command("git", "-C", srcDir, "commit", "-m", "update")
132+
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com",
133+
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com")
134+
out, err = cmd.CombinedOutput()
135+
assert.NoError(t, err, string(out))
136+
137+
bundleData := createBundle(t, srcDir, baseCommit)
138+
139+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
140+
switch {
141+
case strings.HasSuffix(r.URL.Path, "/snapshot.tar.zst"):
142+
w.Header().Set("Content-Type", "application/zstd")
143+
w.Header().Set("X-Cachew-Bundle-Url", "/git/github.com/test/repo/snapshot.bundle?base="+baseCommit)
144+
w.Write(snapshotData) //nolint:errcheck
145+
146+
case strings.HasSuffix(r.URL.Path, "/snapshot.bundle"):
147+
assert.Equal(t, baseCommit, r.URL.Query().Get("base"))
148+
w.Header().Set("Content-Type", "application/x-git-bundle")
149+
w.Write(bundleData) //nolint:errcheck
150+
151+
default:
152+
http.NotFound(w, r)
153+
}
154+
}))
155+
defer srv.Close()
156+
157+
dstDir := filepath.Join(t.TempDir(), "restored")
158+
restoreCmd := &GitRestoreCmd{
159+
RepoURL: "https://github.com/test/repo",
160+
Directory: dstDir,
161+
}
162+
cli := &CLI{URL: srv.URL}
163+
err = restoreCmd.Run(context.Background(), cli, srv.Client())
164+
assert.NoError(t, err)
165+
166+
content, err := os.ReadFile(filepath.Join(dstDir, "file.txt"))
167+
assert.NoError(t, err)
168+
assert.Equal(t, "v2", string(content))
169+
170+
content, err = os.ReadFile(filepath.Join(dstDir, "new.txt"))
171+
assert.NoError(t, err)
172+
assert.Equal(t, "new", string(content))
173+
}
174+
175+
func TestGitRestoreNoBundle(t *testing.T) {
176+
srcDir := t.TempDir()
177+
initGitRepo(t, srcDir, map[string]string{"file.txt": "v1"})
178+
179+
snapshotData := createTarZst(t, srcDir)
180+
181+
bundleRequested := false
182+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183+
switch {
184+
case strings.HasSuffix(r.URL.Path, "/snapshot.tar.zst"):
185+
w.Header().Set("Content-Type", "application/zstd")
186+
w.Header().Set("X-Cachew-Bundle-Url", "/git/github.com/test/repo/snapshot.bundle?base=abc")
187+
w.Write(snapshotData) //nolint:errcheck
188+
189+
case strings.HasSuffix(r.URL.Path, "/snapshot.bundle"):
190+
bundleRequested = true
191+
http.NotFound(w, r)
192+
193+
default:
194+
http.NotFound(w, r)
195+
}
196+
}))
197+
defer srv.Close()
198+
199+
dstDir := filepath.Join(t.TempDir(), "restored")
200+
restoreCmd := &GitRestoreCmd{
201+
RepoURL: "https://github.com/test/repo",
202+
Directory: dstDir,
203+
NoBundle: true,
204+
}
205+
cli := &CLI{URL: srv.URL}
206+
err := restoreCmd.Run(context.Background(), cli, srv.Client())
207+
assert.NoError(t, err)
208+
assert.False(t, bundleRequested)
209+
210+
content, err := os.ReadFile(filepath.Join(dstDir, "file.txt"))
211+
assert.NoError(t, err)
212+
assert.Equal(t, "v1", string(content))
213+
}
214+
215+
func TestGitRestoreSnapshotNotFound(t *testing.T) {
216+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
217+
http.NotFound(w, r)
218+
}))
219+
defer srv.Close()
220+
221+
dstDir := filepath.Join(t.TempDir(), "restored")
222+
restoreCmd := &GitRestoreCmd{
223+
RepoURL: "https://github.com/test/repo",
224+
Directory: dstDir,
225+
}
226+
cli := &CLI{URL: srv.URL}
227+
err := restoreCmd.Run(context.Background(), cli, srv.Client())
228+
assert.Error(t, err)
229+
assert.Contains(t, err.Error(), "status 404")
230+
}
231+
232+
func TestGitRestoreBundleFailureNonFatal(t *testing.T) {
233+
srcDir := t.TempDir()
234+
initGitRepo(t, srcDir, map[string]string{"file.txt": "v1"})
235+
236+
snapshotData := createTarZst(t, srcDir)
237+
238+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
239+
switch {
240+
case strings.HasSuffix(r.URL.Path, "/snapshot.tar.zst"):
241+
w.Header().Set("Content-Type", "application/zstd")
242+
w.Header().Set("X-Cachew-Bundle-Url", "/git/github.com/test/repo/snapshot.bundle?base=abc")
243+
w.Write(snapshotData) //nolint:errcheck
244+
245+
case strings.HasSuffix(r.URL.Path, "/snapshot.bundle"):
246+
http.Error(w, "internal error", http.StatusInternalServerError)
247+
248+
default:
249+
http.NotFound(w, r)
250+
}
251+
}))
252+
defer srv.Close()
253+
254+
dstDir := filepath.Join(t.TempDir(), "restored")
255+
restoreCmd := &GitRestoreCmd{
256+
RepoURL: "https://github.com/test/repo",
257+
Directory: dstDir,
258+
}
259+
cli := &CLI{URL: srv.URL}
260+
err := restoreCmd.Run(context.Background(), cli, srv.Client())
261+
assert.NoError(t, err)
262+
263+
content, err := os.ReadFile(filepath.Join(dstDir, "file.txt"))
264+
assert.NoError(t, err)
265+
assert.Equal(t, "v1", string(content))
266+
}

0 commit comments

Comments
 (0)