Skip to content

Commit 860b070

Browse files
committed
conformance: add upstream Git test harness with t2008 gate
Adds a regression gate for gogit's path-restore checkout by running upstream Git's own test framework against the gogit binary. - Implements just enough gogit subcommands for t/t2008-checkout-subdir.sh to run end-to-end: init, add, commit -m (with GIT_*_NAME/EMAIL/DATE env honoured), checkout HEAD -- <pathspec> (with worktree-escape rejection and directory expansion), version --build-options. - Root-level --exec-path and --version flags, plus os.Exit(1) on bare invocation; required by test-lib.sh's bootstrap and sanity checks. - All repo opens use PlainOpenWithOptions(DetectDotGit) so commands work from subdirectories (t2008 cd's into dir1). - conformance/run.sh fetches upstream tests via \$GIT_SRC override or a shallow clone of github.com/git/git default branch (no SHA pin -- upstream drift surfaces immediately). Synthesises a minimal GIT_BUILD_DIR with GIT-BUILD-OPTIONS and a test-tool stub so test-lib can bootstrap without an actual upstream build. Supports per-script --run= selection and \$GO_GIT_REF for testing against an in-flight go-git ref (with go.mod/go.sum snapshot/restore). - Interactive runs preserve upstream's coloured output by skipping the per-test TAP tee when stdout is a TTY; CI still captures TAP for artifact upload. - make conformance target plus CI matrix job across ubuntu-latest, macos-latest, windows-latest. Assisted-by: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Paulo Gomes <paulo@entire.io>
1 parent ba19e5e commit 860b070

19 files changed

Lines changed: 1051 additions & 3 deletions

.github/workflows/build.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ on:
88
- main
99

1010
workflow_dispatch:
11+
inputs:
12+
go_git_ref:
13+
description: 'go-git ref (commit SHA, tag, or branch) to build gogit against. Empty = go.mod default.'
14+
required: false
15+
default: ''
1116

1217
permissions:
1318
contents: none
@@ -34,3 +39,30 @@ jobs:
3439

3540
- name: Validate
3641
run: make validate
42+
43+
conformance:
44+
strategy:
45+
fail-fast: false
46+
matrix:
47+
platform: [ubuntu-latest, macos-latest, windows-latest]
48+
49+
runs-on: ${{ matrix.platform }}
50+
steps:
51+
- name: Checkout
52+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
53+
- name: Setup Go
54+
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
55+
with:
56+
go-version: stable
57+
- name: Run conformance
58+
shell: bash
59+
env:
60+
GO_GIT_REF: ${{ inputs.go_git_ref }}
61+
run: make conformance
62+
- name: Upload TAP results
63+
if: failure()
64+
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
65+
with:
66+
name: conformance-tap-${{ matrix.platform }}
67+
path: conformance/.cache/results/
68+
if-no-files-found: ignore

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
build/
2+
conformance/.cache/

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@ ifneq ($(shell git status --porcelain --untracked-files=no),)
2424
@git --no-pager diff
2525
@exit 1
2626
endif
27+
28+
.PHONY: conformance
29+
conformance:
30+
./conformance/run.sh

cmd/gogit/add.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/go-git/go-git/v6"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
func init() {
11+
rootCmd.AddCommand(addCmd)
12+
}
13+
14+
var addCmd = &cobra.Command{
15+
Use: "add <pathspec>...",
16+
Short: "Add file contents to the index",
17+
Args: cobra.MinimumNArgs(1),
18+
RunE: func(cmd *cobra.Command, args []string) error {
19+
r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
20+
if err != nil {
21+
return fmt.Errorf("failed to open repository: %w", err)
22+
}
23+
24+
w, err := r.Worktree()
25+
if err != nil {
26+
return fmt.Errorf("failed to open worktree: %w", err)
27+
}
28+
29+
for _, path := range args {
30+
if _, err := w.Add(path); err != nil {
31+
return fmt.Errorf("failed to add %s: %w", path, err)
32+
}
33+
}
34+
35+
return nil
36+
},
37+
DisableFlagsInUseLine: true,
38+
}

cmd/gogit/add_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestAddSingleFile(t *testing.T) {
10+
t.Parallel()
11+
repo := t.TempDir()
12+
13+
if _, _, err := runGogit(t, repo, "init"); err != nil {
14+
t.Fatalf("init: %v", err)
15+
}
16+
17+
if err := os.WriteFile(filepath.Join(repo, "file0"), []byte("base\n"), 0o644); err != nil {
18+
t.Fatal(err)
19+
}
20+
21+
if _, _, err := runGogit(t, repo, "add", "file0"); err != nil {
22+
t.Fatalf("add: %v", err)
23+
}
24+
}
25+
26+
func TestAddMultiplePaths(t *testing.T) {
27+
t.Parallel()
28+
repo := t.TempDir()
29+
30+
if _, _, err := runGogit(t, repo, "init"); err != nil {
31+
t.Fatalf("init: %v", err)
32+
}
33+
34+
if err := os.MkdirAll(filepath.Join(repo, "dir1"), 0o755); err != nil {
35+
t.Fatal(err)
36+
}
37+
38+
if err := os.WriteFile(filepath.Join(repo, "file0"), []byte("a\n"), 0o644); err != nil {
39+
t.Fatal(err)
40+
}
41+
42+
if err := os.WriteFile(filepath.Join(repo, "dir1", "file1"), []byte("b\n"), 0o644); err != nil {
43+
t.Fatal(err)
44+
}
45+
46+
if _, _, err := runGogit(t, repo, "add", "file0", "dir1/file1"); err != nil {
47+
t.Fatalf("add: %v", err)
48+
}
49+
}

cmd/gogit/checkout.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/go-git/go-git/v6"
10+
"github.com/go-git/go-git/v6/plumbing/object"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func init() {
15+
rootCmd.AddCommand(checkoutCmd)
16+
}
17+
18+
var checkoutCmd = &cobra.Command{
19+
Use: "checkout <tree-ish> -- <pathspec>...",
20+
Short: "Restore working tree files from a tree",
21+
Args: cobra.MinimumNArgs(2),
22+
RunE: func(cmd *cobra.Command, args []string) error {
23+
treeish, paths, err := splitCheckoutArgs(cmd, args)
24+
if err != nil {
25+
return err
26+
}
27+
28+
if treeish != "HEAD" {
29+
return fmt.Errorf("only HEAD is supported as tree-ish, got %q", treeish)
30+
}
31+
32+
r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
33+
if err != nil {
34+
return fmt.Errorf("failed to open repository: %w", err)
35+
}
36+
37+
w, err := r.Worktree()
38+
if err != nil {
39+
return fmt.Errorf("failed to open worktree: %w", err)
40+
}
41+
42+
cwd, err := os.Getwd()
43+
if err != nil {
44+
return err
45+
}
46+
47+
root := w.Filesystem().Root()
48+
49+
var resolved []string
50+
51+
for _, spec := range paths {
52+
rel, rerr := resolvePathspec(root, cwd, spec)
53+
if rerr != nil {
54+
return rerr
55+
}
56+
57+
resolved = append(resolved, rel)
58+
}
59+
60+
expanded, err := expandDirectoryPaths(r, resolved)
61+
if err != nil {
62+
return err
63+
}
64+
65+
if len(expanded) == 0 {
66+
return errors.New("no matching paths in HEAD")
67+
}
68+
69+
return w.Restore(&git.RestoreOptions{
70+
Staged: true,
71+
Worktree: true,
72+
Files: expanded,
73+
})
74+
},
75+
DisableFlagsInUseLine: true,
76+
}
77+
78+
// splitCheckoutArgs splits cobra-parsed args into tree-ish and pathspecs.
79+
// Cobra strips the "--" separator but records its position via ArgsLenAtDash.
80+
func splitCheckoutArgs(cmd *cobra.Command, args []string) (string, []string, error) {
81+
dashAt := cmd.ArgsLenAtDash()
82+
if dashAt < 0 {
83+
return "", nil, errors.New("missing -- separator between tree-ish and pathspecs")
84+
}
85+
86+
if dashAt == 0 {
87+
return "", nil, errors.New("missing tree-ish before --")
88+
}
89+
90+
if dashAt == len(args) {
91+
return "", nil, errors.New("missing pathspec after --")
92+
}
93+
94+
return args[0], args[dashAt:], nil
95+
}
96+
97+
// expandDirectoryPaths replaces directory entries in paths with all file
98+
// paths from HEAD's tree that live under them. File entries are kept as-is.
99+
func expandDirectoryPaths(r *git.Repository, paths []string) ([]string, error) {
100+
ref, err := r.Head()
101+
if err != nil {
102+
return nil, fmt.Errorf("resolving HEAD: %w", err)
103+
}
104+
105+
commit, err := r.CommitObject(ref.Hash())
106+
if err != nil {
107+
return nil, err
108+
}
109+
110+
tree, err := commit.Tree()
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
var out []string
116+
117+
for _, p := range paths {
118+
if _, err := tree.File(p); err == nil {
119+
out = append(out, p)
120+
121+
continue
122+
}
123+
124+
// Treat as directory: collect every tree entry whose path starts with p+"/".
125+
prefix := p + "/"
126+
if p == "." || p == "" {
127+
prefix = ""
128+
}
129+
130+
walker := object.NewTreeWalker(tree, true, nil)
131+
132+
for {
133+
name, entry, werr := walker.Next()
134+
if werr != nil {
135+
break
136+
}
137+
138+
if !entry.Mode.IsFile() {
139+
continue
140+
}
141+
142+
if prefix == "" || strings.HasPrefix(name, prefix) {
143+
out = append(out, name)
144+
}
145+
}
146+
147+
walker.Close()
148+
}
149+
150+
return out, nil
151+
}

cmd/gogit/checkout_pathspec.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
// resolvePathspec converts a user-supplied pathspec, interpreted relative to
10+
// cwd, into a path relative to the worktree root. Returns an error if the
11+
// pathspec resolves outside the worktree.
12+
func resolvePathspec(worktreeRoot, cwd, spec string) (string, error) {
13+
abs := spec
14+
if !filepath.IsAbs(abs) {
15+
abs = filepath.Join(cwd, spec)
16+
}
17+
18+
abs = filepath.Clean(abs)
19+
20+
root := filepath.Clean(worktreeRoot)
21+
22+
rel, err := filepath.Rel(root, abs)
23+
if err != nil {
24+
return "", fmt.Errorf("pathspec %q outside worktree: %w", spec, err)
25+
}
26+
27+
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
28+
return "", fmt.Errorf("pathspec %q is outside worktree %q", spec, worktreeRoot)
29+
}
30+
31+
return rel, nil
32+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package main
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
)
7+
8+
func TestResolvePathspec(t *testing.T) {
9+
t.Parallel()
10+
11+
root := "/repo"
12+
13+
tests := []struct {
14+
name string
15+
cwd string
16+
spec string
17+
want string
18+
wantErr bool
19+
}{
20+
{name: "file at root from root", cwd: "/repo", spec: "file0", want: "file0"},
21+
{name: "subdir file from root", cwd: "/repo", spec: "dir1/file1", want: "dir1/file1"},
22+
{name: "dotdot back to root from subdir", cwd: "/repo/dir1", spec: "../file0", want: "file0"},
23+
{name: "sibling via dotdot", cwd: "/repo/dir1", spec: "../dir2/file2", want: "dir2/file2"},
24+
{name: "complex relative", cwd: "/repo/dir1", spec: "../dir1/../dir1/file1", want: "dir1/file1"},
25+
{name: "directory pathspec", cwd: "/repo", spec: "dir1", want: "dir1"},
26+
{name: "parent escape from root", cwd: "/repo", spec: "../Makefile", wantErr: true},
27+
{name: "parent file from subdir", cwd: "/repo/dir1", spec: "../file0", want: "file0"},
28+
{name: "escape from subdir", cwd: "/repo/dir1", spec: "../../file0", wantErr: true},
29+
}
30+
31+
for _, tc := range tests {
32+
t.Run(tc.name, func(t *testing.T) {
33+
t.Parallel()
34+
35+
got, err := resolvePathspec(filepath.FromSlash(root), filepath.FromSlash(tc.cwd), tc.spec)
36+
if tc.wantErr {
37+
if err == nil {
38+
t.Fatalf("expected error, got %q", got)
39+
}
40+
41+
return
42+
}
43+
44+
if err != nil {
45+
t.Fatalf("unexpected error: %v", err)
46+
}
47+
48+
if got != filepath.FromSlash(tc.want) {
49+
t.Fatalf("got %q want %q", got, tc.want)
50+
}
51+
})
52+
}
53+
}

0 commit comments

Comments
 (0)