diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c17ba46..8b29648 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,11 @@ on: - main workflow_dispatch: + inputs: + go_git_ref: + description: 'go-git ref (commit SHA, tag, or branch) to build gogit against. Empty = go.mod default.' + required: false + default: '' permissions: contents: none @@ -34,3 +39,30 @@ jobs: - name: Validate run: make validate + + conformance: + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.platform }} + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: stable + - name: Run conformance + shell: bash + env: + GO_GIT_REF: ${{ inputs.go_git_ref }} + run: make conformance + - name: Upload TAP results + if: failure() + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: conformance-tap-${{ matrix.platform }} + path: conformance/.cache/results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 567609b..1d9af80 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ build/ +conformance/.cache/ diff --git a/Makefile b/Makefile index 88e0e03..b08fe7e 100644 --- a/Makefile +++ b/Makefile @@ -24,3 +24,7 @@ ifneq ($(shell git status --porcelain --untracked-files=no),) @git --no-pager diff @exit 1 endif + +.PHONY: conformance +conformance: + ./conformance/run.sh diff --git a/cmd/gogit/add.go b/cmd/gogit/add.go new file mode 100644 index 0000000..7789f04 --- /dev/null +++ b/cmd/gogit/add.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + + "github.com/go-git/go-git/v6" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(addCmd) +} + +var addCmd = &cobra.Command{ + Use: "add ...", + Short: "Add file contents to the index", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + w, err := r.Worktree() + if err != nil { + return fmt.Errorf("failed to open worktree: %w", err) + } + + for _, path := range args { + if _, err := w.Add(path); err != nil { + return fmt.Errorf("failed to add %s: %w", path, err) + } + } + + return nil + }, + DisableFlagsInUseLine: true, +} diff --git a/cmd/gogit/add_test.go b/cmd/gogit/add_test.go new file mode 100644 index 0000000..a9edc48 --- /dev/null +++ b/cmd/gogit/add_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAddSingleFile(t *testing.T) { + t.Parallel() + repo := t.TempDir() + + if _, _, err := runGogit(t, repo, "init"); err != nil { + t.Fatalf("init: %v", err) + } + + if err := os.WriteFile(filepath.Join(repo, "file0"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, _, err := runGogit(t, repo, "add", "file0"); err != nil { + t.Fatalf("add: %v", err) + } +} + +func TestAddMultiplePaths(t *testing.T) { + t.Parallel() + repo := t.TempDir() + + if _, _, err := runGogit(t, repo, "init"); err != nil { + t.Fatalf("init: %v", err) + } + + if err := os.MkdirAll(filepath.Join(repo, "dir1"), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(repo, "file0"), []byte("a\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(repo, "dir1", "file1"), []byte("b\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, _, err := runGogit(t, repo, "add", "file0", "dir1/file1"); err != nil { + t.Fatalf("add: %v", err) + } +} diff --git a/cmd/gogit/cat-file.go b/cmd/gogit/cat-file.go new file mode 100644 index 0000000..50f061b --- /dev/null +++ b/cmd/gogit/cat-file.go @@ -0,0 +1,94 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/spf13/cobra" +) + +var ( + catFileExists bool + catFileBatchCheck bool +) + +func init() { + catFileCmd.Flags().BoolVarP(&catFileExists, "exists", "e", false, + "Check whether object exists; exit 0 if so, 1 otherwise") + catFileCmd.Flags().BoolVar(&catFileBatchCheck, "batch-check", false, + "Read object IDs from stdin and print per line (or ' missing')") + rootCmd.AddCommand(catFileCmd) +} + +var catFileCmd = &cobra.Command{ + Use: "cat-file (-e | --batch-check)", + Short: "Provide content or check existence of repository objects", + RunE: func(cmd *cobra.Command, args []string) error { + if catFileExists && catFileBatchCheck { + return errors.New("-e and --batch-check are mutually exclusive") + } + + if !catFileExists && !catFileBatchCheck { + return errors.New("one of -e or --batch-check is required") + } + + r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + if catFileExists { + if len(args) != 1 { + return errors.New("-e requires exactly one argument") + } + + return catFileExistsCheck(r, args[0]) + } + + return catFileBatchCheckRun(cmd, r, os.Stdin) + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, +} + +func catFileExistsCheck(r *git.Repository, oid string) error { + h := plumbing.NewHash(oid) + if _, err := r.Storer.EncodedObject(plumbing.AnyObject, h); err != nil { + os.Exit(1) + } + + return nil +} + +func catFileBatchCheckRun(cmd *cobra.Command, r *git.Repository, stdin io.Reader) error { + w := bufio.NewWriter(cmd.OutOrStdout()) + defer w.Flush() + + scanner := bufio.NewScanner(stdin) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + h := plumbing.NewHash(line) + + obj, err := r.Storer.EncodedObject(plumbing.AnyObject, h) + if err != nil { + fmt.Fprintf(w, "%s missing\n", line) + + continue + } + + fmt.Fprintf(w, "%s %s %d\n", line, obj.Type(), obj.Size()) + } + + return scanner.Err() +} diff --git a/cmd/gogit/cat-file_test.go b/cmd/gogit/cat-file_test.go new file mode 100644 index 0000000..74d0502 --- /dev/null +++ b/cmd/gogit/cat-file_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +const baseBlobOID = "df967b96a579e45a18b8251732d16804b2e56a55" // sha1 of "blob 5\0base\n" + +func setupRepoWithBaseBlob(t *testing.T) string { + t.Helper() + + repo := t.TempDir() + + if _, _, err := runGogit(t, repo, "init"); err != nil { + t.Fatalf("init: %v", err) + } + + if err := os.WriteFile(filepath.Join(repo, "file0"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, _, err := runGogit(t, repo, "add", "file0"); err != nil { + t.Fatalf("add: %v", err) + } + + if _, _, err := runGogitEnv(t, repo, gitIdentityEnv(repo), "commit", "-m", "x"); err != nil { + t.Fatalf("commit: %v", err) + } + + return repo +} + +func TestCatFileExistsExitsZero(t *testing.T) { + t.Parallel() + + repo := setupRepoWithBaseBlob(t) + + if _, _, err := runGogit(t, repo, "cat-file", "-e", baseBlobOID); err != nil { + t.Fatalf("cat-file -e : expected exit 0, got %v", err) + } +} + +func TestCatFileMissingExitsOne(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + + if _, _, err := runGogit(t, repo, "init"); err != nil { + t.Fatalf("init: %v", err) + } + + stdout, _, err := runGogit(t, repo, "cat-file", "-e", "0000000000000000000000000000000000000000") + if err == nil { + t.Fatalf("expected non-zero exit, got success") + } + + if stdout != "" { + t.Fatalf("expected no stdout, got %q", stdout) + } +} + +func TestCatFileBatchCheck(t *testing.T) { + t.Parallel() + + repo := setupRepoWithBaseBlob(t) + + const missingOID = "0000000000000000000000000000000000000000" + + input := baseBlobOID + "\n" + missingOID + "\n" + want := baseBlobOID + " blob 5\n" + missingOID + " missing\n" + + stdout, stderr, err := runGogitStdin(t, repo, input, "cat-file", "--batch-check") + if err != nil { + t.Fatalf("cat-file --batch-check failed: %v\nstderr: %s", err, stderr) + } + + if stdout != want { + t.Fatalf("batch-check output mismatch:\n got: %q\nwant: %q", stdout, want) + } +} diff --git a/cmd/gogit/checkout.go b/cmd/gogit/checkout.go new file mode 100644 index 0000000..aa0f1c4 --- /dev/null +++ b/cmd/gogit/checkout.go @@ -0,0 +1,153 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(checkoutCmd) +} + +var checkoutCmd = &cobra.Command{ + Use: "checkout -- ...", + Short: "Restore working tree files from a tree", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + treeish, paths, err := splitCheckoutArgs(cmd, args) + if err != nil { + return err + } + + if treeish != "HEAD" { + return fmt.Errorf("only HEAD is supported as tree-ish, got %q", treeish) + } + + r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + defer r.Close() + + w, err := r.Worktree() + if err != nil { + return fmt.Errorf("failed to open worktree: %w", err) + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + + root := w.Filesystem().Root() + + var resolved []string + + for _, spec := range paths { + rel, rerr := resolvePathspec(root, cwd, spec) + if rerr != nil { + return rerr + } + + resolved = append(resolved, rel) + } + + expanded, err := expandDirectoryPaths(r, resolved) + if err != nil { + return err + } + + if len(expanded) == 0 { + return errors.New("no matching paths in HEAD") + } + + return w.Restore(&git.RestoreOptions{ + Staged: true, + Worktree: true, + Files: expanded, + }) + }, + DisableFlagsInUseLine: true, +} + +// splitCheckoutArgs splits cobra-parsed args into tree-ish and pathspecs. +// Cobra strips the "--" separator but records its position via ArgsLenAtDash. +func splitCheckoutArgs(cmd *cobra.Command, args []string) (string, []string, error) { + dashAt := cmd.ArgsLenAtDash() + if dashAt < 0 { + return "", nil, errors.New("missing -- separator between tree-ish and pathspecs") + } + + if dashAt == 0 { + return "", nil, errors.New("missing tree-ish before --") + } + + if dashAt == len(args) { + return "", nil, errors.New("missing pathspec after --") + } + + return args[0], args[dashAt:], nil +} + +// expandDirectoryPaths replaces directory entries in paths with all file +// paths from HEAD's tree that live under them. File entries are kept as-is. +func expandDirectoryPaths(r *git.Repository, paths []string) ([]string, error) { + ref, err := r.Head() + if err != nil { + return nil, fmt.Errorf("resolving HEAD: %w", err) + } + + commit, err := r.CommitObject(ref.Hash()) + if err != nil { + return nil, err + } + + tree, err := commit.Tree() + if err != nil { + return nil, err + } + + var out []string + + for _, p := range paths { + if _, err := tree.File(p); err == nil { + out = append(out, p) + + continue + } + + // Treat as directory: collect every tree entry whose path starts with p+"/". + prefix := p + "/" + if p == "." || p == "" { + prefix = "" + } + + walker := object.NewTreeWalker(tree, true, nil) + + for { + name, entry, werr := walker.Next() + if werr != nil { + break + } + + if !entry.Mode.IsFile() { + continue + } + + if prefix == "" || strings.HasPrefix(name, prefix) { + out = append(out, name) + } + } + + walker.Close() + } + + return out, nil +} diff --git a/cmd/gogit/checkout_pathspec.go b/cmd/gogit/checkout_pathspec.go new file mode 100644 index 0000000..000670f --- /dev/null +++ b/cmd/gogit/checkout_pathspec.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" +) + +// resolvePathspec converts a user-supplied pathspec, interpreted relative to +// cwd, into a path relative to the worktree root. Returns an error if the +// pathspec resolves outside the worktree. +func resolvePathspec(worktreeRoot, cwd, spec string) (string, error) { + abs := spec + if !filepath.IsAbs(abs) { + abs = filepath.Join(cwd, spec) + } + + abs = filepath.Clean(abs) + + root := filepath.Clean(worktreeRoot) + + rel, err := filepath.Rel(root, abs) + if err != nil { + return "", fmt.Errorf("pathspec %q outside worktree: %w", spec, err) + } + + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("pathspec %q is outside worktree %q", spec, worktreeRoot) + } + + // Tree paths in go-git always use forward slashes regardless of OS, so + // normalise Windows-style separators that filepath.Rel produces. + return filepath.ToSlash(rel), nil +} diff --git a/cmd/gogit/checkout_pathspec_test.go b/cmd/gogit/checkout_pathspec_test.go new file mode 100644 index 0000000..7ba78cf --- /dev/null +++ b/cmd/gogit/checkout_pathspec_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestResolvePathspec(t *testing.T) { + t.Parallel() + + root := "/repo" + + tests := []struct { + name string + cwd string + spec string + want string + wantErr bool + }{ + {name: "file at root from root", cwd: "/repo", spec: "file0", want: "file0"}, + {name: "subdir file from root", cwd: "/repo", spec: "dir1/file1", want: "dir1/file1"}, + {name: "dotdot back to root from subdir", cwd: "/repo/dir1", spec: "../file0", want: "file0"}, + {name: "sibling via dotdot", cwd: "/repo/dir1", spec: "../dir2/file2", want: "dir2/file2"}, + {name: "complex relative", cwd: "/repo/dir1", spec: "../dir1/../dir1/file1", want: "dir1/file1"}, + {name: "directory pathspec", cwd: "/repo", spec: "dir1", want: "dir1"}, + {name: "parent escape from root", cwd: "/repo", spec: "../Makefile", wantErr: true}, + {name: "parent file from subdir", cwd: "/repo/dir1", spec: "../file0", want: "file0"}, + {name: "escape from subdir", cwd: "/repo/dir1", spec: "../../file0", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := resolvePathspec(filepath.FromSlash(root), filepath.FromSlash(tc.cwd), tc.spec) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %q", got) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != filepath.FromSlash(tc.want) { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} diff --git a/cmd/gogit/checkout_test.go b/cmd/gogit/checkout_test.go new file mode 100644 index 0000000..2187c53 --- /dev/null +++ b/cmd/gogit/checkout_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func setupRepoWithCommit(t *testing.T) string { + t.Helper() + + repo := t.TempDir() + + if _, _, err := runGogit(t, repo, "init"); err != nil { + t.Fatalf("init: %v", err) + } + + if err := os.WriteFile(filepath.Join(repo, "file0"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(filepath.Join(repo, "dir1"), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(repo, "dir1", "file1"), []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, _, err := runGogit(t, repo, "add", "file0", "dir1/file1"); err != nil { + t.Fatalf("add: %v", err) + } + + if _, stderr, err := runGogitEnv(t, repo, gitIdentityEnv(repo), "commit", "-m", "init"); err != nil { + t.Fatalf("commit: %v\nstderr: %s", err, stderr) + } + + return repo +} + +func TestCheckoutRestoresFile(t *testing.T) { + t.Parallel() + + repo := setupRepoWithCommit(t) + + if err := os.Remove(filepath.Join(repo, "file0")); err != nil { + t.Fatal(err) + } + + if _, stderr, err := runGogit(t, repo, "checkout", "HEAD", "--", "file0"); err != nil { + t.Fatalf("checkout: %v\nstderr: %s", err, stderr) + } + + got, err := os.ReadFile(filepath.Join(repo, "file0")) + if err != nil { + t.Fatalf("file0 not restored: %v", err) + } + + if string(got) != "base\n" { + t.Fatalf("file0 content = %q want %q", got, "base\n") + } +} + +func TestCheckoutRestoresDirectory(t *testing.T) { + t.Parallel() + + repo := setupRepoWithCommit(t) + + if err := os.Remove(filepath.Join(repo, "dir1", "file1")); err != nil { + t.Fatal(err) + } + + if _, stderr, err := runGogit(t, repo, "checkout", "HEAD", "--", "dir1"); err != nil { + t.Fatalf("checkout: %v\nstderr: %s", err, stderr) + } + + got, err := os.ReadFile(filepath.Join(repo, "dir1", "file1")) + if err != nil { + t.Fatalf("dir1/file1 not restored: %v", err) + } + + if string(got) != "hello\n" { + t.Fatalf("dir1/file1 content = %q want %q", got, "hello\n") + } +} + +func TestCheckoutEscapeFails(t *testing.T) { + t.Parallel() + + repo := setupRepoWithCommit(t) + + if _, _, err := runGogit(t, repo, "checkout", "HEAD", "--", "../../etc/passwd"); err == nil { + t.Fatal("expected non-zero exit for path outside worktree") + } +} diff --git a/cmd/gogit/commit.go b/cmd/gogit/commit.go new file mode 100644 index 0000000..99db811 --- /dev/null +++ b/cmd/gogit/commit.go @@ -0,0 +1,114 @@ +package main + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/spf13/cobra" +) + +// errNoIdentityEnv is returned by signatureFromEnv when none of the identity +// environment variables are set, so the caller can fall back to git config. +var errNoIdentityEnv = errors.New("no identity environment variables set") + +var commitMessage string + +func init() { + commitCmd.Flags().StringVarP(&commitMessage, "message", "m", "", "Commit message") + _ = commitCmd.MarkFlagRequired("message") + rootCmd.AddCommand(commitCmd) +} + +var commitCmd = &cobra.Command{ + Use: "commit -m ", + Short: "Record changes to the repository", + RunE: func(cmd *cobra.Command, _ []string) error { + r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + defer r.Close() + + w, err := r.Worktree() + if err != nil { + return fmt.Errorf("failed to open worktree: %w", err) + } + + opts := &git.CommitOptions{} + + author, err := signatureFromEnv("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_AUTHOR_DATE") + if err != nil && !errors.Is(err, errNoIdentityEnv) { + return err + } + + opts.Author = author + + committer, err := signatureFromEnv("GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_COMMITTER_DATE") + if err != nil && !errors.Is(err, errNoIdentityEnv) { + return err + } + + opts.Committer = committer + + if _, err := w.Commit(commitMessage, opts); err != nil { + return fmt.Errorf("commit failed: %w", err) + } + + return nil + }, + DisableFlagsInUseLine: true, +} + +// signatureFromEnv builds an object.Signature from the given environment +// variable names. Returns errNoIdentityEnv if none of the variables are set. +func signatureFromEnv(nameVar, emailVar, dateVar string) (*object.Signature, error) { + name := os.Getenv(nameVar) + email := os.Getenv(emailVar) + date := os.Getenv(dateVar) + + if name == "" && email == "" && date == "" { + return nil, errNoIdentityEnv + } + + sig := &object.Signature{Name: name, Email: email, When: time.Now()} + + if date != "" { + t, err := parseGitDate(date) + if err != nil { + return nil, fmt.Errorf("invalid %s=%q: %w", dateVar, date, err) + } + + sig.When = t + } + + return sig, nil +} + +// parseGitDate parses the " <±HHMM>" format used by GIT_*_DATE. +func parseGitDate(s string) (time.Time, error) { + var secs int64 + + var zone string + + if _, err := fmt.Sscanf(s, "%d %s", &secs, &zone); err != nil { + return time.Time{}, err + } + + // Defer the sign/range parsing to time.Parse with the canonical "-0700" + // layout: it validates length and digits and handles both positive and + // negative offsets correctly. The reference value's date components are + // irrelevant — we only consume the resulting zone offset. + zt, err := time.Parse("-0700", zone) + if err != nil { + return time.Time{}, fmt.Errorf("invalid zone %q: %w", zone, err) + } + + _, offset := zt.Zone() + + return time.Unix(secs, 0).In(time.FixedZone(zone, offset)), nil +} diff --git a/cmd/gogit/commit_test.go b/cmd/gogit/commit_test.go new file mode 100644 index 0000000..ee8835e --- /dev/null +++ b/cmd/gogit/commit_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestParseGitDate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + wantSecs int64 + wantOffset int + wantErr bool + }{ + {name: "test_tick negative offset", in: "1112911993 -0700", wantSecs: 1112911993, wantOffset: -7 * 3600}, + {name: "positive offset", in: "1112911993 +0530", wantSecs: 1112911993, wantOffset: 5*3600 + 30*60}, + {name: "missing zone", in: "1112911993", wantErr: true}, + {name: "short zone panics in old impl", in: "1112911993 -7", wantErr: true}, + {name: "non-numeric zone", in: "1112911993 abcd", wantErr: true}, + {name: "non-numeric seconds", in: "abc -0700", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := parseGitDate(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("parseGitDate(%q) expected error, got time %v", tc.in, got) + } + + return + } + + if err != nil { + t.Fatalf("parseGitDate(%q): %v", tc.in, err) + } + + if got.Unix() != tc.wantSecs { + t.Fatalf("seconds: got %d want %d", got.Unix(), tc.wantSecs) + } + + _, offset := got.Zone() + if offset != tc.wantOffset { + t.Fatalf("offset: got %d want %d (got time %s)", offset, tc.wantOffset, got.Format(time.RFC3339)) + } + }) + } +} + +func TestCommitWithEnvIdentity(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + + if _, _, err := runGogit(t, repo, "init"); err != nil { + t.Fatalf("init: %v", err) + } + + if err := os.WriteFile(filepath.Join(repo, "file0"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, _, err := runGogit(t, repo, "add", "file0"); err != nil { + t.Fatalf("add: %v", err) + } + + if _, stderr, err := runGogitEnv(t, repo, gitIdentityEnv(repo), "commit", "-m", "populate tree"); err != nil { + t.Fatalf("commit failed: %v\nstderr: %s", err, stderr) + } +} diff --git a/cmd/gogit/diff.go b/cmd/gogit/diff.go new file mode 100644 index 0000000..65902f6 --- /dev/null +++ b/cmd/gogit/diff.go @@ -0,0 +1,102 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +var ( + diffNoIndex bool + diffIgnoreCRAtEol bool +) + +func init() { + diffCmd.Flags().BoolVar(&diffNoIndex, "no-index", false, "Compare two files outside a git repository") + diffCmd.Flags().BoolVar(&diffIgnoreCRAtEol, "ignore-cr-at-eol", false, + "Ignore carriage-returns at the end of line when comparing") + rootCmd.AddCommand(diffCmd) +} + +// diff is a minimal stand-in for `git diff` providing only the surface used +// by test-lib.sh's GIT_TEST_CMP override on Windows: --no-index file/file +// comparison with optional CRLF tolerance. Exit code 0 means equal, 1 means +// different. Differences are printed in unified-diff style. +var diffCmd = &cobra.Command{ + Use: "diff [--no-index] [--ignore-cr-at-eol] -- ", + Short: "Show changes between files", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if !diffNoIndex { + return errors.New("only --no-index mode is supported") + } + + a, err := readDiffInput(args[0], diffIgnoreCRAtEol) + if err != nil { + return err + } + + b, err := readDiffInput(args[1], diffIgnoreCRAtEol) + if err != nil { + return err + } + + if bytes.Equal(a, b) { + return nil + } + + fmt.Fprintf(cmd.OutOrStdout(), "--- %s\n+++ %s\n", args[0], args[1]) + emitNaiveDiff(cmd.OutOrStdout(), a, b) + + os.Exit(1) + + return nil + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, +} + +func readDiffInput(path string, ignoreCRAtEol bool) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + if ignoreCRAtEol { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + } + + return data, nil +} + +// emitNaiveDiff writes a per-line diff sufficient for test_cmp diagnostics. +// Not a true LCS diff, just a side-by-side dump. +func emitNaiveDiff(w io.Writer, a, b []byte) { + aLines := splitLines(a) + bLines := splitLines(b) + + for _, line := range aLines { + fmt.Fprintf(w, "-%s\n", line) + } + + for _, line := range bLines { + fmt.Fprintf(w, "+%s\n", line) + } +} + +func splitLines(b []byte) []string { + var out []string + + scanner := bufio.NewScanner(bytes.NewReader(b)) + for scanner.Scan() { + out = append(out, scanner.Text()) + } + + return out +} diff --git a/cmd/gogit/diff_test.go b/cmd/gogit/diff_test.go new file mode 100644 index 0000000..49e0a0f --- /dev/null +++ b/cmd/gogit/diff_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDiffNoIndexEqual(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + a := filepath.Join(dir, "a") + b := filepath.Join(dir, "b") + + if err := os.WriteFile(a, []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(b, []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, _, err := runGogit(t, dir, "diff", "--no-index", "--", a, b); err != nil { + t.Fatalf("expected exit 0 for equal files: %v", err) + } +} + +func TestDiffNoIndexDifferentExitsOne(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + a := filepath.Join(dir, "a") + b := filepath.Join(dir, "b") + + if err := os.WriteFile(a, []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(b, []byte("world\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, _, err := runGogit(t, dir, "diff", "--no-index", "--", a, b); err == nil { + t.Fatal("expected non-zero exit for different files") + } +} + +func TestDiffNoIndexIgnoresCRAtEOL(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + a := filepath.Join(dir, "a") + b := filepath.Join(dir, "b") + + if err := os.WriteFile(a, []byte("hello\nworld\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(b, []byte("hello\r\nworld\r\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, _, err := runGogit(t, dir, "diff", "--no-index", "--ignore-cr-at-eol", "--", a, b); err != nil { + t.Fatalf("expected exit 0 with --ignore-cr-at-eol: %v", err) + } +} diff --git a/cmd/gogit/index-pack.go b/cmd/gogit/index-pack.go new file mode 100644 index 0000000..90b794c --- /dev/null +++ b/cmd/gogit/index-pack.go @@ -0,0 +1,104 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/format/packfile" + "github.com/go-git/go-git/v6/plumbing/storer" + "github.com/spf13/cobra" +) + +var ( + indexPackStdin bool + indexPackStrict bool +) + +func init() { + indexPackCmd.Flags().BoolVar(&indexPackStdin, "stdin", false, "Read the pack from standard input") + indexPackCmd.Flags().BoolVar(&indexPackStrict, "strict", false, "Reject packs containing duplicate object IDs") + rootCmd.AddCommand(indexPackCmd) +} + +var indexPackCmd = &cobra.Command{ + Use: "index-pack --stdin [--strict]", + Short: "Build a pack index for an existing packed archive", + RunE: func(cmd *cobra.Command, _ []string) error { + if !indexPackStdin { + return errors.New("--stdin is required") + } + + r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + return indexPackRun(r, cmd.InOrStdin(), indexPackStrict) + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, +} + +func indexPackRun(repo *git.Repository, in io.Reader, strict bool) error { + pw, ok := repo.Storer.(storer.PackfileWriter) + if !ok { + return errors.New("repository storer does not support packfile writes") + } + + if !strict { + return packfile.WritePackfileToObjectStorage(pw, in) + } + + buf, err := io.ReadAll(in) + if err != nil { + return fmt.Errorf("read pack: %w", err) + } + + if err := checkPackForDuplicates(buf); err != nil { + return err + } + + return packfile.WritePackfileToObjectStorage(pw, bytes.NewReader(buf)) +} + +func checkPackForDuplicates(pack []byte) error { + obs := &dupObserver{seen: make(map[plumbing.Hash]struct{})} + parser := packfile.NewParser(bytes.NewReader(pack), packfile.WithScannerObservers(obs)) + + if _, err := parser.Parse(); err != nil { + return fmt.Errorf("parse pack: %w", err) + } + + if obs.dup != plumbing.ZeroHash { + return fmt.Errorf("duplicate object %s in pack (--strict)", obs.dup) + } + + return nil +} + +type dupObserver struct { + seen map[plumbing.Hash]struct{} + dup plumbing.Hash +} + +func (o *dupObserver) OnHeader(_ uint32) error { return nil } +func (o *dupObserver) OnFooter(_ plumbing.Hash) error { return nil } + +func (o *dupObserver) OnInflatedObjectHeader(_ plumbing.ObjectType, _, _ int64) error { + return nil +} + +func (o *dupObserver) OnInflatedObjectContent(h plumbing.Hash, _ int64, _ uint32, _ []byte) error { + if _, ok := o.seen[h]; ok && o.dup == plumbing.ZeroHash { + o.dup = h + } + + o.seen[h] = struct{}{} + + return nil +} diff --git a/cmd/gogit/index-pack_test.go b/cmd/gogit/index-pack_test.go new file mode 100644 index 0000000..9fe91c1 --- /dev/null +++ b/cmd/gogit/index-pack_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "bytes" + "crypto/sha1" + "encoding/binary" + "os" + "path/filepath" + "testing" +) + +// emptyBlobPackEntry is the on-the-wire (zlib-compressed) packfile entry for the +// canonical empty blob. Lifted verbatim from upstream's t/lib-pack.sh, which is +// the same trick t5308 uses to construct test packs. +var emptyBlobPackEntry = []byte{0x30, 0x78, 0x9c, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01} + +// makePack returns a valid v2 packfile containing `count` copies of the empty +// blob entry, with a correct SHA1 trailer. +func makePack(t *testing.T, count int) []byte { + t.Helper() + + var buf bytes.Buffer + + buf.WriteString("PACK") + + if err := binary.Write(&buf, binary.BigEndian, uint32(2)); err != nil { + t.Fatal(err) + } + + if err := binary.Write(&buf, binary.BigEndian, uint32(count)); err != nil { + t.Fatal(err) + } + + for range count { + buf.Write(emptyBlobPackEntry) + } + + h := sha1.Sum(buf.Bytes()) + buf.Write(h[:]) + + return buf.Bytes() +} + +func TestIndexPackStdinAcceptsCleanPack(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + + if _, _, err := runGogit(t, repo, "init"); err != nil { + t.Fatalf("init: %v", err) + } + + pack := makePack(t, 1) + if _, stderr, err := runGogitStdin(t, repo, string(pack), "index-pack", "--stdin"); err != nil { + t.Fatalf("index-pack --stdin failed: %v\nstderr: %s", err, stderr) + } + + matches, err := filepath.Glob(filepath.Join(repo, ".git", "objects", "pack", "pack-*.pack")) + if err != nil { + t.Fatal(err) + } + + if len(matches) != 1 { + t.Fatalf("expected exactly one pack file, got %d: %v", len(matches), matches) + } + + idxPath := matches[0][:len(matches[0])-5] + ".idx" + if _, err := os.Stat(idxPath); err != nil { + t.Fatalf("expected idx alongside pack: %v", err) + } +} + +func TestIndexPackStrictRejectsDuplicates(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + + if _, _, err := runGogit(t, repo, "init"); err != nil { + t.Fatalf("init: %v", err) + } + + pack := makePack(t, 2) + if _, _, err := runGogitStdin(t, repo, string(pack), "index-pack", "--strict", "--stdin"); err == nil { + t.Fatal("expected non-zero exit for duplicate-object pack under --strict") + } + + matches, err := filepath.Glob(filepath.Join(repo, ".git", "objects", "pack", "pack-*.pack")) + if err != nil { + t.Fatal(err) + } + + if len(matches) != 0 { + t.Fatalf("expected no pack file left behind, got %d: %v", len(matches), matches) + } +} diff --git a/cmd/gogit/init.go b/cmd/gogit/init.go new file mode 100644 index 0000000..a33d398 --- /dev/null +++ b/cmd/gogit/init.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + + "github.com/go-git/go-git/v6" + "github.com/spf13/cobra" +) + +var initTemplate string + +func init() { + initCmd.Flags().StringVar(&initTemplate, "template", "", "Template directory (accepted for compatibility, ignored)") + rootCmd.AddCommand(initCmd) +} + +var initCmd = &cobra.Command{ + Use: "init []", + Short: "Create an empty Git repository", + Args: cobra.RangeArgs(0, 1), + RunE: func(cmd *cobra.Command, args []string) error { + dir := "." + if len(args) == 1 { + dir = args[0] + } + + if _, err := git.PlainInit(dir, false); err != nil { + return fmt.Errorf("failed to init repository: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Initialized empty Git repository in %s\n", dir) + + return nil + }, + DisableFlagsInUseLine: true, +} diff --git a/cmd/gogit/init_test.go b/cmd/gogit/init_test.go new file mode 100644 index 0000000..78f3ead --- /dev/null +++ b/cmd/gogit/init_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestInit(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + repo := filepath.Join(tmp, "repo") + + if _, _, err := runGogit(t, tmp, "init", repo); err != nil { + t.Fatalf("init failed: %v", err) + } + + info, err := os.Stat(filepath.Join(repo, ".git")) + if err != nil { + t.Fatalf("expected .git dir: %v", err) + } + + if !info.IsDir() { + t.Fatal(".git is not a directory") + } +} + +func TestInitTemplateFlagIgnored(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + repo := filepath.Join(tmp, "repo") + + if _, _, err := runGogit(t, tmp, "init", "--template=", repo); err != nil { + t.Fatalf("init --template= failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(repo, ".git")); err != nil { + t.Fatalf("expected .git dir: %v", err) + } +} diff --git a/cmd/gogit/main.go b/cmd/gogit/main.go index b861f0b..0f04353 100644 --- a/cmd/gogit/main.go +++ b/cmd/gogit/main.go @@ -5,7 +5,9 @@ import ( "fmt" "net/url" "os" + "path/filepath" "strconv" + "strings" "github.com/go-git/go-git/v6/plumbing/client" "github.com/go-git/go-git/v6/plumbing/transport" @@ -16,10 +18,16 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "gogit [] ", - Short: "gogit is a Git CLI that uses go-git as its backend.", + Use: "gogit [] ", + Short: "gogit is a Git CLI that uses go-git as its backend.", + Version: "0.1.0-gogit", RunE: func(cmd *cobra.Command, _ []string) error { - return cmd.Usage() + _ = cmd.Usage() + // Real git exits 1 when invoked with no subcommand. + // test-lib.sh relies on this to detect a working git binary. + os.Exit(1) + + return nil }, DisableFlagsInUseLine: true, } @@ -47,6 +55,20 @@ func init() { } func main() { + for _, arg := range os.Args[1:] { + if arg == "--exec-path" || strings.HasPrefix(arg, "--exec-path=") { + exe, err := os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Println(filepath.Dir(exe)) + + return + } + } + err := rootCmd.Execute() if err != nil { var rerr *transport.RemoteError diff --git a/cmd/gogit/main_test.go b/cmd/gogit/main_test.go new file mode 100644 index 0000000..9d8f9b2 --- /dev/null +++ b/cmd/gogit/main_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +var gogitBin string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "gogit-test-") + if err != nil { + panic(err) + } + + gogitBin = filepath.Join(dir, "gogit") + build := exec.Command("go", "build", "-o", gogitBin, ".") + + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + _ = os.RemoveAll(dir) + + panic(err) + } + + // os.Exit skips deferred functions, so run cleanup explicitly before exit. + code := m.Run() + _ = os.RemoveAll(dir) + + os.Exit(code) +} + +func runGogit(t *testing.T, dir string, args ...string) (string, string, error) { + t.Helper() + + cmd := exec.Command(gogitBin, args...) + cmd.Dir = dir + + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + + return stdout.String(), stderr.String(), err +} + +func runGogitEnv(t *testing.T, dir string, env []string, args ...string) (string, string, error) { //nolint:unparam + t.Helper() + + cmd := exec.Command(gogitBin, args...) + cmd.Dir = dir + + cmd.Env = append(os.Environ(), env...) + + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + + return stdout.String(), stderr.String(), err +} + +func runGogitStdin(t *testing.T, dir string, stdin string, args ...string) (string, string, error) { + t.Helper() + + cmd := exec.Command(gogitBin, args...) + cmd.Dir = dir + cmd.Stdin = strings.NewReader(stdin) + + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + + return stdout.String(), stderr.String(), err +} + +func gitIdentityEnv(repo string) []string { + return []string{ + "GIT_AUTHOR_NAME=A U Thor", + "GIT_AUTHOR_EMAIL=author@example.com", + "GIT_AUTHOR_DATE=1112911993 -0700", + "GIT_COMMITTER_NAME=C O Mitter", + "GIT_COMMITTER_EMAIL=committer@example.com", + "GIT_COMMITTER_DATE=1112911993 -0700", + "HOME=" + repo, + } +} + +func TestExecPath(t *testing.T) { + t.Parallel() + + stdout, _, err := runGogit(t, t.TempDir(), "--exec-path") + if err != nil { + t.Fatalf("--exec-path failed: %v", err) + } + + if len(stdout) == 0 { + t.Fatal("--exec-path produced no output") + } +} + +func TestVersion(t *testing.T) { + t.Parallel() + + stdout, _, err := runGogit(t, t.TempDir(), "--version") + if err != nil { + t.Fatalf("--version failed: %v", err) + } + + if len(stdout) == 0 { + t.Fatal("--version produced no output") + } +} + +func TestRootCmdNoArgsExitsOne(t *testing.T) { + t.Parallel() + + _, _, err := runGogit(t, t.TempDir()) + if err == nil { + t.Fatal("expected error, got nil") + } + + var ee *exec.ExitError + if !errors.As(err, &ee) { + t.Fatalf("expected exec.ExitError, got %T", err) + } + + if ee.ExitCode() != 1 { + t.Errorf("expected exit code 1, got %d", ee.ExitCode()) + } +} diff --git a/cmd/gogit/version.go b/cmd/gogit/version.go new file mode 100644 index 0000000..60ec3b6 --- /dev/null +++ b/cmd/gogit/version.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" +) + +func init() { + versionCmd.Flags().Bool("build-options", false, "Print build options") + rootCmd.AddCommand(versionCmd) +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Display version information", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + fmt.Fprintf(cmd.OutOrStdout(), "git version %s (gogit)\n", rootCmd.Version) + + buildOptions, _ := cmd.Flags().GetBool("build-options") + if buildOptions { + fmt.Fprintf(cmd.OutOrStdout(), "cpu: %s\n", runtime.GOARCH) + fmt.Fprintf(cmd.OutOrStdout(), "default-ref-format: files\n") + fmt.Fprintf(cmd.OutOrStdout(), "default-hash: sha1\n") + } + + return nil + }, + DisableFlagsInUseLine: true, +} diff --git a/cmd/gogit/version_test.go b/cmd/gogit/version_test.go new file mode 100644 index 0000000..3d142cf --- /dev/null +++ b/cmd/gogit/version_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "strings" + "testing" +) + +func TestVersionBuildOptions(t *testing.T) { + t.Parallel() + + stdout, _, err := runGogit(t, t.TempDir(), "version", "--build-options") + if err != nil { + t.Fatalf("version --build-options failed: %v", err) + } + + if !strings.Contains(stdout, "default-hash: sha1") { + t.Errorf("expected output to contain 'default-hash: sha1', got:\n%s", stdout) + } +} diff --git a/cmd/gogit/worktree.go b/cmd/gogit/worktree.go index 16c16f3..32bcedc 100644 --- a/cmd/gogit/worktree.go +++ b/cmd/gogit/worktree.go @@ -51,6 +51,8 @@ var worktreeAddCmd = &cobra.Command{ return fmt.Errorf("failed to open repository: %w", err) } + defer r.Close() + w, err := worktree.New(r.Storer) if err != nil { return fmt.Errorf("failed to create worktree manager: %w", err) @@ -92,6 +94,8 @@ var worktreeListCmd = &cobra.Command{ return fmt.Errorf("failed to open repository: %w", err) } + defer r.Close() + w, err := worktree.New(r.Storer) if err != nil { return fmt.Errorf("failed to create worktree manager: %w", err) @@ -175,6 +179,8 @@ var worktreeRemoveCmd = &cobra.Command{ return fmt.Errorf("failed to open repository: %w", err) } + defer r.Close() + wt, err := worktree.New(r.Storer) if err != nil { return fmt.Errorf("failed to create worktree manager: %w", err) diff --git a/conformance/run.sh b/conformance/run.sh new file mode 100755 index 0000000..aceb398 --- /dev/null +++ b/conformance/run.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Resolve repo root (parent of conformance/). +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CACHE_DIR="$REPO_ROOT/conformance/.cache" +BIN_DIR="$CACHE_DIR/bin" +RESULTS_DIR="$CACHE_DIR/results" +BUILD_SNAPSHOT="$CACHE_DIR/build" + +mkdir -p "$BIN_DIR" "$RESULTS_DIR" + +# Optional go-git ref override: snapshot go.mod/go.sum, bump, restore on exit. +if [ -n "${GO_GIT_REF:-}" ]; then + mkdir -p "$BUILD_SNAPSHOT" + cp "$REPO_ROOT/go.mod" "$BUILD_SNAPSHOT/go.mod" + cp "$REPO_ROOT/go.sum" "$BUILD_SNAPSHOT/go.sum" + trap 'cp "$BUILD_SNAPSHOT/go.mod" "$REPO_ROOT/go.mod"; cp "$BUILD_SNAPSHOT/go.sum" "$REPO_ROOT/go.sum"' EXIT + ( cd "$REPO_ROOT" && go get "github.com/go-git/go-git/v6@$GO_GIT_REF" ) +fi + +echo "Building gogit..." +( cd "$REPO_ROOT" && go build -o "$BIN_DIR/gogit" ./cmd/gogit ) +ln -sf gogit "$BIN_DIR/git" + +# Resolve upstream tests source. +if [ -n "${GIT_SRC:-}" ] && [ -f "$GIT_SRC/t/test-lib.sh" ]; then + UPSTREAM_T="$GIT_SRC/t" + echo "Using GIT_SRC=$GIT_SRC" +else + UPSTREAM_REPO="$CACHE_DIR/git" + if [ ! -d "$UPSTREAM_REPO/.git" ]; then + echo "Cloning git/git into $UPSTREAM_REPO..." + git clone --depth=1 https://github.com/git/git "$UPSTREAM_REPO" + else + echo "Refreshing $UPSTREAM_REPO from origin..." + git -C "$UPSTREAM_REPO" fetch --depth=1 origin + git -C "$UPSTREAM_REPO" reset --hard FETCH_HEAD + fi + UPSTREAM_T="$UPSTREAM_REPO/t" +fi + +# Prepare a minimal GIT_BUILD_DIR so test-lib.sh can initialise. +# test-lib.sh requires: GIT-BUILD-OPTIONS, t/helper/test-tool, and +# a valid GIT_TEST_TEMPLATE_DIR. None of these need to be a real git +# build; a stub test-tool and the system templates suffice for t2008. +FAKE_BUILD_DIR="$CACHE_DIR/fake-build" +mkdir -p "$FAKE_BUILD_DIR/t/helper" + +# Locate system git templates (needed by test-lib.sh's BAIL_OUT guard). +SYSTEM_TEMPLATES="" +for d in /usr/share/git-core/templates /usr/local/share/git-core/templates; do + if [ -d "$d" ]; then + SYSTEM_TEMPLATES="$d" + break + fi +done +if [ -z "$SYSTEM_TEMPLATES" ]; then + # Fall back to upstream source templates (unbuilt, but the directory exists). + UPSTREAM_ROOT="${UPSTREAM_T%/t}" + SYSTEM_TEMPLATES="$UPSTREAM_ROOT/templates" +fi + +# Write GIT-BUILD-OPTIONS (only the variables test-lib.sh actually reads). +# Values with spaces must be single-quoted so the shell evaluates them +# correctly when test-lib.sh sources this file. +PERL_BIN="$(command -v perl 2>/dev/null || echo /usr/bin/perl)" +cat > "$FAKE_BUILD_DIR/GIT-BUILD-OPTIONS" < "$STUB_TEST_TOOL" <<'STUB' +#!/bin/sh +# Minimal test-tool stub for conformance harness. +case "$1" in + date) + case "$2" in + is64bit) date +%s | awk '{exit ($1 > 2147483647) ? 0 : 1}' ;; + time_t-is64bit) date +%s | awk '{exit ($1 > 2147483647) ? 0 : 1}' ;; + *) echo "test-tool stub: unimplemented date subcommand: $2" >&2; exit 1 ;; + esac + ;; + path-utils) + case "$2" in + file-size) wc -c < "$3" ;; + *) echo "test-tool stub: unimplemented path-utils subcommand: $2" >&2; exit 1 ;; + esac + ;; + env-helper) printenv "$2" ;; + sha1) + # `test-tool sha1 [-b]` computes the SHA1 of stdin. With -b the digest + # is emitted as 20 raw bytes (used by t/lib-pack.sh to build a pack + # trailer); without -b it's a 40-char hex string + newline. + if [ "$2" = "-b" ]; then + openssl dgst -sha1 -binary + else + openssl dgst -sha1 -hex | awk '{print $NF}' + fi + ;; + sha256) + if [ "$2" = "-b" ]; then + openssl dgst -sha256 -binary + else + openssl dgst -sha256 -hex | awk '{print $NF}' + fi + ;; + *) + echo "test-tool stub: unimplemented subcommand: $1" >&2 + exit 1 + ;; +esac +STUB +chmod +x "$STUB_TEST_TOOL" + +export GIT_TEST_INSTALLED GIT_BUILD_DIR +GIT_TEST_INSTALLED="$(cd "$BIN_DIR" && pwd)" +GIT_BUILD_DIR="$(cd "$FAKE_BUILD_DIR" && pwd)" + +# Decide what to run. +if [ "$#" -ge 1 ]; then + TEST_NAME="$1" + SELECTOR="${2:-}" + TESTS_TO_RUN=("$TEST_NAME") +elif [ -n "${TESTS:-}" ]; then + read -r -a TESTS_TO_RUN <<< "$TESTS" + SELECTOR="" +else + # Read curated list, ignoring blank lines and comments. + TESTS_TO_RUN=() + while IFS= read -r line; do + case "$line" in + ''|\#*) continue ;; + esac + TESTS_TO_RUN+=("$line") + done < "$REPO_ROOT/conformance/tests.txt" + SELECTOR="" +fi + +if [ "${#TESTS_TO_RUN[@]}" -eq 0 ]; then + echo "No tests to run." + exit 0 +fi + +EXIT_CODE=0 +# Upstream test-lib only colours output when stdout is a TTY (test-lib.sh +# `test -t 1`). Piping to tee defeats that, so when run.sh itself is on a +# terminal we run the test scripts directly and skip the per-test TAP capture +# (TAP capture is only relied on by CI for artifact upload). +INTERACTIVE=0 +if [ -t 1 ]; then + INTERACTIVE=1 +fi +for test_script in "${TESTS_TO_RUN[@]}"; do + if [ ! -f "$UPSTREAM_T/$test_script" ]; then + echo "Skipping missing test: $test_script" >&2 + EXIT_CODE=1 + continue + fi + echo "=== Running $test_script ===" + selector_args=() + if [ -n "$SELECTOR" ]; then + selector_args=(--run="$SELECTOR") + fi + if [ "$INTERACTIVE" = 1 ]; then + if ( cd "$UPSTREAM_T" && sh "./$test_script" -v -i ${selector_args[@]+"${selector_args[@]}"} ); then + : + else + EXIT_CODE=1 + fi + else + tap_file="$RESULTS_DIR/$test_script.tap" + if ( cd "$UPSTREAM_T" && sh "./$test_script" -v -i ${selector_args[@]+"${selector_args[@]}"} ) | tee "$tap_file"; then + : + else + EXIT_CODE=1 + fi + fi +done + +exit $EXIT_CODE diff --git a/conformance/tests.txt b/conformance/tests.txt new file mode 100644 index 0000000..4ce1946 --- /dev/null +++ b/conformance/tests.txt @@ -0,0 +1,5 @@ +# Curated upstream tests run against gogit. +# Add one filename per line. Lines starting with # are ignored. + +t2008-checkout-subdir.sh +t5308-pack-detect-duplicates.sh diff --git a/go.mod b/go.mod index 63dbae7..8f2a4ee 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.25.10 require ( github.com/go-git/go-billy/v6 v6.0.0-alpha.1 github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 - github.com/go-git/go-git/v6 v6.0.0-alpha.3 + github.com/go-git/go-git/v6 v6.0.0-alpha.3.0.20260512141313-533ba09d9588 github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.51.0 golang.org/x/term v0.43.0 @@ -25,7 +25,7 @@ require ( github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect ) diff --git a/go.sum b/go.sum index bfbeb89..3abad53 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H github.com/go-git/go-billy/v6 v6.0.0-alpha.1/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA= github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= -github.com/go-git/go-git/v6 v6.0.0-alpha.3 h1:lJGritJ5AcC0X7buV0lReZ4cEHqcKB3Ab2ZjD3Ku+Ss= -github.com/go-git/go-git/v6 v6.0.0-alpha.3/go.mod h1:DGnqu+twdAgtDx/4tQTWFrVE1an+2ACph3W9yOfSJZM= +github.com/go-git/go-git/v6 v6.0.0-alpha.3.0.20260512141313-533ba09d9588 h1:TgVntrlBTW1A2HAoPljPLhP8rxmLzI4mUPv2Aq7r4KQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.3.0.20260512141313-533ba09d9588/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= @@ -51,8 +51,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=