tenseleyflow/shithub / 04743f1

Browse files

S11: git plumbing helpers (hash-object → write-tree → commit-tree → update-ref)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
04743f11b05b5644d58e576e4165eb99e2a93210
Parents
1aa34dc
Tree
6ee4a74

2 changed files

StatusFile+-
A internal/repos/git/plumbing.go 193 0
A internal/repos/git/plumbing_test.go 113 0
internal/repos/git/plumbing.goadded
@@ -0,0 +1,193 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package git wraps the git plumbing commands we need to build the
4
+// optional initial commit on a freshly-init'd bare repo. Plumbing-only,
5
+// no working tree: hash-object → update-index (with GIT_INDEX_FILE) →
6
+// write-tree → commit-tree → update-ref. This is what `git` itself does
7
+// internally; we drive it from outside via short-lived subprocesses so
8
+// we don't need to vendor a Go-native git implementation.
9
+//
10
+// Every shell-out is constrained to a caller-supplied gitDir we control
11
+// (`storage.RepoFS.RepoPath` produces it from a strict path whitelist).
12
+// `git -C` is used instead of changing the process working directory.
13
+package git
14
+
15
+import (
16
+	"bytes"
17
+	"context"
18
+	"errors"
19
+	"fmt"
20
+	"os"
21
+	"os/exec"
22
+	"strings"
23
+	"time"
24
+)
25
+
26
+// FileEntry is one file destined for the initial commit. Path is the
27
+// repository-relative slash-separated path (no leading slash). Body is
28
+// the raw bytes; we hash whatever we're given without normalization.
29
+type FileEntry struct {
30
+	Path string
31
+	Body []byte
32
+}
33
+
34
+// InitialCommit describes the single commit produced for a freshly
35
+// initialized repo whose owner ticked "initialize with README/license/
36
+// .gitignore" on the create form. Everything except Files defaults to
37
+// sensible values; callers must always set GitDir + Author* + Files.
38
+type InitialCommit struct {
39
+	GitDir      string // absolute bare-repo path (must already exist + be inited)
40
+	AuthorName  string
41
+	AuthorEmail string
42
+	Message     string    // defaults to "Initial commit"
43
+	Branch      string    // defaults to "trunk"
44
+	When        time.Time // defaults to time.Now()
45
+	Files       []FileEntry
46
+}
47
+
48
+// Build runs the full plumbing sequence and returns the new commit OID.
49
+// On error the bare repo may have orphan blobs/trees written; the
50
+// caller is expected to RemoveAll the bare repo dir on failure.
51
+func (ic InitialCommit) Build(ctx context.Context) (string, error) {
52
+	if ic.GitDir == "" {
53
+		return "", errors.New("git plumbing: GitDir is required")
54
+	}
55
+	if ic.AuthorName == "" || ic.AuthorEmail == "" {
56
+		return "", errors.New("git plumbing: author name/email are required")
57
+	}
58
+	if len(ic.Files) == 0 {
59
+		return "", errors.New("git plumbing: at least one file is required")
60
+	}
61
+	if ic.Message == "" {
62
+		ic.Message = "Initial commit"
63
+	}
64
+	if ic.Branch == "" {
65
+		ic.Branch = "trunk"
66
+	}
67
+	if ic.When.IsZero() {
68
+		ic.When = time.Now()
69
+	}
70
+
71
+	indexFile, err := os.CreateTemp("", "shithub-index-*")
72
+	if err != nil {
73
+		return "", fmt.Errorf("git plumbing: temp index: %w", err)
74
+	}
75
+	indexPath := indexFile.Name()
76
+	_ = indexFile.Close()
77
+	defer func() { _ = os.Remove(indexPath) }()
78
+	// git refuses to use an empty file as an index — delete now and let
79
+	// `update-index` recreate it on first use.
80
+	_ = os.Remove(indexPath)
81
+
82
+	for _, f := range ic.Files {
83
+		oid, err := ic.hashObject(ctx, f.Body)
84
+		if err != nil {
85
+			return "", fmt.Errorf("hash-object %s: %w", f.Path, err)
86
+		}
87
+		if err := ic.updateIndex(ctx, indexPath, oid, f.Path); err != nil {
88
+			return "", fmt.Errorf("update-index %s: %w", f.Path, err)
89
+		}
90
+	}
91
+
92
+	tree, err := ic.writeTree(ctx, indexPath)
93
+	if err != nil {
94
+		return "", fmt.Errorf("write-tree: %w", err)
95
+	}
96
+	commit, err := ic.commitTree(ctx, tree)
97
+	if err != nil {
98
+		return "", fmt.Errorf("commit-tree: %w", err)
99
+	}
100
+	if err := ic.updateRef(ctx, commit); err != nil {
101
+		return "", fmt.Errorf("update-ref: %w", err)
102
+	}
103
+	return commit, nil
104
+}
105
+
106
+// hashObject pipes body through `git hash-object -w --stdin` and
107
+// returns the resulting OID.
108
+func (ic InitialCommit) hashObject(ctx context.Context, body []byte) (string, error) {
109
+	//nolint:gosec // G204: gitDir is constrained by storage.RepoFS path validation.
110
+	cmd := exec.CommandContext(ctx, "git", "-C", ic.GitDir, "hash-object", "-w", "--stdin")
111
+	cmd.Stdin = bytes.NewReader(body)
112
+	out, err := cmd.Output()
113
+	if err != nil {
114
+		return "", wrapExecErr(err)
115
+	}
116
+	return strings.TrimSpace(string(out)), nil
117
+}
118
+
119
+// updateIndex stages a blob at path under the temp index pointed at by
120
+// indexPath. Mode is fixed at 100644 (regular file); the spec doesn't
121
+// emit symlinks or executables for the initial commit.
122
+func (ic InitialCommit) updateIndex(ctx context.Context, indexPath, oid, path string) error {
123
+	cacheinfo := fmt.Sprintf("100644,%s,%s", oid, path)
124
+	//nolint:gosec // G204: gitDir + path are validated upstream.
125
+	cmd := exec.CommandContext(ctx, "git", "-C", ic.GitDir, "update-index", "--add", "--cacheinfo", cacheinfo)
126
+	cmd.Env = append(os.Environ(), "GIT_INDEX_FILE="+indexPath)
127
+	if out, err := cmd.CombinedOutput(); err != nil {
128
+		return fmt.Errorf("%w: %s", wrapExecErr(err), strings.TrimSpace(string(out)))
129
+	}
130
+	return nil
131
+}
132
+
133
+// writeTree turns the staged index into a tree object and returns its OID.
134
+func (ic InitialCommit) writeTree(ctx context.Context, indexPath string) (string, error) {
135
+	//nolint:gosec // G204: gitDir validated upstream.
136
+	cmd := exec.CommandContext(ctx, "git", "-C", ic.GitDir, "write-tree")
137
+	cmd.Env = append(os.Environ(), "GIT_INDEX_FILE="+indexPath)
138
+	out, err := cmd.Output()
139
+	if err != nil {
140
+		return "", wrapExecErr(err)
141
+	}
142
+	return strings.TrimSpace(string(out)), nil
143
+}
144
+
145
+// commitTree builds a commit object pointing at tree, with no parent.
146
+// Author + committer are both populated from ic.Author*; ic.When fixes
147
+// both timestamps so the test suite gets deterministic OIDs.
148
+func (ic InitialCommit) commitTree(ctx context.Context, tree string) (string, error) {
149
+	//nolint:gosec // G204: tree is git's stdout (40-char OID); gitDir validated.
150
+	cmd := exec.CommandContext(ctx, "git", "-C", ic.GitDir, "commit-tree", tree, "-m", ic.Message)
151
+	stamp := ic.When.Format(time.RFC3339)
152
+	cmd.Env = append(os.Environ(),
153
+		"GIT_AUTHOR_NAME="+ic.AuthorName,
154
+		"GIT_AUTHOR_EMAIL="+ic.AuthorEmail,
155
+		"GIT_AUTHOR_DATE="+stamp,
156
+		"GIT_COMMITTER_NAME="+ic.AuthorName,
157
+		"GIT_COMMITTER_EMAIL="+ic.AuthorEmail,
158
+		"GIT_COMMITTER_DATE="+stamp,
159
+	)
160
+	out, err := cmd.Output()
161
+	if err != nil {
162
+		return "", wrapExecErr(err)
163
+	}
164
+	return strings.TrimSpace(string(out)), nil
165
+}
166
+
167
+// updateRef points refs/heads/<Branch> at commit. After this call the
168
+// bare repo's HEAD (a symbolic ref to refs/heads/<Branch>) finally
169
+// resolves to a real commit.
170
+func (ic InitialCommit) updateRef(ctx context.Context, commit string) error {
171
+	ref := "refs/heads/" + ic.Branch
172
+	//nolint:gosec // G204: ref is constructed from a non-empty branch name we set.
173
+	cmd := exec.CommandContext(ctx, "git", "-C", ic.GitDir, "update-ref", ref, commit)
174
+	if out, err := cmd.CombinedOutput(); err != nil {
175
+		return fmt.Errorf("%w: %s", wrapExecErr(err), strings.TrimSpace(string(out)))
176
+	}
177
+	return nil
178
+}
179
+
180
+// wrapExecErr unwraps an *exec.ExitError to expose stderr in the
181
+// returned message; on other errors it passes through. Useful when the
182
+// caller logs %w errors and we want the actual git stderr in the line.
183
+func wrapExecErr(err error) error {
184
+	var ee *exec.ExitError
185
+	if errors.As(err, &ee) {
186
+		stderr := strings.TrimSpace(string(ee.Stderr))
187
+		if stderr != "" {
188
+			return fmt.Errorf("%w: %s", err, stderr)
189
+		}
190
+	}
191
+	return err
192
+}
193
+
internal/repos/git/plumbing_test.goadded
@@ -0,0 +1,113 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package git_test
4
+
5
+import (
6
+	"context"
7
+	"os/exec"
8
+	"path/filepath"
9
+	"strings"
10
+	"testing"
11
+	"time"
12
+
13
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
14
+)
15
+
16
+// initBare creates a bare repo at dir/<name>.git and returns the path.
17
+// Tests that need a bare repo to operate against use this rather than
18
+// reaching into the storage package (one less inter-package dependency
19
+// in the test).
20
+func initBare(t *testing.T) string {
21
+	t.Helper()
22
+	root := t.TempDir()
23
+	gitDir := filepath.Join(root, "x.git")
24
+	if out, err := exec.Command("git", "init", "--bare", "--initial-branch=trunk", gitDir).CombinedOutput(); err != nil {
25
+		t.Fatalf("git init: %v\n%s", err, out)
26
+	}
27
+	return gitDir
28
+}
29
+
30
+func TestInitialCommit_BuildSingleCommitWithFiles(t *testing.T) {
31
+	t.Parallel()
32
+	gitDir := initBare(t)
33
+
34
+	when := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
35
+	ic := repogit.InitialCommit{
36
+		GitDir:      gitDir,
37
+		AuthorName:  "Alice Anderson",
38
+		AuthorEmail: "alice@example.com",
39
+		Message:     "Initial commit",
40
+		Branch:      "trunk",
41
+		When:        when,
42
+		Files: []repogit.FileEntry{
43
+			{Path: "README.md", Body: []byte("# foo\n\nhello\n")},
44
+			{Path: "LICENSE", Body: []byte("MIT-ish\n")},
45
+			{Path: ".gitignore", Body: []byte("*.tmp\n")},
46
+		},
47
+	}
48
+	commit, err := ic.Build(context.Background())
49
+	if err != nil {
50
+		t.Fatalf("Build: %v", err)
51
+	}
52
+	if len(commit) != 40 {
53
+		t.Fatalf("commit OID looks wrong: %q", commit)
54
+	}
55
+
56
+	// HEAD must now resolve to the commit via refs/heads/trunk.
57
+	out, err := exec.Command("git", "-C", gitDir, "rev-parse", "refs/heads/trunk").CombinedOutput()
58
+	if err != nil {
59
+		t.Fatalf("rev-parse: %v\n%s", err, out)
60
+	}
61
+	if got := strings.TrimSpace(string(out)); got != commit {
62
+		t.Fatalf("rev-parse %q != commit %q", got, commit)
63
+	}
64
+
65
+	// Single commit, no parent.
66
+	out, err = exec.Command("git", "-C", gitDir, "rev-list", "--count", "trunk").CombinedOutput()
67
+	if err != nil {
68
+		t.Fatalf("rev-list: %v\n%s", err, out)
69
+	}
70
+	if got := strings.TrimSpace(string(out)); got != "1" {
71
+		t.Fatalf("rev-list count = %q, want 1", got)
72
+	}
73
+
74
+	// Tree contents are the three files at the right paths.
75
+	out, err = exec.Command("git", "-C", gitDir, "ls-tree", "--name-only", "trunk").CombinedOutput()
76
+	if err != nil {
77
+		t.Fatalf("ls-tree: %v\n%s", err, out)
78
+	}
79
+	got := strings.TrimSpace(string(out))
80
+	wantSet := map[string]bool{"README.md": true, "LICENSE": true, ".gitignore": true}
81
+	for _, name := range strings.Split(got, "\n") {
82
+		if !wantSet[name] {
83
+			t.Errorf("unexpected entry %q in tree (got=%q)", name, got)
84
+		}
85
+		delete(wantSet, name)
86
+	}
87
+	if len(wantSet) != 0 {
88
+		t.Errorf("missing tree entries: %v", wantSet)
89
+	}
90
+
91
+	// Author identity is what we passed in.
92
+	out, err = exec.Command("git", "-C", gitDir, "log", "-1", "--format=%an <%ae>", "trunk").CombinedOutput()
93
+	if err != nil {
94
+		t.Fatalf("log: %v\n%s", err, out)
95
+	}
96
+	if want := "Alice Anderson <alice@example.com>"; strings.TrimSpace(string(out)) != want {
97
+		t.Errorf("author = %q, want %q", strings.TrimSpace(string(out)), want)
98
+	}
99
+}
100
+
101
+func TestInitialCommit_RejectsEmptyInputs(t *testing.T) {
102
+	t.Parallel()
103
+	for _, ic := range []repogit.InitialCommit{
104
+		{},
105
+		{GitDir: "/tmp"},
106
+		{GitDir: "/tmp", AuthorName: "x"},
107
+		{GitDir: "/tmp", AuthorName: "x", AuthorEmail: "y"},
108
+	} {
109
+		if _, err := ic.Build(context.Background()); err == nil {
110
+			t.Errorf("expected error for incomplete InitialCommit %+v", ic)
111
+		}
112
+	}
113
+}