Skip to content

Commit 0ae702a

Browse files
Preserve squashed commits references to avoid getting deleted by GC (#4)
* Preserve squashed commits references to avoid getting deleted by GC * Store commit message in metadata
1 parent e6117ec commit 0ae702a

File tree

10 files changed

+400
-15
lines changed

10 files changed

+400
-15
lines changed

internal/git/archive.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package git
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"strings"
7+
)
8+
9+
const (
10+
ArchiveRefPrefix = "refs/squash-archive/"
11+
)
12+
13+
func PreservationRefName(rootFullSHA, childFullSHA string) string {
14+
return ArchiveRefPrefix + rootFullSHA + "/" + childFullSHA
15+
}
16+
17+
func FullHash(repoPath, ref string) (string, error) {
18+
cmd := exec.Command("git", "rev-parse", ref)
19+
if repoPath != "" {
20+
cmd.Dir = repoPath
21+
}
22+
output, err := cmd.Output()
23+
if err != nil {
24+
return "", fmt.Errorf("git rev-parse %s: %w", ref, err)
25+
}
26+
return strings.TrimSpace(string(output)), nil
27+
}
28+
29+
func CreatePreservationRefs(repoPath, rootFullSHA string, childFullSHAs []string) error {
30+
for _, child := range childFullSHAs {
31+
refName := PreservationRefName(rootFullSHA, child)
32+
cmd := exec.Command("git", "update-ref", refName, child)
33+
if repoPath != "" {
34+
cmd.Dir = repoPath
35+
}
36+
if out, err := cmd.CombinedOutput(); err != nil {
37+
return fmt.Errorf("git update-ref %s %s: %w: %s", refName, child, err, string(out))
38+
}
39+
}
40+
return nil
41+
}
42+
43+
func PreservationRefsExist(repoPath, rootFullSHA string, childFullSHAs []string) (bool, error) {
44+
for _, child := range childFullSHAs {
45+
refName := PreservationRefName(rootFullSHA, child)
46+
cmd := exec.Command("git", "show-ref", "--verify", "--quiet", refName)
47+
if repoPath != "" {
48+
cmd.Dir = repoPath
49+
}
50+
if err := cmd.Run(); err != nil {
51+
return false, nil
52+
}
53+
}
54+
return true, nil
55+
}

internal/git/archive_test.go

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package git
2+
3+
import (
4+
"os/exec"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestFullHash(t *testing.T) {
10+
requireGit(t)
11+
repoPath, cleanup := initTempRepo(t)
12+
defer cleanup()
13+
14+
shortHash := makeCommit(t, repoPath, "test commit")
15+
16+
fullHash, err := FullHash(repoPath, shortHash)
17+
if err != nil {
18+
t.Fatalf("FullHash: %v", err)
19+
}
20+
21+
if len(fullHash) != 40 {
22+
t.Errorf("FullHash: expected 40 chars, got %d (%q)", len(fullHash), fullHash)
23+
}
24+
25+
if !strings.HasPrefix(fullHash, shortHash) {
26+
t.Errorf("FullHash: %q is not a prefix of full hash %q", shortHash, fullHash)
27+
}
28+
29+
headFull, err := FullHash(repoPath, "HEAD")
30+
if err != nil {
31+
t.Fatalf("FullHash(HEAD): %v", err)
32+
}
33+
if headFull != fullHash {
34+
t.Errorf("FullHash(HEAD)=%q, want %q", headFull, fullHash)
35+
}
36+
}
37+
38+
func TestFullHash_InvalidRef(t *testing.T) {
39+
requireGit(t)
40+
repoPath, cleanup := initTempRepo(t)
41+
defer cleanup()
42+
43+
makeCommit(t, repoPath, "initial")
44+
45+
_, err := FullHash(repoPath, "nonexistent-ref")
46+
if err == nil {
47+
t.Error("FullHash(nonexistent): expected error, got nil")
48+
}
49+
}
50+
51+
func TestPreservationRefName(t *testing.T) {
52+
root := "a1b2c3d4e5f6789012345678901234567890abcd"
53+
child := "b2c3d4e5f6789012345678901234567890abcde"
54+
55+
refName := PreservationRefName(root, child)
56+
expected := "refs/squash-archive/" + root + "/" + child
57+
58+
if refName != expected {
59+
t.Errorf("PreservationRefName=%q, want %q", refName, expected)
60+
}
61+
}
62+
63+
func TestCreatePreservationRefs(t *testing.T) {
64+
requireGit(t)
65+
repoPath, cleanup := initTempRepo(t)
66+
defer cleanup()
67+
68+
hash1 := makeCommit(t, repoPath, "commit 1")
69+
hash2 := makeCommitUnique(t, repoPath, "commit 2", "2")
70+
hash3 := makeCommitUnique(t, repoPath, "commit 3", "3")
71+
72+
fullRoot, _ := FullHash(repoPath, hash1)
73+
fullChild1, _ := FullHash(repoPath, hash2)
74+
fullChild2, _ := FullHash(repoPath, hash3)
75+
76+
err := CreatePreservationRefs(repoPath, fullRoot, []string{fullChild1, fullChild2})
77+
if err != nil {
78+
t.Fatalf("CreatePreservationRefs: %v", err)
79+
}
80+
81+
ref1 := PreservationRefName(fullRoot, fullChild1)
82+
ref2 := PreservationRefName(fullRoot, fullChild2)
83+
84+
for _, ref := range []string{ref1, ref2} {
85+
cmd := exec.Command("git", "show-ref", "--verify", ref)
86+
cmd.Dir = repoPath
87+
if err := cmd.Run(); err != nil {
88+
t.Errorf("ref %s does not exist", ref)
89+
}
90+
}
91+
92+
for _, tc := range []struct {
93+
ref string
94+
expected string
95+
}{
96+
{ref1, fullChild1},
97+
{ref2, fullChild2},
98+
} {
99+
cmd := exec.Command("git", "rev-parse", tc.ref)
100+
cmd.Dir = repoPath
101+
out, err := cmd.Output()
102+
if err != nil {
103+
t.Fatalf("rev-parse %s: %v", tc.ref, err)
104+
}
105+
got := strings.TrimSpace(string(out))
106+
if got != tc.expected {
107+
t.Errorf("ref %s points to %q, want %q", tc.ref, got, tc.expected)
108+
}
109+
}
110+
}
111+
112+
func TestCreatePreservationRefs_Idempotent(t *testing.T) {
113+
requireGit(t)
114+
repoPath, cleanup := initTempRepo(t)
115+
defer cleanup()
116+
117+
hash1 := makeCommit(t, repoPath, "commit 1")
118+
hash2 := makeCommitUnique(t, repoPath, "commit 2", "2")
119+
120+
fullRoot, _ := FullHash(repoPath, hash1)
121+
fullChild, _ := FullHash(repoPath, hash2)
122+
123+
if err := CreatePreservationRefs(repoPath, fullRoot, []string{fullChild}); err != nil {
124+
t.Fatalf("CreatePreservationRefs (1st): %v", err)
125+
}
126+
if err := CreatePreservationRefs(repoPath, fullRoot, []string{fullChild}); err != nil {
127+
t.Fatalf("CreatePreservationRefs (2nd): %v", err)
128+
}
129+
}
130+
131+
func TestPreservationRefsExist(t *testing.T) {
132+
requireGit(t)
133+
repoPath, cleanup := initTempRepo(t)
134+
defer cleanup()
135+
136+
hash1 := makeCommit(t, repoPath, "commit 1")
137+
hash2 := makeCommitUnique(t, repoPath, "commit 2", "2")
138+
139+
fullRoot, _ := FullHash(repoPath, hash1)
140+
fullChild, _ := FullHash(repoPath, hash2)
141+
142+
exists, err := PreservationRefsExist(repoPath, fullRoot, []string{fullChild})
143+
if err != nil {
144+
t.Fatalf("PreservationRefsExist: %v", err)
145+
}
146+
if exists {
147+
t.Error("PreservationRefsExist: expected false before creation")
148+
}
149+
150+
if err := CreatePreservationRefs(repoPath, fullRoot, []string{fullChild}); err != nil {
151+
t.Fatalf("CreatePreservationRefs: %v", err)
152+
}
153+
154+
exists, err = PreservationRefsExist(repoPath, fullRoot, []string{fullChild})
155+
if err != nil {
156+
t.Fatalf("PreservationRefsExist: %v", err)
157+
}
158+
if !exists {
159+
t.Error("PreservationRefsExist: expected true after creation")
160+
}
161+
}
162+
163+
func TestWriteMetadata_CreatesPreservationRefs(t *testing.T) {
164+
requireGit(t)
165+
repoPath, cleanup := initTempRepo(t)
166+
defer cleanup()
167+
168+
makeCommit(t, repoPath, "base")
169+
child1Short := makeCommitUnique(t, repoPath, "child1", "c1")
170+
child2Short := makeCommitUnique(t, repoPath, "child2", "c2")
171+
rootShort := makeCommitUnique(t, repoPath, "squash", "sq")
172+
173+
baseShort := child1Short
174+
175+
children := []string{child1Short, child2Short}
176+
err := WriteMetadata(repoPath, rootShort, baseShort, children, "test")
177+
if err != nil {
178+
t.Fatalf("WriteMetadata: %v", err)
179+
}
180+
181+
rootFull, _ := FullHash(repoPath, rootShort)
182+
child1Full, _ := FullHash(repoPath, child1Short)
183+
child2Full, _ := FullHash(repoPath, child2Short)
184+
185+
exists, err := PreservationRefsExist(repoPath, rootFull, []string{child1Full, child2Full})
186+
if err != nil {
187+
t.Fatalf("PreservationRefsExist: %v", err)
188+
}
189+
if !exists {
190+
t.Error("WriteMetadata did not create preservation refs")
191+
}
192+
}
193+
194+
func makeCommitUnique(t *testing.T, repoPath, msg, uniqueContent string) string {
195+
t.Helper()
196+
197+
cmd := exec.Command("git", "config", "commit.gpgsign", "false")
198+
cmd.Dir = repoPath
199+
cmd.Run()
200+
201+
cmd = exec.Command("git", "rev-parse", "--short", "HEAD")
202+
cmd.Dir = repoPath
203+
out, _ := cmd.Output()
204+
currentHead := strings.TrimSpace(string(out))
205+
206+
f := repoPath + "/f.txt"
207+
cmd = exec.Command("bash", "-c", "echo '"+uniqueContent+"' >> "+f)
208+
cmd.Dir = repoPath
209+
if err := cmd.Run(); err != nil {
210+
t.Fatalf("append to file: %v", err)
211+
}
212+
213+
cmd = exec.Command("git", "add", "f.txt")
214+
cmd.Dir = repoPath
215+
if out, err := cmd.CombinedOutput(); err != nil {
216+
t.Fatalf("git add: %v %s", err, out)
217+
}
218+
219+
cmd = exec.Command("git", "commit", "-m", msg)
220+
cmd.Dir = repoPath
221+
if out, err := cmd.CombinedOutput(); err != nil {
222+
if strings.Contains(string(out), "nothing to commit") {
223+
return currentHead
224+
}
225+
t.Fatalf("git commit: %v %s", err, out)
226+
}
227+
228+
cmd = exec.Command("git", "rev-parse", "--short", "HEAD")
229+
cmd.Dir = repoPath
230+
out, err := cmd.Output()
231+
if err != nil {
232+
t.Fatalf("git rev-parse: %v", err)
233+
}
234+
return strings.TrimSpace(string(out))
235+
}

internal/git/notes.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,37 @@ func (nr *NotesReader) CommitExists(commitHash string) bool {
9090
return err == nil
9191
}
9292

93+
func getCommitMessage(repoPath, ref string) (string, error) {
94+
cmd := exec.Command("git", "log", "-1", "--format=%s", ref)
95+
if repoPath != "" {
96+
cmd.Dir = repoPath
97+
}
98+
output, err := cmd.Output()
99+
if err != nil {
100+
return "", fmt.Errorf("git log: %w", err)
101+
}
102+
return strings.TrimSpace(string(output)), nil
103+
}
104+
93105
func WriteMetadata(repoPath, rootShortHash, baseShortHash string, children []string, strategy string) error {
94106
if len(children) == 0 {
95107
return fmt.Errorf("at least one child commit required")
96108
}
109+
110+
rootMessage, _ := getCommitMessage(repoPath, rootShortHash)
111+
97112
childCommits := make([]metadata.ChildCommit, len(children))
98113
for i, h := range children {
99-
childCommits[i] = metadata.ChildCommit{Hash: h, Order: i + 1}
114+
msg, _ := getCommitMessage(repoPath, h)
115+
childCommits[i] = metadata.ChildCommit{Hash: h, Order: i + 1, Message: msg}
100116
}
117+
101118
meta := &metadata.SquashMetadata{
102119
Spec: metadata.SpecVersionV1,
103120
Type: metadata.TypeSquash,
104121
Root: rootShortHash,
105122
Base: baseShortHash,
123+
Message: rootMessage,
106124
Children: childCommits,
107125
CreatedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z07:00"),
108126
Strategy: strategy,
@@ -117,5 +135,22 @@ func WriteMetadata(repoPath, rootShortHash, baseShortHash string, children []str
117135
if out, err := cmd.CombinedOutput(); err != nil {
118136
return fmt.Errorf("git notes add: %w: %s", err, string(out))
119137
}
138+
139+
rootFull, err := FullHash(repoPath, rootShortHash)
140+
if err != nil {
141+
return fmt.Errorf("resolve root full hash: %w", err)
142+
}
143+
childFulls := make([]string, len(children))
144+
for i, c := range children {
145+
full, err := FullHash(repoPath, c)
146+
if err != nil {
147+
return fmt.Errorf("resolve child %s full hash: %w", c, err)
148+
}
149+
childFulls[i] = full
150+
}
151+
if err := CreatePreservationRefs(repoPath, rootFull, childFulls); err != nil {
152+
return fmt.Errorf("create preservation refs: %w", err)
153+
}
154+
120155
return nil
121156
}

internal/git/notes_test.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ func TestWriteMetadata_ReadMetadata_RoundTrip(t *testing.T) {
4949
if len(meta.Children) != 1 || meta.Children[0].Hash != hash {
5050
t.Errorf("Children: %+v", meta.Children)
5151
}
52+
if meta.Message != "initial" {
53+
t.Errorf("Message=%q, want %q", meta.Message, "initial")
54+
}
55+
if meta.Children[0].Message != "initial" {
56+
t.Errorf("Children[0].Message=%q, want %q", meta.Children[0].Message, "initial")
57+
}
5258
}
5359

5460
func TestNotesReader_CommitExists(t *testing.T) {
@@ -107,8 +113,12 @@ func initTempRepo(t *testing.T) (string, func()) {
107113

108114
func makeCommit(t *testing.T, repoPath, msg string) string {
109115
t.Helper()
110-
// Set user so commit succeeds
111-
for _, args := range [][]string{{"git", "config", "user.email", "test@test"}, {"git", "config", "user.name", "Test"}} {
116+
// Set user so commit succeeds and disable GPG signing
117+
for _, args := range [][]string{
118+
{"git", "config", "user.email", "test@test"},
119+
{"git", "config", "user.name", "Test"},
120+
{"git", "config", "commit.gpgsign", "false"},
121+
} {
112122
cmd := exec.Command(args[0], args[1:]...)
113123
cmd.Dir = repoPath
114124
cmd.Run()

0 commit comments

Comments
 (0)