1package replica
2
3import (
4 "errors"
5 "fmt"
6 "io/fs"
7 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11
12 "github.com/go-git/go-git/v5"
13 "github.com/go-git/go-git/v5/plumbing"
14 "github.com/go-git/go-git/v5/plumbing/object"
15)
16
17var ErrPruneKeepMissing = errors.New("replica: an object the prune must keep is not in the local repository")
18
19var pruneHook = func() error { return nil }
20
21func Prune(dir string, keep ...plumbing.Hash) (*git.Repository, error) {
22 r, err := git.PlainOpen(dir)
23 if err != nil {
24 return nil, err
25 }
26 head, err := localHead(r)
27 if err != nil {
28 return nil, err
29 }
30 roots := append([]plumbing.Hash{}, keep...)
31 if !head.IsZero() {
32 roots = append(roots, head)
33 }
34 critical := map[plumbing.Hash]bool{}
35 for _, id := range roots {
36 if err := addFull(r, id, critical); err != nil {
37 return nil, err
38 }
39 }
40 set := map[plumbing.Hash]bool{}
41 for h := range critical {
42 set[h] = true
43 }
44 addCommitChain(r, roots, set)
45 objects := filepath.Join(dir, "objects")
46 for h := range set {
47 p := loosePath(objects, h)
48 written := false
49 if _, err := os.Stat(p); errors.Is(err, fs.ErrNotExist) {
50 obj, err := r.Storer.EncodedObject(plumbing.AnyObject, h)
51 if err != nil {
52 return nil, err
53 }
54 if _, err := r.Storer.SetEncodedObject(obj); err != nil {
55 return nil, err
56 }
57 written = true
58 } else if err != nil {
59 return nil, err
60 }
61 if !written && !critical[h] {
62 continue
63 }
64 if err := syncPath(p); err != nil {
65 return nil, err
66 }
67 if err := syncPath(filepath.Dir(p)); err != nil {
68 return nil, err
69 }
70 }
71 if err := syncPath(objects); err != nil {
72 return nil, err
73 }
74 if err := removeOtherRefs(r); err != nil {
75 return nil, err
76 }
77 if err := removePacks(filepath.Join(objects, "pack")); err != nil {
78 return nil, err
79 }
80 if err := removeLoose(objects, set); err != nil {
81 return nil, err
82 }
83 return git.PlainOpen(dir)
84}
85
86func addFull(r *git.Repository, id plumbing.Hash, set map[plumbing.Hash]bool) error {
87 c, err := object.GetCommit(r.Storer, id)
88 if err != nil {
89 return fmt.Errorf("%w: commit %s: %v", ErrPruneKeepMissing, id, err)
90 }
91 tree, err := c.Tree()
92 if err != nil {
93 return fmt.Errorf("%w: tree of %s: %v", ErrPruneKeepMissing, id, err)
94 }
95 for _, e := range tree.Entries {
96 if _, err := r.Storer.EncodedObject(plumbing.AnyObject, e.Hash); err != nil {
97 return fmt.Errorf("%w: blob of %s: %v", ErrPruneKeepMissing, id, err)
98 }
99 set[e.Hash] = true
100 }
101 set[id] = true
102 set[tree.Hash] = true
103 return nil
104}
105
106func addCommitChain(r *git.Repository, roots []plumbing.Hash, set map[plumbing.Hash]bool) {
107 seen := map[plumbing.Hash]bool{}
108 stack := append([]plumbing.Hash{}, roots...)
109 for len(stack) > 0 {
110 id := stack[len(stack)-1]
111 stack = stack[:len(stack)-1]
112 if seen[id] {
113 continue
114 }
115 seen[id] = true
116 c, err := object.GetCommit(r.Storer, id)
117 if err != nil {
118 continue
119 }
120 set[id] = true
121 stack = append(stack, c.ParentHashes...)
122 }
123}
124
125func loosePath(objects string, h plumbing.Hash) string {
126 s := h.String()
127 return filepath.Join(objects, s[:2], s[2:])
128}
129
130func syncPath(p string) error {
131 f, err := os.Open(p)
132 if err != nil {
133 return err
134 }
135 defer f.Close()
136 return f.Sync()
137}
138
139func removeOtherRefs(r *git.Repository) error {
140 iter, err := r.Storer.IterReferences()
141 if err != nil {
142 return err
143 }
144 var names []plumbing.ReferenceName
145 if err := iter.ForEach(func(ref *plumbing.Reference) error {
146 if ref.Name() != Branch && ref.Name() != plumbing.HEAD {
147 names = append(names, ref.Name())
148 }
149 return nil
150 }); err != nil {
151 return err
152 }
153 for _, n := range names {
154 if err := r.Storer.RemoveReference(n); err != nil {
155 return err
156 }
157 }
158 return nil
159}
160
161func removePacks(dir string) error {
162 entries, err := os.ReadDir(dir)
163 if errors.Is(err, fs.ErrNotExist) {
164 return nil
165 }
166 if err != nil {
167 return err
168 }
169 var packs, rest []string
170 for _, e := range entries {
171 if strings.HasSuffix(e.Name(), ".pack") {
172 packs = append(packs, e.Name())
173 } else {
174 rest = append(rest, e.Name())
175 }
176 }
177 sort.Strings(packs)
178 sort.Strings(rest)
179 for _, name := range append(packs, rest...) {
180 if err := pruneHook(); err != nil {
181 return err
182 }
183 if err := os.Remove(filepath.Join(dir, name)); err != nil {
184 return err
185 }
186 }
187 return nil
188}
189
190func removeLoose(objects string, keep map[plumbing.Hash]bool) error {
191 dirs, err := os.ReadDir(objects)
192 if err != nil {
193 return err
194 }
195 for _, d := range dirs {
196 if !d.IsDir() || len(d.Name()) != 2 {
197 continue
198 }
199 sub := filepath.Join(objects, d.Name())
200 files, err := os.ReadDir(sub)
201 if err != nil {
202 return err
203 }
204 left := 0
205 for _, f := range files {
206 h := plumbing.NewHash(d.Name() + f.Name())
207 if len(d.Name()+f.Name()) != 40 || h.IsZero() || keep[h] {
208 left++
209 continue
210 }
211 if err := pruneHook(); err != nil {
212 return err
213 }
214 if err := os.Remove(filepath.Join(sub, f.Name())); err != nil {
215 return err
216 }
217 }
218 if left == 0 {
219 _ = os.Remove(sub)
220 }
221 }
222 return nil
223}