Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c7fa408
gogit: add top-level -C <dir> flag
pjbgf May 13, 2026
ac9dbc7
gogit: read default-hash from env for version --build-options
pjbgf May 13, 2026
8b10254
gogit: init honors --object-format flag and GIT_DEFAULT_HASH
pjbgf May 13, 2026
60c4797
gogit: add rev-parse --show-object-format
pjbgf May 13, 2026
f47c968
conformance: add --hash flag and @modes tokens to run.sh
pjbgf May 13, 2026
80f403c
conformance: run suite in both sha1 and sha256 modes
pjbgf May 13, 2026
d600142
conformance: graduate t0001-init.sh sha256 cases
pjbgf May 13, 2026
db326e5
gogit: init accepts --quiet / -q
pjbgf May 13, 2026
0273b5c
conformance: graduate t1007-hash-object.sh
pjbgf May 13, 2026
00c6fae
gogit: add ls-files
pjbgf May 13, 2026
74abc7e
conformance: graduate t3700-add.sh
pjbgf May 13, 2026
2ecd71a
conformance: graduate t7501 commit smoke
pjbgf May 13, 2026
24cd44a
gogit: clone accepts local paths and rejects populated targets
pjbgf May 13, 2026
43a8a1d
conformance: graduate t5601-clone.sh clone smoke
pjbgf May 13, 2026
41ebdf0
conformance: add local HTTP transport tests for sha1 and sha256
pjbgf May 13, 2026
dcf798f
conformance: add local HTTP push tests for sha1 and sha256
pjbgf May 13, 2026
803f4d0
gogit: add merge --ff-only
pjbgf May 13, 2026
6890d47
gogit: add cherry-pick
pjbgf May 13, 2026
e5ebce8
gogit: add submodule {status,init,update,add}
pjbgf May 14, 2026
398ee9e
gogit: propagate -c overrides to .git/config for storage construction
pjbgf May 14, 2026
fe66482
gogit: drop ensureCloneTargetAvailable, rely on go-git's own check
pjbgf May 14, 2026
75d91da
gogit: move submodule add logic to internal/submodule
pjbgf May 14, 2026
aae2bfe
gogit: resolve clone target to absolute before handing to PlainClone
pjbgf May 14, 2026
ccff723
conformance: expand existing graduations to sha256
pjbgf May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ endif

.PHONY: conformance
conformance:
./conformance/run.sh
./conformance/run.sh --hash=sha1
./conformance/run.sh --hash=sha256
Comment on lines +30 to +31
./conformance/local/run.sh
115 changes: 115 additions & 0 deletions cmd/gogit/cherry-pick.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"errors"
"fmt"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/spf13/cobra"
)

var cherryPickStrategy string

func init() {
cherryPickCmd.Flags().StringVar(&cherryPickStrategy, "strategy-option", "theirs", "Conflict resolution preference: `theirs` (keep cherry-picked changes) or `ours` (keep current changes)")

Check failure on line 16 in cmd/gogit/cherry-pick.go

View workflow job for this annotation

GitHub Actions / build (stable, macos-latest)

The line is 188 characters long, which exceeds the maximum of 120 characters. (lll)

Check failure on line 16 in cmd/gogit/cherry-pick.go

View workflow job for this annotation

GitHub Actions / build (stable, windows-latest)

The line is 188 characters long, which exceeds the maximum of 120 characters. (lll)

Check failure on line 16 in cmd/gogit/cherry-pick.go

View workflow job for this annotation

GitHub Actions / build (stable, ubuntu-latest)

The line is 188 characters long, which exceeds the maximum of 120 characters. (lll)

Check failure on line 16 in cmd/gogit/cherry-pick.go

View workflow job for this annotation

GitHub Actions / build (stable, ubuntu-24.04-arm)

The line is 188 characters long, which exceeds the maximum of 120 characters. (lll)
rootCmd.AddCommand(cherryPickCmd)
}

var cherryPickCmd = &cobra.Command{
Use: "cherry-pick <commit>...",
Short: "Apply the changes introduced by some existing commits",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
strategy, err := parseCherryPickStrategy(cherryPickStrategy)
if err != nil {
return err
}

r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}

defer r.Close()

commits, err := resolveCherryPickCommits(r, args)
if err != nil {
return err
}

w, err := r.Worktree()
if err != nil {
return fmt.Errorf("failed to open worktree: %w", err)
}

opts := &git.CommitOptions{}

committer, err := signatureFromEnv("GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_COMMITTER_DATE")
if err != nil && !errors.Is(err, errNoIdentityEnv) {
return err
}

opts.Committer = committer

return w.CherryPick(opts, strategy, commits...)
},
DisableFlagsInUseLine: true,
SilenceUsage: true,
SilenceErrors: true,
}

// parseCherryPickStrategy maps the user-facing --strategy-option value onto
// go-git's OrtMergeStrategyOption. go-git's CherryPick auto-resolves
// conflicting changes by picking one side; the upstream `-X theirs` / `-X
// ours` strategy options are the closest analogues, and `theirs` matches
// the default behaviour upstream `git cherry-pick` users intuit
// (incoming changes win).
func parseCherryPickStrategy(s string) (git.OrtMergeStrategyOption, error) {
switch s {
case "theirs":
return git.TheirsMergeStrategy, nil
case "ours":
return git.OursMergeStrategy, nil
default:
return 0, fmt.Errorf("cherry-pick: unknown --strategy-option %q (expected `theirs` or `ours`)", s)
}
}

// resolveCherryPickCommits turns each positional argument into a commit
// object. Any single bad reference fails the whole call before any commits
// are applied, matching upstream's "pre-flight validation" behaviour.
//
// Falls back to FromHex when ResolveRevision can't parse the input — go-git's
// resolver gates the full-hash branch on the sha1 hex length, so 64-char
// sha256 hex strings miss it and would otherwise fail.
func resolveCherryPickCommits(r *git.Repository, args []string) ([]*object.Commit, error) {
out := make([]*object.Commit, 0, len(args))

for _, arg := range args {
var (
hash plumbing.Hash
ok bool
)

if resolved, err := r.ResolveRevision(plumbing.Revision(arg)); err == nil {
hash, ok = *resolved, true
} else if h, fromHex := plumbing.FromHex(arg); fromHex {
hash, ok = h, true
}

if !ok {
return nil, fmt.Errorf("cherry-pick: %q does not name a known commit", arg)
}

commit, err := r.CommitObject(hash)
if err != nil {
return nil, fmt.Errorf("cherry-pick: not a commit object: %s", arg)
}

out = append(out, commit)
}

return out, nil
}
69 changes: 66 additions & 3 deletions cmd/gogit/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ package main
import (
"fmt"
"net/url"
"os"
"path"
"path/filepath"
"strings"

"github.com/go-git/go-git/v6"
"github.com/spf13/cobra"
)

// Note: go-git's PlainCloneContext calls checkTargetDirIsEmpty before
// initialising the clone, so the non-empty / non-directory target guard
// is upstream. That check uses osfs.Default (rooted at "/"), so it only
// fires reliably when given an absolute path — the gogit wrapper below
// resolves the destination via filepath.Abs before handing it off.

var (
cloneBare bool
cloneProgress bool
Expand Down Expand Up @@ -41,13 +49,15 @@ var cloneCmd = &cobra.Command{
}
}

ep, err := url.Parse(args[0])
repoURL := resolveCloneURL(args[0])

ep, err := url.Parse(repoURL)
if err != nil {
return err
}

opts := git.CloneOptions{
URL: args[0],
URL: repoURL,
Depth: cloneDepth,
ClientOptions: defaultClientOptions(ep),
Bare: cloneBare,
Expand All @@ -63,9 +73,62 @@ var cloneCmd = &cobra.Command{

fmt.Fprintf(cmd.ErrOrStderr(), "Cloning into '%s'...\n", dir)

_, err = git.PlainClone(dir, &opts)
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}

_, err = git.PlainClone(absDir, &opts)

return err
},
DisableFlagsInUseLine: true,
}

// resolveCloneURL accepts a clone target as a user typed it and returns a form
// that go-git's PlainClone can dereference. Bare local paths (relative or
// absolute) are pointed at the directory they name on disk; scp-like refs
// (host:path) and explicit schemes (file://, https://, ssh://, git://) pass
// through unchanged.
func resolveCloneURL(arg string) string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels like reimplementation of transport.ParseURL?

if hasURLScheme(arg) || isScpLike(arg) {
return arg
}

abs, err := filepath.Abs(arg)
if err != nil {
return arg
}

if _, err := os.Stat(abs); err != nil {
return arg
}

return abs
}

// hasURLScheme reports whether arg begins with a recognised URL scheme. We
// match the same set go-git's transport routing recognises.
func hasURLScheme(arg string) bool {
for _, scheme := range []string{"file://", "http://", "https://", "ssh://", "git://"} {
if strings.HasPrefix(arg, scheme) {
return true
}
}

return false
}

// isScpLike reports whether arg looks like `[user@]host:path` — the SSH
// shorthand that has no scheme but is not a local filesystem path. The rule
// matches upstream Git: a `:` must appear before any `/`.
func isScpLike(arg string) bool {
colon := strings.IndexByte(arg, ':')
if colon < 0 {
return false
}

slash := strings.IndexByte(arg, '/')

return slash < 0 || colon < slash
Comment on lines +125 to +133
}
90 changes: 88 additions & 2 deletions cmd/gogit/config.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
package main

import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"sync"

"github.com/go-git/go-git/v6/config"
formatcfg "github.com/go-git/go-git/v6/plumbing/format/config"
Comment on lines 11 to +12
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make this an interface over formatcfg and minimize the usage of config

)

var (
configOverridesRaw []string
configOverrides = map[string]string{}
configOverrideMu sync.Mutex

// configBackupPath is the .git/config we patched on this command's
// behalf via `-c`. restoreConfigBackup() puts it back on exit.
configBackupPath string

Check failure on line 22 in cmd/gogit/config.go

View workflow job for this annotation

GitHub Actions / build (stable, macos-latest)

File is not properly formatted (gci)

Check failure on line 22 in cmd/gogit/config.go

View workflow job for this annotation

GitHub Actions / build (stable, windows-latest)

File is not properly formatted (gci)

Check failure on line 22 in cmd/gogit/config.go

View workflow job for this annotation

GitHub Actions / build (stable, ubuntu-latest)

File is not properly formatted (gci)

Check failure on line 22 in cmd/gogit/config.go

View workflow job for this annotation

GitHub Actions / build (stable, ubuntu-24.04-arm)

File is not properly formatted (gci)
configBackup []byte
configBackupCreated bool
)

// splitKV splits "<key>=<value>" into (key, value, true). Invalid input
Expand Down Expand Up @@ -41,7 +51,10 @@
}

// applyConfigOverridesFromFlags parses raw `-c k=v` values previously captured
// by cobra and populates the override map.
// by cobra, populates the override map, and persists each value into
// .git/config so go-git's storage construction (which eagerly reads the file
// at PlainOpen time) sees the overridden values. The original config is
// restored after the subcommand returns via restoreConfigBackup.
func applyConfigOverridesFromFlags() error {
for _, raw := range configOverridesRaw {
k, v, ok := splitKV(raw)
Expand All @@ -52,7 +65,80 @@
applyConfigOverride(k, v)
}

return nil
if len(configOverridesRaw) == 0 {
return nil
}

return persistConfigOverridesToGitDir()
}

// persistConfigOverridesToGitDir writes each -c override into the on-disk
// .git/config so storage construction picks it up. Saves the original
// contents (or notes their absence) for restoreConfigBackup to revert.
//
// Outside a repository the override map is still populated for callers that
// consult it directly (configBool/hasConfigOverride); persistence is a no-op
// because there's no storage to influence.
func persistConfigOverridesToGitDir() error {
gitDir, err := findGitDir()
if err != nil {
return nil //nolint:nilerr // not in a repo: persistence is a no-op
}
Comment on lines +82 to +86

cfgPath := filepath.Join(gitDir, "config")

existing, readErr := os.ReadFile(cfgPath)
if readErr != nil && !os.IsNotExist(readErr) {
return fmt.Errorf("read .git/config: %w", readErr)
}

raw := formatcfg.New()

if len(existing) > 0 {
if err := formatcfg.NewDecoder(bytes.NewReader(existing)).Decode(raw); err != nil {
return fmt.Errorf("parse .git/config: %w", err)
}
}

configOverrideMu.Lock()
for k, v := range configOverrides {
section, key, err := splitConfigKey(k)
if err != nil {
configOverrideMu.Unlock()

return err
}

raw.Section(section).SetOption(key, v)
Comment on lines +103 to +112
}
configOverrideMu.Unlock()

configBackupPath = cfgPath
configBackup = existing
configBackupCreated = os.IsNotExist(readErr)

return writeConfigFile(cfgPath, raw)
}

// restoreConfigBackup reverts the .git/config to what it was before
// persistConfigOverridesToGitDir ran. Safe to call when no backup was
// taken — it's a no-op in that case. Called from main() after rootCmd's
// Execute completes (success or error), so the on-disk config is back to
// its starting state by the time the process exits.
func restoreConfigBackup() {
if configBackupPath == "" {
return
}

if configBackupCreated {
_ = os.Remove(configBackupPath)
} else {
_ = os.WriteFile(configBackupPath, configBackup, 0o644)
}
Comment on lines +133 to +137
Comment on lines +133 to +137

configBackupPath = ""
configBackup = nil
configBackupCreated = false
}

// hasConfigOverride reports whether key has been explicitly set via -c.
Expand Down
Loading
Loading