From c7fa4089abbc38945237174e8d10d1244717b773 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 18:10:44 +0100 Subject: [PATCH 01/24] gogit: add top-level -C flag Mirrors upstream Git's -C semantics: each -C chdirs relative to the previous cwd, missing argument exits 129, chdir failure exits 128 with "fatal: cannot change to '': ". Handled via argv-peeking before cobra.Execute so it works with every subcommand. Required by upcoming sha256 conformance graduation, which verifies init outcomes via "git -C rev-parse --show-object-format". Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 813bd3dc97d9 --- cmd/gogit/main.go | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/cmd/gogit/main.go b/cmd/gogit/main.go index 7dfc1b2..1b559a1 100644 --- a/cmd/gogit/main.go +++ b/cmd/gogit/main.go @@ -66,7 +66,12 @@ func init() { } func main() { - for _, arg := range os.Args[1:] { + args := os.Args[1:] + rest := make([]string, 0, len(args)) + + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--exec-path" || strings.HasPrefix(arg, "--exec-path=") { exe, err := os.Executable() if err != nil { @@ -78,8 +83,28 @@ func main() { return } + + if arg == "-C" { + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "fatal: option `-C' requires a value") + os.Exit(129) + } + + if err := os.Chdir(args[i+1]); err != nil { + fmt.Fprintf(os.Stderr, "fatal: cannot change to '%s': %s\n", args[i+1], chdirErrMsg(err)) + os.Exit(128) + } + + i++ + + continue + } + + rest = append(rest, arg) } + os.Args = append(os.Args[:1], rest...) + err := rootCmd.Execute() if err != nil { var rerr *transport.RemoteError @@ -93,6 +118,19 @@ func main() { } } +// chdirErrMsg extracts the human-readable portion of an os.Chdir error so we +// can format it like upstream Git's "fatal: cannot change to 'X': ". +// os.Chdir wraps the syscall error in *PathError, whose Err message is the +// uncapitalised form upstream uses ("no such file or directory", "permission +// denied", etc.). +func chdirErrMsg(err error) string { + var pe *os.PathError + if errors.As(err, &pe) { + return pe.Err.Error() + } + return err.Error() +} + func defaultClientOptions(u *url.URL) []client.Option { if u == nil { return nil From ac9dbc738987521fbbc107f876c932fd39235db5 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 18:13:39 +0100 Subject: [PATCH 02/24] gogit: read default-hash from env for version --build-options go-git supports sha1 and sha256 at runtime, so there is no compile-time default. The conformance harness reads `default-hash` from this output before exporting GIT_DEFAULT_HASH; echoing the test-driven value when GIT_TEST_DEFAULT_HASH or GIT_DEFAULT_HASH is set keeps the DEFAULT_HASH_ALGORITHM prereq lit in both sha1 and sha256 passes. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 5cac3975981d --- cmd/gogit/version.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/cmd/gogit/version.go b/cmd/gogit/version.go index 60ec3b6..25b9e71 100644 --- a/cmd/gogit/version.go +++ b/cmd/gogit/version.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "os" "runtime" "github.com/spf13/cobra" @@ -23,10 +24,27 @@ var versionCmd = &cobra.Command{ 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") + fmt.Fprintf(cmd.OutOrStdout(), "default-hash: %s\n", defaultHashFromEnv()) } return nil }, DisableFlagsInUseLine: true, } + +// defaultHashFromEnv reports the value used by gogit's `version --build-options` +// for `default-hash`. go-git supports both sha1 and sha256 at runtime, so there +// is no compile-time builtin. The conformance harness reads this line before +// it exports GIT_DEFAULT_HASH; by echoing the test-driven value when it's set, +// the harness's DEFAULT_HASH_ALGORITHM prereq comes out right in both passes. +func defaultHashFromEnv() string { + if v := os.Getenv("GIT_TEST_DEFAULT_HASH"); v != "" { + return v + } + + if v := os.Getenv("GIT_DEFAULT_HASH"); v != "" { + return v + } + + return "sha1" +} From 8b10254d50a1613c910af0462c2d1675989129c9 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 18:19:35 +0100 Subject: [PATCH 03/24] gogit: init honors --object-format flag and GIT_DEFAULT_HASH Resolution order matches upstream: --object-format wins, then GIT_DEFAULT_HASH, then sha1. Unknown algorithm names fail with `unknown hash algorithm "X"`. The resolved format is passed to go-git's PlainInit via WithObjectFormat. Required by the upcoming t0001-init.sh sha256 cases (52, 53, 56, 60), which init repos using both the flag and the env var. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: cc90fb9ba1b8 --- cmd/gogit/init.go | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/cmd/gogit/init.go b/cmd/gogit/init.go index a33d398..17d857d 100644 --- a/cmd/gogit/init.go +++ b/cmd/gogit/init.go @@ -2,15 +2,21 @@ package main import ( "fmt" + "os" "github.com/go-git/go-git/v6" + formatcfg "github.com/go-git/go-git/v6/plumbing/format/config" "github.com/spf13/cobra" ) -var initTemplate string +var ( + initTemplate string + initObjectFormat string +) func init() { initCmd.Flags().StringVar(&initTemplate, "template", "", "Template directory (accepted for compatibility, ignored)") + initCmd.Flags().StringVar(&initObjectFormat, "object-format", "", "Object hash algorithm: sha1 or sha256") rootCmd.AddCommand(initCmd) } @@ -24,7 +30,12 @@ var initCmd = &cobra.Command{ dir = args[0] } - if _, err := git.PlainInit(dir, false); err != nil { + format, err := resolveInitObjectFormat(initObjectFormat, os.Getenv("GIT_DEFAULT_HASH")) + if err != nil { + return err + } + + if _, err := git.PlainInit(dir, false, git.WithObjectFormat(format)); err != nil { return fmt.Errorf("failed to init repository: %w", err) } @@ -34,3 +45,23 @@ var initCmd = &cobra.Command{ }, DisableFlagsInUseLine: true, } + +// resolveInitObjectFormat picks the hash algorithm for `gogit init`, applying +// upstream's resolution order: --object-format flag wins, then GIT_DEFAULT_HASH, +// then the sha1 default. An unrecognised value is an error. +func resolveInitObjectFormat(flag, env string) (formatcfg.ObjectFormat, error) { + for _, v := range []string{flag, env} { + switch v { + case "": + continue + case "sha1": + return formatcfg.SHA1, nil + case "sha256": + return formatcfg.SHA256, nil + default: + return formatcfg.SHA1, fmt.Errorf("unknown hash algorithm %q", v) + } + } + + return formatcfg.SHA1, nil +} From 60c4797b2832c2ba08682bd77d8ab3981fd9a2e2 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 18:25:43 +0100 Subject: [PATCH 04/24] gogit: add rev-parse --show-object-format Prints the repository's object hash algorithm. Absent extensions.objectformat (sha1 repos) is reported as "sha1" instead of the empty UnsetObjectFormat string. Args relaxed to cobra.ArbitraryArgs so --show-object-format can be used without a rev argument; an explicit error replaces cobra's "requires at least 1 arg" when neither a rev nor a --show-* flag is provided. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 89dcbf41263d --- cmd/gogit/rev-parse.go | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/cmd/gogit/rev-parse.go b/cmd/gogit/rev-parse.go index ad27fb9..40b9cec 100644 --- a/cmd/gogit/rev-parse.go +++ b/cmd/gogit/rev-parse.go @@ -5,17 +5,21 @@ import ( "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" + formatcfg "github.com/go-git/go-git/v6/plumbing/format/config" "github.com/spf13/cobra" ) +var revParseShowObjectFormat bool + func init() { + revParseCmd.Flags().BoolVar(&revParseShowObjectFormat, "show-object-format", false, "Show the object format (hash algorithm) in use for the repository") rootCmd.AddCommand(revParseCmd) } var revParseCmd = &cobra.Command{ Use: "rev-parse ...", Short: "Pick out and massage parameters", - Args: cobra.MinimumNArgs(1), + Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) if err != nil { @@ -24,6 +28,28 @@ var revParseCmd = &cobra.Command{ defer r.Close() + if revParseShowObjectFormat { + cfg, err := r.Config() + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + // formatcfg.UnsetObjectFormat ("") is the zero value when + // extensions.objectformat is absent, which means SHA1. + of := cfg.Extensions.ObjectFormat + if of == formatcfg.UnsetObjectFormat { + of = formatcfg.SHA1 + } + + fmt.Fprintln(cmd.OutOrStdout(), of) + + return nil + } + + if len(args) == 0 { + return fmt.Errorf("rev-parse: no revisions and no --show-* flag") + } + for _, rev := range args { h, err := r.ResolveRevision(plumbing.Revision(rev)) if err != nil { From f47c968f3f1febf60a0a4450c781385822d8ae61 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 18:30:47 +0100 Subject: [PATCH 05/24] conformance: add --hash flag and @modes tokens to run.sh --hash= filters curated tests.txt entries by their @modes tag (entries without @ default to sha1-only) and exports GIT_TEST_DEFAULT_HASH per test invocation. The flag is stripped from positional args before the single-test path, so existing `./conformance/run.sh t0001-init.sh 53` calls keep working. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: c67539cc1120 --- conformance/run.sh | 52 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/conformance/run.sh b/conformance/run.sh index 8324aa6..99b8f64 100755 --- a/conformance/run.sh +++ b/conformance/run.sh @@ -10,6 +10,26 @@ BUILD_SNAPSHOT="$CACHE_DIR/build" mkdir -p "$BIN_DIR" "$RESULTS_DIR" +# Optional --hash= flag controls which mode this run executes. +# When set, GIT_TEST_DEFAULT_HASH is exported before each test script and only +# tests.txt entries whose `@modes` list contains the value participate. Default +# is sha1, which keeps single-test invocations and pre-modes tests.txt entries +# working unchanged. +HASH_MODE="sha1" +RUN_ARGS=() +for arg in "$@"; do + case "$arg" in + --hash=*) HASH_MODE="${arg#--hash=}" ;; + *) RUN_ARGS+=("$arg") ;; + esac +done +set -- "${RUN_ARGS[@]+${RUN_ARGS[@]}}" + +case "$HASH_MODE" in + sha1|sha256) ;; + *) echo "Unknown --hash=$HASH_MODE (expected sha1 or sha256)" >&2; exit 2 ;; +esac + # Optional go-git ref override: snapshot go.mod/go.sum, bump, restore on exit. if [ -n "${GO_GIT_REF:-}" ]; then mkdir -p "$BUILD_SNAPSHOT" @@ -108,12 +128,11 @@ elif [ -n "${TESTS:-}" ]; then read -r -a TESTS_TO_RUN <<< "$TESTS" SELECTOR="" else - # Read curated list, ignoring blank lines and comments. - # Each non-comment line in tests.txt is either `` or - # ` `. The selector is the same form upstream's - # --run= accepts (e.g. `1-5,7`, `!2`). It is forwarded to the test - # script so a single test can be graduated with a subset of its cases - # (used for t1600 which has a submodule case out of scope here). + # Each non-comment line in tests.txt is `` followed by any number + # of optional whitespace-separated tokens. A token starting with `@` is the + # comma-separated mode list (sha1, sha256). Other tokens are the upstream + # selector (`--run=` form, e.g. `1-5,7`). Order between selector and mode + # list is free. When no `@` token is present, the entry runs in sha1 only. TESTS_TO_RUN=() TESTS_SELECTORS=() while IFS= read -r line; do @@ -121,11 +140,23 @@ else ''|\#*) continue ;; esac - name="${line%% *}" + name="" sel="" - if [ "$name" != "$line" ]; then - sel="${line#"$name" }" - sel="${sel## }" + modes="sha1" + for tok in $line; do + if [ -z "$name" ]; then + name="$tok" + continue + fi + + case "$tok" in + @*) modes="${tok#@}" ;; + *) sel="$tok" ;; + esac + done + + if ! echo ",$modes," | grep -q ",$HASH_MODE,"; then + continue fi TESTS_TO_RUN+=("$name") @@ -193,6 +224,7 @@ for i in "${!TESTS_TO_RUN[@]}"; do if [ -n "$per_test_sel" ]; then selector_args=(--run="$per_test_sel") fi + export GIT_TEST_DEFAULT_HASH="$HASH_MODE" # 2>&1 inside the subshell so the upstream `-v` trace, which goes to the # test script's stderr, also reaches summary_filter; otherwise it would # bypass the grep and leak straight to our stderr in summary mode. From 80f403c40e9729e2da59659687da8b34e0b2bc56 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 18:32:10 +0100 Subject: [PATCH 06/24] conformance: run suite in both sha1 and sha256 modes `make conformance` now invokes run.sh twice. Either pass failing fails the target. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 9dcfab90d5a0 --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c2accc2..fb9941d 100644 --- a/Makefile +++ b/Makefile @@ -27,4 +27,5 @@ endif .PHONY: conformance conformance: - ./conformance/run.sh + ./conformance/run.sh --hash=sha1 + ./conformance/run.sh --hash=sha256 From d600142f2a2ff022382be6c596faabbaaafe2914 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 18:36:35 +0100 Subject: [PATCH 07/24] conformance: graduate t0001-init.sh sha256 cases Adds five t0001-init.sh cases that exercise the new gogit init --object-format flag, GIT_DEFAULT_HASH env, rev-parse --show-object-format, and extensions.objectFormat validation. They run in both sha1 and sha256 modes via the @sha1,sha256 mode tag. Graduated cases (numbered post-for-loop-expansion): 52: init honors GIT_DEFAULT_HASH 53: init honors --object-format 56: --object-format overrides GIT_DEFAULT_HASH 60: extensions.objectFormat is not allowed with repo version 0 61: init rejects attempts to initialize with different hash Header in tests.txt documents the `@modes` token syntax for future entries. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: fbb2e4556116 --- conformance/tests.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/conformance/tests.txt b/conformance/tests.txt index 1befeb9..501420b 100644 --- a/conformance/tests.txt +++ b/conformance/tests.txt @@ -1,11 +1,23 @@ # Curated upstream tests run against gogit. # Add one filename per line. Lines starting with # are ignored. +# Optional tokens after the filename, in any order: +# `@sha1,sha256` — comma-separated hash modes this entry participates in +# (default: sha1 only). +# selector — upstream `--run=` form, e.g. `1-5,7` or `!2`. t2008-checkout-subdir.sh t5308-pack-detect-duplicates.sh t5325-reverse-index.sh t1600-index.sh 1-5,7 t1601-index-bogus.sh +# t0001 sha256 surface — runtime-numbered cases (the `for hash in sha1 sha256` +# loop expands 1 source-level case into 2, shifting the upper cases by one): +# 52 init honors GIT_DEFAULT_HASH +# 53 init honors --object-format +# 56 --object-format overrides GIT_DEFAULT_HASH +# 60 extensions.objectFormat is not allowed with repo version 0 +# 61 init rejects attempts to initialize with different hash +t0001-init.sh @sha1,sha256 52,53,56,60,61 # # Not yet graduated: # From db326e5fda7d1119d868a8f0e29012e81379655a Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 19:31:37 +0100 Subject: [PATCH 08/24] gogit: init accepts --quiet / -q MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Required by t1007-hash-object.sh, which calls `git init --quiet` in its push_repo helper outside any test_expect_success — meaning the helper runs at script-parse time and a missing --quiet flag breaks every subsequent case. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 26245ba9c2b7 --- cmd/gogit/init.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/gogit/init.go b/cmd/gogit/init.go index 17d857d..5cb50aa 100644 --- a/cmd/gogit/init.go +++ b/cmd/gogit/init.go @@ -12,11 +12,13 @@ import ( var ( initTemplate string initObjectFormat string + initQuiet bool ) func init() { initCmd.Flags().StringVar(&initTemplate, "template", "", "Template directory (accepted for compatibility, ignored)") initCmd.Flags().StringVar(&initObjectFormat, "object-format", "", "Object hash algorithm: sha1 or sha256") + initCmd.Flags().BoolVarP(&initQuiet, "quiet", "q", false, "Suppress all output except errors") rootCmd.AddCommand(initCmd) } @@ -39,7 +41,9 @@ var initCmd = &cobra.Command{ return fmt.Errorf("failed to init repository: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "Initialized empty Git repository in %s\n", dir) + if !initQuiet { + fmt.Fprintf(cmd.OutOrStdout(), "Initialized empty Git repository in %s\n", dir) + } return nil }, From 0273b5cc160060037c2aae6aeac0c19afdb63910 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 19:33:07 +0100 Subject: [PATCH 09/24] conformance: graduate t1007-hash-object.sh Graduates 16 cases from t1007 that exercise gogit's core hash-object surface: hashing a file, --stdin, -w (write-to-store), bogus/truncated type-name rejection, --literally, and --stdin outside a repo. Excluded cases need flags gogit doesn't have (--stdin-paths, --path, --no-filters), crlf filter behaviour, or stricter malformed-object validation than gogit currently does. The upstream test bakes sha256 OIDs into its test_oid cache, so this entry exercises sha256 hashing identity end-to-end in the sha256 pass. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 566cae5bb242 --- conformance/tests.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/conformance/tests.txt b/conformance/tests.txt index 501420b..db54cbb 100644 --- a/conformance/tests.txt +++ b/conformance/tests.txt @@ -18,6 +18,12 @@ t1601-index-bogus.sh # 60 extensions.objectFormat is not allowed with repo version 0 # 61 init rejects attempts to initialize with different hash t0001-init.sh @sha1,sha256 52,53,56,60,61 +# t1007 hash-object: core hashing surface (file, stdin, -w, bogus type names, +# --literally, --stdin outside a repo). Excluded cases need flags gogit lacks +# (--stdin-paths, --path, --no-filters), crlf filtering, or strict +# malformed-object validation. Both modes exercise the sha256 OIDs baked into +# the upstream test's oid cache. +t1007-hash-object.sh @sha1,sha256 1,3-12,20-23,37-40 # # Not yet graduated: # From 00c6fae77f6031b71fbb205cfdbea779cedbb6c5 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 19:45:44 +0100 Subject: [PATCH 10/24] gogit: add ls-files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads the on-disk index and emits one entry per matching pathspec. Supports the minimum surface upstream tests rely on: ls-files — list tracked paths ls-files — filter by literal pathspec ls-files -s / --stage — ` \t` ls-files --error-unmatch SPEC — exit non-zero on no match Pathspecs are matched literally (no glob expansion); a trailing slash on a directory spec is ignored. The --stage hash field naturally widens to 32 bytes in sha256 repos, so the same command exercises both formats end-to-end. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 4b1debc131f6 --- cmd/gogit/ls-files.go | 98 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 cmd/gogit/ls-files.go diff --git a/cmd/gogit/ls-files.go b/cmd/gogit/ls-files.go new file mode 100644 index 0000000..dfc055d --- /dev/null +++ b/cmd/gogit/ls-files.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/format/index" + "github.com/spf13/cobra" +) + +var ( + lsFilesStage bool + lsFilesErrorUnmatch bool +) + +func init() { + lsFilesCmd.Flags().BoolVarP(&lsFilesStage, "stage", "s", false, "Show staged contents' mode, object hash, and stage") + lsFilesCmd.Flags().BoolVar(&lsFilesErrorUnmatch, "error-unmatch", false, "Exit with an error if a given pathspec does not match any file in the index") + rootCmd.AddCommand(lsFilesCmd) +} + +var lsFilesCmd = &cobra.Command{ + Use: "ls-files [] [--] [...]", + Short: "Show information about files in the index and the working tree", + Args: cobra.ArbitraryArgs, + 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) + } + + defer r.Close() + + idx, err := r.Storer.Index() + if err != nil { + return fmt.Errorf("read index: %w", err) + } + + specs := make([]string, len(args)) + matched := make([]bool, len(args)) + + for i, spec := range args { + specs[i] = strings.TrimSuffix(spec, "/") + } + + var selected []*index.Entry + + for _, e := range idx.Entries { + if len(specs) == 0 { + selected = append(selected, e) + continue + } + + for i, spec := range specs { + if pathMatchesSpec(e.Name, spec) { + matched[i] = true + + selected = append(selected, e) + + break + } + } + } + + if lsFilesErrorUnmatch { + for i, m := range matched { + if !m { + return fmt.Errorf("error: pathspec %q did not match any file(s) known to git", args[i]) + } + } + } + + sort.Slice(selected, func(i, j int) bool { return selected[i].Name < selected[j].Name }) + + for _, e := range selected { + if lsFilesStage { + fmt.Fprintf(cmd.OutOrStdout(), "%06o %s %d\t%s\n", uint32(e.Mode), e.Hash, e.Stage, e.Name) + } else { + fmt.Fprintln(cmd.OutOrStdout(), e.Name) + } + } + + return nil + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, +} + +// pathMatchesSpec reports whether path is selected by spec. A spec matches a +// path that equals it or that is rooted beneath it (treating the spec as a +// directory). Specs are taken as literal strings — no glob interpretation — +// which suits gogit's current pathspec needs. +func pathMatchesSpec(path, spec string) bool { + return path == spec || strings.HasPrefix(path, spec+"/") +} From 74abc7e93b8c7a4ff96f6c4b2b9cc5cdda71d789 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 19:47:13 +0100 Subject: [PATCH 11/24] conformance: graduate t3700-add.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graduates 12 cases from t3700 covering add's main interactions plus index round-trip via ls-files. Case 3 in particular ("Post-check that foo is in the index") exercises the full add → index-write → index-read chain, which under sha256 mode verifies that index entries carry 32-byte hashes end-to-end. Excluded cases need gogit surface that isn't there yet: --chmod, --refresh, --ignore-errors, core.fsyncmethod=batch, update-index --add, unmerged-entry stages, and embedded-repo detection. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 693f4caa6d61 --- conformance/tests.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/conformance/tests.txt b/conformance/tests.txt index db54cbb..b2ebf17 100644 --- a/conformance/tests.txt +++ b/conformance/tests.txt @@ -24,6 +24,13 @@ t0001-init.sh @sha1,sha256 52,53,56,60,61 # malformed-object validation. Both modes exercise the sha256 OIDs baked into # the upstream test's oid cache. t1007-hash-object.sh @sha1,sha256 1,3-12,20-23,37-40 +# t3700 add: cases that exercise `add` + the new `ls-files` reader. Includes +# the index round-trip case 3 ("Post-check that foo is in the index"), which +# in sha256 mode also verifies that index entries carry 32-byte hashes. +# Excluded cases need gogit surface that doesn't exist yet (--chmod, +# --refresh, --ignore-errors, core.fsyncmethod=batch, update-index --add, +# unmerged-entry handling, embedded-repo detection). +t3700-add.sh @sha1,sha256 1,3,4,12,13,20,21,34,36,40,42,44 # # Not yet graduated: # From 2ecd71aada345f0b0cd5b9f5f9ca110ade58b526 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 20:17:20 +0100 Subject: [PATCH 12/24] conformance: graduate t7501 commit smoke 12 self-contained cases from t7501-commit-basic-functionality.sh that cover argument validation and error paths around `git commit`: 2 fail initial amend 6 paths and -a do not mix 9 using invalid commit with -C 11 --dry-run fails with nothing to commit 12 --short fails with nothing to commit 13 --porcelain fails with nothing to commit 14 --long fails with nothing to commit 26 commit message from non-existing file 27 empty commit message 34 set up editor 41 -m and -F do not mix 57 commit complains about completely bogus dates Case 3 (the only case that successfully creates a commit) depends on case 1 ("initial status") having added a file. Case 1 fails because gogit's `status` output differs from upstream, breaking the dependency for case 3 under selector mode. End-to-end init/add/commit round-trip in sha256 is covered by go-git's own test suite, not by gogit conformance. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: d86017ed622e --- conformance/tests.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/conformance/tests.txt b/conformance/tests.txt index b2ebf17..fd8e970 100644 --- a/conformance/tests.txt +++ b/conformance/tests.txt @@ -31,6 +31,12 @@ t1007-hash-object.sh @sha1,sha256 1,3-12,20-23,37-40 # --refresh, --ignore-errors, core.fsyncmethod=batch, update-index --add, # unmerged-entry handling, embedded-repo detection). t3700-add.sh @sha1,sha256 1,3,4,12,13,20,21,34,36,40,42,44 +# t7501 commit: smoke coverage — argument validation and error paths around +# `git commit`. The cases that actually create commits in this script chain +# off case 1 ("initial status", which exercises `git status` output gogit +# doesn't match), so we can't graduate them in selector mode. End-to-end +# init/add/commit round-trip in sha256 is covered by go-git's own tests. +t7501-commit-basic-functionality.sh @sha1,sha256 2,6,9,11-14,26,27,34,41,57 # # Not yet graduated: # From 24cd44aa92caa7279b1db8692d5436261960d6b6 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 20:26:55 +0100 Subject: [PATCH 13/24] gogit: clone accepts local paths and rejects populated targets Two upstream-parity fixes to `gogit clone`: * Bare local paths (`gogit clone src dst`, `gogit clone ./src dst`) are resolved to absolute filesystem paths before being handed to go-git's PlainClone, which only accepts schemed URLs or absolute paths today. Explicit schemes (file://, http(s)://, ssh://, git://) and scp-like host:path refs pass through unchanged. * The target directory is checked before the clone runs. If it already exists as a non-empty directory or as a non-directory file, gogit fails with upstream's "destination path 'X' already exists and is not an empty directory" message. Without this check go-git happily merged the cloned worktree into whatever was there. Required by t5601-clone.sh case 7 ("clone checks out files"), which runs `git clone src dst` with a relative path, and by cases 22/23 which assert non-empty / non-directory targets are rejected. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 2d4f5ce35af9 --- cmd/gogit/clone.go | 91 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/cmd/gogit/clone.go b/cmd/gogit/clone.go index 86beec5..5b2b25a 100644 --- a/cmd/gogit/clone.go +++ b/cmd/gogit/clone.go @@ -3,7 +3,9 @@ package main import ( "fmt" "net/url" + "os" "path" + "path/filepath" "strings" "github.com/go-git/go-git/v6" @@ -41,13 +43,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, @@ -63,9 +67,92 @@ var cloneCmd = &cobra.Command{ fmt.Fprintf(cmd.ErrOrStderr(), "Cloning into '%s'...\n", dir) + if err := ensureCloneTargetAvailable(dir); err != nil { + return err + } + _, err = git.PlainClone(dir, &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 { + 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 +} + +// ensureCloneTargetAvailable matches upstream's pre-clone check: the target +// must not already be a non-empty directory, and must not be a non-directory +// path (e.g. an existing file). go-git's PlainClone will happily merge a +// clone into a populated directory, which lets clones silently overwrite +// unrelated content. +func ensureCloneTargetAvailable(dir string) error { + info, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + + return err + } + + if !info.IsDir() { + return fmt.Errorf("fatal: destination path %q already exists and is not an empty directory", dir) + } + + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + if len(entries) > 0 { + return fmt.Errorf("fatal: destination path %q already exists and is not an empty directory", dir) + } + + return nil +} + +// 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 +} From 43a8a1d96041d8257a97b45f3a65ce90c45f52be Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 20:28:30 +0100 Subject: [PATCH 14/24] conformance: graduate t5601-clone.sh clone smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graduates 8 cases from t5601-clone.sh covering local clone transport in both sha1 and sha256 modes. Case 7 ("clone checks out files") is the real transport probe — case 1's setup builds a source repo in whichever hash mode is active, case 7 clones it via a local path and verifies the file is checked out. Excluded: ssh-wrapper cases (70-72 need the wrapper setup at line 337); richer clone surface (--bare, --mirror, --separate-git-dir, hooks, partial clone) gogit doesn't have; specific ref-format / shallow / mirror tests; the explicit sha1<->sha256 cross-clone case which needs compat-object-format we explicitly excluded. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: da112df47cb7 --- conformance/tests.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/conformance/tests.txt b/conformance/tests.txt index fd8e970..52ffcde 100644 --- a/conformance/tests.txt +++ b/conformance/tests.txt @@ -37,6 +37,14 @@ t3700-add.sh @sha1,sha256 1,3,4,12,13,20,21,34,36,40,42,44 # doesn't match), so we can't graduate them in selector mode. End-to-end # init/add/commit round-trip in sha256 is covered by go-git's own tests. t7501-commit-basic-functionality.sh @sha1,sha256 2,6,9,11-14,26,27,34,41,57 +# t5601 clone: case 7 ("clone checks out files") is the real transport probe +# — sha1 or sha256 source repo is built in case 1's setup, then cloned via +# local-path resolution (handled by gogit's clone wrapper). The other cases +# cover URL parsing (27, 70-72 deferred — they need an ssh-wrapper setup), +# excess-argument validation, and non-empty-target rejection. Cases that +# need richer clone surface (--bare, --mirror, --separate-git-dir, depth, +# hooks, partial clone, ssh) stay deferred. +t5601-clone.sh @sha1,sha256 1-3,7,22,23,27,59 # # Not yet graduated: # From 41ebdf015b0bd76e95eb2ef94f380ac5008000fb Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 20:55:38 +0100 Subject: [PATCH 15/24] conformance: add local HTTP transport tests for sha1 and sha256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream's HTTP conformance tests (t5551, t5561) are locked to Apache + git-http-backend via lib-httpd.sh and can't drive our standalone gogit-http-server. This adds an in-house equivalent under conformance/local/: a runner builds gogit + gogit-http-server, picks a free port, and runs each test script against them. The first test (http-clone.sh) builds a source repo in sha1 then sha256, serves it via gogit-http-server, clones over http://, and asserts the clone's object format and HEAD match the source. Confirms that go-git's existing object-format capability negotiation is end-to-end intact through gogit-http-server — no wiring in the server itself was needed. Wired into `make conformance` so the local pass runs after the two upstream-curated passes. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 61a462655c4a --- Makefile | 1 + conformance/local/http-clone.sh | 107 ++++++++++++++++++++++++++++++++ conformance/local/run.sh | 81 ++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100755 conformance/local/http-clone.sh create mode 100755 conformance/local/run.sh diff --git a/Makefile b/Makefile index fb9941d..d2ce760 100644 --- a/Makefile +++ b/Makefile @@ -29,3 +29,4 @@ endif conformance: ./conformance/run.sh --hash=sha1 ./conformance/run.sh --hash=sha256 + ./conformance/local/run.sh diff --git a/conformance/local/http-clone.sh b/conformance/local/http-clone.sh new file mode 100755 index 0000000..31f5ff9 --- /dev/null +++ b/conformance/local/http-clone.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# +# Local conformance test: HTTP clone over gogit-http-server, in both sha1 and +# sha256 modes. Upstream's HTTP tests (t5551, t5561) require Apache and the +# `git-http-backend` CGI, so they can't drive our standalone server. This +# script is the in-house equivalent — start gogit-http-server on a free port, +# build a source repo in the chosen hash, clone it via http://, verify the +# clone's format and HEAD match the source. +# +# Inputs: +# GOGIT — path to the gogit binary (built by the wrapper) +# SERVER — path to the gogit-http-server binary (built by the wrapper) +# WORK_DIR — scratch directory; the test owns it for the run +# PORT — TCP port to bind the HTTP server to +# +# Exit 0 on full success; non-zero with a printed reason on any failure. + +set -euo pipefail + +: "${GOGIT:?GOGIT not set}" +: "${SERVER:?SERVER not set}" +: "${WORK_DIR:?WORK_DIR not set}" +: "${PORT:?PORT not set}" + +# A fresh HOME so the user's git config (commit.gpgsign, hooks, includes…) +# can't reach into the test repo. +HOME="$WORK_DIR/home" +mkdir -p "$HOME" +export HOME GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=t@example.com +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=t@example.com +export GIT_AUTHOR_DATE='1700000000 +0000' +export GIT_COMMITTER_DATE='1700000000 +0000' + +SERVE_DIR="$WORK_DIR/serve" +mkdir -p "$SERVE_DIR" + +SERVER_PID="" +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +"$SERVER" -p "$PORT" "$SERVE_DIR" >"$WORK_DIR/server.log" 2>&1 & +SERVER_PID=$! + +# Wait until the server is accepting connections (up to ~2s). Without this +# the first clone races the server's bind() and produces a flaky failure. +for _ in $(seq 1 20); do + if (echo >/dev/tcp/127.0.0.1/"$PORT") 2>/dev/null; then + break + fi + sleep 0.1 +done + +run_case() { + local hash="$1" + local repo="$SERVE_DIR/repo-$hash" + + "$GOGIT" init --quiet --object-format="$hash" "$repo" + echo "content-$hash" >"$repo/file" + ( cd "$repo" && "$GOGIT" add file ) + ( cd "$repo" && "$GOGIT" commit -m initial >/dev/null ) + + local source_head + source_head=$( cd "$repo" && "$GOGIT" rev-parse HEAD ) + local source_format + source_format=$( cd "$repo" && "$GOGIT" rev-parse --show-object-format ) + + if [ "$source_format" != "$hash" ]; then + echo "FAIL ($hash): source repo format is $source_format, want $hash" >&2 + return 1 + fi + + local clone="$WORK_DIR/clone-$hash" + rm -rf "$clone" + + "$GOGIT" clone "http://127.0.0.1:$PORT/repo-$hash" "$clone" >"$WORK_DIR/clone-$hash.log" 2>&1 + + local clone_head + clone_head=$( cd "$clone" && "$GOGIT" rev-parse HEAD ) + local clone_format + clone_format=$( cd "$clone" && "$GOGIT" rev-parse --show-object-format ) + + if [ "$clone_format" != "$hash" ]; then + echo "FAIL ($hash): clone repo format is $clone_format, want $hash" >&2 + return 1 + fi + + if [ "$clone_head" != "$source_head" ]; then + echo "FAIL ($hash): clone HEAD ($clone_head) != source HEAD ($source_head)" >&2 + return 1 + fi + + if ! [ -f "$clone/file" ] || [ "$(cat "$clone/file")" != "content-$hash" ]; then + echo "FAIL ($hash): clone worktree file missing or wrong content" >&2 + return 1 + fi + + echo "ok - http-clone $hash (HEAD=${clone_head:0:12}…)" +} + +run_case sha1 +run_case sha256 diff --git a/conformance/local/run.sh b/conformance/local/run.sh new file mode 100755 index 0000000..01050de --- /dev/null +++ b/conformance/local/run.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Runner for local (in-house) conformance tests under conformance/local/. +# +# Upstream's HTTP test framework (lib-httpd.sh, t5551, t5561) is locked to +# Apache + git-http-backend, so it can't drive `gogit-http-server`. This +# runner is the in-house equivalent: it builds gogit + gogit-http-server, +# allocates a scratch dir and TCP port, and runs each *.sh in this +# directory as a self-contained sub-test. +# +# Each sub-test receives: +# GOGIT — path to the freshly-built gogit binary +# SERVER — path to the freshly-built gogit-http-server binary +# WORK_DIR — its own scratch dir +# PORT — a free TCP port +# +# Non-zero exit from any sub-test fails the runner. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LOCAL_DIR="$REPO_ROOT/conformance/local" +CACHE_DIR="$REPO_ROOT/conformance/.cache" +BIN_DIR="$CACHE_DIR/bin" + +mkdir -p "$BIN_DIR" + +echo "Building gogit..." +( cd "$REPO_ROOT" && go build -o "$BIN_DIR/gogit" ./cmd/gogit ) + +echo "Building gogit-http-server..." +( cd "$REPO_ROOT" && go build -o "$BIN_DIR/gogit-http-server" ./cmd/gogit-http-server ) + +GOGIT="$BIN_DIR/gogit" +SERVER="$BIN_DIR/gogit-http-server" + +# Pick a free local port. mktemp+ss-based discovery would also work but +# this is portable and good enough — collisions just re-roll. +pick_port() { + local p + for _ in 1 2 3 4 5; do + p=$(( (RANDOM % 20000) + 30000 )) + if ! (echo >/dev/tcp/127.0.0.1/"$p") 2>/dev/null; then + echo "$p" + return 0 + fi + done + echo "could not allocate a free local port after 5 attempts" >&2 + return 1 +} + +EXIT_CODE=0 +for test_script in "$LOCAL_DIR"/*.sh; do + [ -e "$test_script" ] || continue + + case "$(basename "$test_script")" in + run.sh) continue ;; + esac + + name=$(basename "$test_script" .sh) + work_dir="$CACHE_DIR/local/$name" + rm -rf "$work_dir" + mkdir -p "$work_dir" + + port=$(pick_port) + + echo "=== Running local/$name (port $port) ===" + + if GOGIT="$GOGIT" SERVER="$SERVER" WORK_DIR="$work_dir" PORT="$port" \ + bash "$test_script"; then + : + else + EXIT_CODE=1 + echo "--- $name failed; server log: ---" >&2 + if [ -f "$work_dir/server.log" ]; then + sed 's/^/ /' "$work_dir/server.log" >&2 + fi + fi +done + +exit "$EXIT_CODE" From dcf798f1724996b2082b4dca959aa01add17faad Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 21:03:53 +0100 Subject: [PATCH 16/24] conformance: add local HTTP push tests for sha1 and sha256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors http-clone.sh in shape: seed a bare destination via clone --bare, serve it through gogit-http-server, then clone, commit, push from a client, and re-clone to confirm the server now holds the pushed commit. The push URL has dummy credentials baked in (test:test) because go-git's backend rejects receive-pack requests without an Authorization header as a sanity check — it doesn't validate the credentials. Cloning with the credential-bearing URL puts them in the cloned repo's origin config, so the subsequent `push origin` inherits the header automatically. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: e8c69b1a63c4 --- conformance/local/http-push.sh | 115 +++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100755 conformance/local/http-push.sh diff --git a/conformance/local/http-push.sh b/conformance/local/http-push.sh new file mode 100755 index 0000000..689a4ee --- /dev/null +++ b/conformance/local/http-push.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# Local conformance test: HTTP push via gogit-http-server, in both sha1 and +# sha256 modes. Same shape as http-clone.sh — start the server, build a bare +# destination repo in the chosen hash format, push to it, verify the server +# now holds the pushed commit by re-cloning and comparing HEADs. +# +# Inputs: +# GOGIT — path to the gogit binary +# SERVER — path to the gogit-http-server binary +# WORK_DIR — scratch directory; the test owns it +# PORT — TCP port for the HTTP server +# +# Exit 0 on full success; non-zero with a printed reason on any failure. + +set -euo pipefail + +: "${GOGIT:?GOGIT not set}" +: "${SERVER:?SERVER not set}" +: "${WORK_DIR:?WORK_DIR not set}" +: "${PORT:?PORT not set}" + +HOME="$WORK_DIR/home" +mkdir -p "$HOME" +export HOME GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=t@example.com +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=t@example.com +export GIT_AUTHOR_DATE='1700000000 +0000' +export GIT_COMMITTER_DATE='1700000000 +0000' + +SERVE_DIR="$WORK_DIR/serve" +mkdir -p "$SERVE_DIR" + +SERVER_PID="" +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +"$SERVER" -p "$PORT" "$SERVE_DIR" >"$WORK_DIR/server.log" 2>&1 & +SERVER_PID=$! + +for _ in $(seq 1 20); do + if (echo >/dev/tcp/127.0.0.1/"$PORT") 2>/dev/null; then + break + fi + sleep 0.1 +done + +run_case() { + local hash="$1" + local seed="$WORK_DIR/seed-$hash" + local bare="$SERVE_DIR/dest-$hash.git" + + # Seed: a non-bare repo with an initial commit, used only to populate + # the bare destination with starting history. + "$GOGIT" init --quiet --object-format="$hash" "$seed" + echo "seed-$hash" >"$seed/file" + ( cd "$seed" && "$GOGIT" add file ) + ( cd "$seed" && "$GOGIT" commit -m initial >/dev/null ) + + "$GOGIT" clone --bare "$seed" "$bare" >/dev/null 2>&1 + + # Clone with credentials baked into the URL. go-git stores the URL + # verbatim in the remote config, which means subsequent pushes inherit + # the Authorization header. The server doesn't validate the credentials + # — it just rejects receive-pack requests without an Authorization + # header as a basic sanity check. + local client="$WORK_DIR/client-$hash" + "$GOGIT" clone "http://test:test@127.0.0.1:$PORT/dest-$hash.git" "$client" \ + >"$WORK_DIR/client-clone-$hash.log" 2>&1 + + echo "pushed-$hash" >"$client/file" + ( cd "$client" && "$GOGIT" add file ) + ( cd "$client" && "$GOGIT" commit -m "push-update" >/dev/null ) + + local pushed_head + pushed_head=$( cd "$client" && "$GOGIT" rev-parse HEAD ) + + ( cd "$client" && "$GOGIT" push origin master ) \ + >"$WORK_DIR/push-$hash.log" 2>&1 + + # Verify: re-clone the destination and confirm its HEAD matches. + local verify="$WORK_DIR/verify-$hash" + "$GOGIT" clone "http://127.0.0.1:$PORT/dest-$hash.git" "$verify" \ + >"$WORK_DIR/verify-clone-$hash.log" 2>&1 + + local verify_head + verify_head=$( cd "$verify" && "$GOGIT" rev-parse HEAD ) + local verify_format + verify_format=$( cd "$verify" && "$GOGIT" rev-parse --show-object-format ) + + if [ "$verify_format" != "$hash" ]; then + echo "FAIL ($hash): verify clone format is $verify_format, want $hash" >&2 + return 1 + fi + + if [ "$verify_head" != "$pushed_head" ]; then + echo "FAIL ($hash): server HEAD after push ($verify_head) != client HEAD ($pushed_head)" >&2 + return 1 + fi + + if [ "$(cat "$verify/file")" != "pushed-$hash" ]; then + echo "FAIL ($hash): pushed content not present in re-clone" >&2 + return 1 + fi + + echo "ok - http-push $hash (HEAD=${verify_head:0:12}…)" +} + +run_case sha1 +run_case sha256 From 803f4d0c0309df03aa2b3dc627453348ee7c0f26 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 21:07:07 +0100 Subject: [PATCH 17/24] gogit: add merge --ff-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin wrapper over go-git's Repository.Merge with FastForwardMerge strategy. After the ref update the worktree is checked out at the new HEAD so the user sees the merged content; bare repos skip the worktree step. Non-FF merge is rejected (with a hint to use --ff-only). go-git doesn't implement other merge strategies, so for now --ff-only and plain `merge` behave the same — the flag is the upstream-compatible spelling for users who want to opt into FF-only explicitly. Also adds a local conformance test (conformance/local/ff-merge.sh) that exercises A -> B fast-forward in both sha1 and sha256. Branch setup uses direct ref writes because gogit doesn't have `branch` / `switch` / `update-ref` yet; those are orthogonal gaps. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 39622a34d2c8 --- cmd/gogit/merge.go | 91 +++++++++++++++++++++++++++++++++++ conformance/local/ff-merge.sh | 89 ++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 cmd/gogit/merge.go create mode 100755 conformance/local/ff-merge.sh diff --git a/cmd/gogit/merge.go b/cmd/gogit/merge.go new file mode 100644 index 0000000..f1dfb45 --- /dev/null +++ b/cmd/gogit/merge.go @@ -0,0 +1,91 @@ +package main + +import ( + "errors" + "fmt" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/spf13/cobra" +) + +var mergeFFOnly bool + +func init() { + mergeCmd.Flags().BoolVar(&mergeFFOnly, "ff-only", false, "Refuse to merge unless the merge can be resolved as a fast-forward") + rootCmd.AddCommand(mergeCmd) +} + +var mergeCmd = &cobra.Command{ + Use: "merge ", + Short: "Join two development histories together", + Args: cobra.ExactArgs(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) + } + + defer r.Close() + + target, err := resolveMergeTarget(r, args[0]) + if err != nil { + return err + } + + err = r.Merge(*target, git.MergeOptions{Strategy: git.FastForwardMerge}) + + switch { + case errors.Is(err, git.ErrFastForwardMergeNotPossible): + if mergeFFOnly { + return fmt.Errorf("fatal: Not possible to fast-forward, aborting") + } + + return fmt.Errorf("non-fast-forward merge not supported (use --ff-only)") + case err != nil: + return fmt.Errorf("merge: %w", err) + } + + // r.Merge advances the HEAD ref but does not touch the worktree. + // Sync the worktree by checking out HEAD's new tip, matching the + // behaviour `git merge --ff-only` has on a non-bare repository. + head, err := r.Head() + if err != nil { + return fmt.Errorf("read HEAD after merge: %w", err) + } + + w, err := r.Worktree() + if err != nil { + // Bare repos have no worktree; the ref update above is the + // whole merge. + if errors.Is(err, git.ErrIsBareRepository) { + return nil + } + + return fmt.Errorf("open worktree: %w", err) + } + + return w.Checkout(&git.CheckoutOptions{Branch: head.Name(), Force: true}) + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, +} + +// resolveMergeTarget interprets a branch name (or local ref short name) and +// returns the corresponding plumbing.Reference. Falls back through a few +// common forms: full reference name, "refs/heads/" branch, then bare +// short-name lookup. +func resolveMergeTarget(r *git.Repository, name string) (*plumbing.Reference, error) { + for _, candidate := range []plumbing.ReferenceName{ + plumbing.ReferenceName(name), + plumbing.NewBranchReferenceName(name), + } { + ref, err := r.Reference(candidate, true) + if err == nil { + return ref, nil + } + } + + return nil, fmt.Errorf("merge: %q does not name a known reference", name) +} diff --git a/conformance/local/ff-merge.sh b/conformance/local/ff-merge.sh new file mode 100755 index 0000000..ad76c97 --- /dev/null +++ b/conformance/local/ff-merge.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Local conformance test: fast-forward merge in both sha1 and sha256 modes. +# Exercises the new `gogit merge --ff-only` command. +# +# go-git only supports the FastForward merge strategy and gogit doesn't have +# a branch / switch command, so the two-branch setup is built directly: +# commit A, commit B on the same branch, then rewind the branch to A and +# create `feature` pointing at B by writing the ref file. Merging `feature` +# back into the branch should advance HEAD from A to B and sync the worktree. +# +# Inputs: +# GOGIT — path to the gogit binary +# WORK_DIR — scratch directory; the test owns it +# +# Unused by this test, but the runner provides them for symmetry: +# SERVER, PORT +# +# Exit 0 on full success; non-zero with a printed reason on any failure. + +set -euo pipefail + +: "${GOGIT:?GOGIT not set}" +: "${WORK_DIR:?WORK_DIR not set}" + +HOME="$WORK_DIR/home" +mkdir -p "$HOME" +export HOME GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=t@example.com +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=t@example.com +export GIT_AUTHOR_DATE='1700000000 +0000' +export GIT_COMMITTER_DATE='1700000000 +0000' + +run_case() { + local hash="$1" + local repo="$WORK_DIR/repo-$hash" + + "$GOGIT" init --quiet --object-format="$hash" "$repo" + cd "$repo" + + # Commit A on the initial branch. + echo "$hash-A" >file + "$GOGIT" add file + "$GOGIT" commit -m A >/dev/null + local branch + branch=$(sed 's|ref: refs/heads/||' <"$repo/.git/HEAD") + local oid_a + oid_a=$("$GOGIT" rev-parse HEAD) + + # Commit B on the same branch (so B is a descendant of A). + echo "$hash-B" >file + "$GOGIT" add file + "$GOGIT" commit -m B >/dev/null + local oid_b + oid_b=$("$GOGIT" rev-parse HEAD) + + # Move `feature` to B, rewind the active branch back to A. HEAD still + # points at the active branch by name, so it now resolves to A. + echo "$oid_b" >"$repo/.git/refs/heads/feature" + echo "$oid_a" >"$repo/.git/refs/heads/$branch" + + local pre + pre=$("$GOGIT" rev-parse HEAD) + if [ "$pre" != "$oid_a" ]; then + echo "FAIL ($hash): pre-merge HEAD is $pre, want $oid_a" >&2 + return 1 + fi + + "$GOGIT" merge --ff-only feature >"$WORK_DIR/merge-$hash.log" 2>&1 + + local post + post=$("$GOGIT" rev-parse HEAD) + if [ "$post" != "$oid_b" ]; then + echo "FAIL ($hash): post-merge HEAD is $post, want $oid_b" >&2 + cat "$WORK_DIR/merge-$hash.log" >&2 + return 1 + fi + + if [ "$(cat "$repo/file")" != "$hash-B" ]; then + echo "FAIL ($hash): worktree file not updated to B content" >&2 + return 1 + fi + + cd "$WORK_DIR" + echo "ok - ff-merge $hash (${oid_a:0:12}… -> ${oid_b:0:12}…)" +} + +run_case sha1 +run_case sha256 From 6890d473b377656736986d7fcf9ff805ada28cac Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Wed, 13 May 2026 21:18:07 +0100 Subject: [PATCH 18/24] gogit: add cherry-pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps go-git's Worktree.CherryPick. Resolves each positional argument to a commit object (falling back to FromHex when go-git's ResolveRevision rejects a full sha256 hex string — its full-hash branch is gated on sha1 width), then runs the cherry-pick with the chosen merge strategy. --strategy-option {theirs,ours} maps onto go-git's OrtMergeStrategyOption. theirs is the default (incoming changes win). go-git's CherryPick is structurally a single-strategy merge rather than an upstream-style cherry-pick of a single commit's diff against its parent, so cherry-pick onto a different parent is best treated as "merge B into current, picking theirs" and may pull in more than the upstream version would. Also adds a local conformance test (conformance/local/cherry-pick.sh) that exercises the well-supported subset: A -> B introduces a new file, rewind to A, cherry-pick B, verify the resulting commit, index, and worktree. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 1a49384c05b3 --- cmd/gogit/cherry-pick.go | 115 +++++++++++++++++++++++++++++ conformance/local/cherry-pick.sh | 120 +++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 cmd/gogit/cherry-pick.go create mode 100755 conformance/local/cherry-pick.sh diff --git a/cmd/gogit/cherry-pick.go b/cmd/gogit/cherry-pick.go new file mode 100644 index 0000000..07163d2 --- /dev/null +++ b/cmd/gogit/cherry-pick.go @@ -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)") + rootCmd.AddCommand(cherryPickCmd) +} + +var cherryPickCmd = &cobra.Command{ + Use: "cherry-pick ...", + 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 +} diff --git a/conformance/local/cherry-pick.sh b/conformance/local/cherry-pick.sh new file mode 100755 index 0000000..9f2cf22 --- /dev/null +++ b/conformance/local/cherry-pick.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# Local conformance test: gogit cherry-pick in both sha1 and sha256 modes. +# +# Setup: commit A (base.txt), commit B (B = A + topic.txt). Rewind the active +# branch back to A and remove topic.txt from the worktree. Cherry-pick B — +# the new commit should re-introduce topic.txt and its content. Because +# environment timestamps and identities are fixed and B's parent is A, +# the cherry-picked commit comes out byte-identical to B; we still verify +# the round-trip (HEAD advanced, worktree contains topic.txt, ls-files +# entries are present) which exercises gogit cherry-pick + go-git's +# Worktree.CherryPick + the new commit's full round-trip. +# +# Cherry-pick onto a *different* parent (with conflicting modifications) +# is intentionally out of scope: go-git's CherryPick is a single-strategy +# merge (theirs/ours) rather than a true upstream-style cherry-pick, and +# its worktree-sync behaviour on multi-file diffs has rough edges. The +# add-only case here is the well-supported subset. +# +# Inputs: +# GOGIT — path to the gogit binary +# WORK_DIR — scratch directory; the test owns it +# Unused but supplied by the runner: SERVER, PORT +# +# Exit 0 on full success; non-zero with a printed reason on any failure. + +set -euo pipefail + +: "${GOGIT:?GOGIT not set}" +: "${WORK_DIR:?WORK_DIR not set}" + +HOME="$WORK_DIR/home" +mkdir -p "$HOME" +export HOME GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=t@example.com +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=t@example.com +export GIT_AUTHOR_DATE='1700000000 +0000' +export GIT_COMMITTER_DATE='1700000000 +0000' + +run_case() { + local hash="$1" + local repo="$WORK_DIR/repo-$hash" + + "$GOGIT" init --quiet --object-format="$hash" "$repo" + cd "$repo" + + echo "base-$hash" >base.txt + "$GOGIT" add base.txt + "$GOGIT" commit -m base >/dev/null + local branch + branch=$(sed 's|ref: refs/heads/||' <"$repo/.git/HEAD") + local oid_a + oid_a=$("$GOGIT" rev-parse HEAD) + + echo "topic-$hash" >topic.txt + "$GOGIT" add topic.txt + "$GOGIT" commit -m "topic adds topic.txt" >/dev/null + local oid_b + oid_b=$("$GOGIT" rev-parse HEAD) + + # Rewind to A so cherry-pick has work to do. + echo "$oid_a" >"$repo/.git/refs/heads/$branch" + rm -f topic.txt + + if [ "$("$GOGIT" rev-parse HEAD)" != "$oid_a" ]; then + echo "FAIL ($hash): pre-pick HEAD != A" >&2 + return 1 + fi + + "$GOGIT" cherry-pick "$oid_b" >"$WORK_DIR/cherry-$hash.log" 2>&1 + + local oid_after + oid_after=$("$GOGIT" rev-parse HEAD) + + if [ "$oid_after" = "$oid_a" ]; then + echo "FAIL ($hash): cherry-pick did not advance HEAD" >&2 + cat "$WORK_DIR/cherry-$hash.log" >&2 + return 1 + fi + + if [ ! -f topic.txt ]; then + echo "FAIL ($hash): topic.txt missing from worktree after cherry-pick" >&2 + return 1 + fi + + if [ "$(cat topic.txt)" != "topic-$hash" ]; then + echo "FAIL ($hash): topic.txt content is $(cat topic.txt), want topic-$hash" >&2 + return 1 + fi + + # Index must contain topic.txt staged with a hash of the right width. + local idx + idx=$("$GOGIT" ls-files --stage topic.txt) + if [ -z "$idx" ]; then + echo "FAIL ($hash): topic.txt not in index after cherry-pick" >&2 + return 1 + fi + + # Confirm the resulting commit's parent is A (the cherry-pick lands on + # the active history) and that the commit message survives. + local parent + parent=$("$GOGIT" rev-parse HEAD^) + if [ "$parent" != "$oid_a" ]; then + echo "FAIL ($hash): cherry-pick parent is $parent, want A ($oid_a)" >&2 + return 1 + fi + + local msg + msg=$("$GOGIT" cat-file commit "$oid_after" | tail -1) + if [ "$msg" != "topic adds topic.txt" ]; then + echo "FAIL ($hash): cherry-pick commit message is $msg" >&2 + return 1 + fi + + cd "$WORK_DIR" + echo "ok - cherry-pick $hash (A=${oid_a:0:12}…, B=${oid_b:0:12}…, new=${oid_after:0:12}…)" +} + +run_case sha1 +run_case sha256 From e5ebce8012a911626928ae0cfce4d17c51575489 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Thu, 14 May 2026 05:32:06 +0100 Subject: [PATCH 19/24] gogit: add submodule {status,init,update,add} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps go-git's Worktree.Submodules() for status/init/update and implements `submodule add` directly: clone the repository into the chosen path, write the .gitmodules entry, then stage a gitlink entry (mode 160000, hash = clone HEAD) in the parent's index by writing the index Entry directly — go-git's Worktree.Add doesn't gitlink-stage. Behaviour verified manually for both sha1 and sha256 repositories. Add in sha256 parent stages a 32-byte gitlink hash; subsequent init/update in a fresh clone of the parent fetches and checks out the submodule. Known gaps that this commit doesn't close: * Submodule data lives inline at /.git, not at .git/modules//, so the upstream gitfile indirection isn't present. Tests that check .git/modules// exist (e.g. t1600 case 6) won't graduate without that. * No `submodule deinit`, `sync`, `summary`, `foreach`. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 0b7494ea3d73 --- cmd/gogit/submodule.go | 278 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 cmd/gogit/submodule.go diff --git a/cmd/gogit/submodule.go b/cmd/gogit/submodule.go new file mode 100644 index 0000000..78e0577 --- /dev/null +++ b/cmd/gogit/submodule.go @@ -0,0 +1,278 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/filemode" + "github.com/go-git/go-git/v6/plumbing/format/index" + "github.com/spf13/cobra" +) + +var submoduleUpdateInit bool + +func init() { + submoduleUpdateCmd.Flags().BoolVar(&submoduleUpdateInit, "init", false, "Initialise uninitialised submodules before updating") + + submoduleCmd.AddCommand(submoduleStatusCmd) + submoduleCmd.AddCommand(submoduleInitCmd) + submoduleCmd.AddCommand(submoduleUpdateCmd) + submoduleCmd.AddCommand(submoduleAddCmd) + rootCmd.AddCommand(submoduleCmd) +} + +var submoduleCmd = &cobra.Command{ + Use: "submodule ", + Short: "Initialise, update, or inspect submodules", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Usage() + }, + DisableFlagsInUseLine: true, +} + +var submoduleStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show the status of submodules", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + w, err := openWorktree() + if err != nil { + return err + } + + statuses, err := w.Submodules() + if err != nil { + return fmt.Errorf("read submodules: %w", err) + } + + s, err := statuses.Status() + if err != nil { + return fmt.Errorf("submodule status: %w", err) + } + + // Status' String() already emits " " lines, the + // upstream-shape format. + fmt.Fprint(cmd.OutOrStdout(), s.String()) + + return nil + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, +} + +var submoduleInitCmd = &cobra.Command{ + Use: "init", + Short: "Initialise submodules recorded in .gitmodules into the local config", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + w, err := openWorktree() + if err != nil { + return err + } + + subs, err := w.Submodules() + if err != nil { + return fmt.Errorf("read submodules: %w", err) + } + + return subs.Init() + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, +} + +var submoduleUpdateCmd = &cobra.Command{ + Use: "update [--init]", + Short: "Update submodules to the recorded commit", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + w, err := openWorktree() + if err != nil { + return err + } + + subs, err := w.Submodules() + if err != nil { + return fmt.Errorf("read submodules: %w", err) + } + + return subs.Update(&git.SubmoduleUpdateOptions{Init: submoduleUpdateInit}) + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, +} + +var submoduleAddCmd = &cobra.Command{ + Use: "add []", + Short: "Add the given repository as a submodule", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + return runSubmoduleAdd(cmd, args) + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, +} + +// runSubmoduleAdd implements `gogit submodule add []`: +// resolve the URL (local paths get expanded to absolute), clone the +// submodule into , write a `.gitmodules` entry, then stage the +// submodule path in the parent's index as a gitlink (mode 160000 with +// the submodule's HEAD as the hash). go-git's Worktree.Add doesn't +// gitlink-stage, so the index entry is written directly via +// Storer.Index / SetIndex. +func runSubmoduleAdd(cmd *cobra.Command, args []string) error { + repoArg := args[0] + + parent, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return fmt.Errorf("open parent repository: %w", err) + } + + defer parent.Close() + + parentWT, err := parent.Worktree() + if err != nil { + return fmt.Errorf("open parent worktree: %w", err) + } + + parentRoot := parentWT.Filesystem().Root() + + relPath := args[0] + if len(args) == 2 { + relPath = args[1] + } else { + relPath = filepath.Base(strings.TrimSuffix(relPath, ".git")) + } + + relPath = filepath.ToSlash(filepath.Clean(relPath)) + + if filepath.IsAbs(relPath) || strings.HasPrefix(relPath, "../") || relPath == ".." { + return fmt.Errorf("submodule add: path %q must be inside the repository", relPath) + } + + cloneURL := resolveCloneURL(repoArg) + cloneTarget := filepath.Join(parentRoot, relPath) + + if err := ensureCloneTargetAvailable(cloneTarget); err != nil { + return err + } + + sub, err := git.PlainClone(cloneTarget, &git.CloneOptions{URL: cloneURL}) + if err != nil { + return fmt.Errorf("submodule add: clone %q: %w", repoArg, err) + } + + head, err := sub.Head() + if err != nil { + return fmt.Errorf("submodule add: read clone HEAD: %w", err) + } + + if err := upsertGitmodulesEntry(parentRoot, relPath, cloneURL); err != nil { + return err + } + + if _, err := parentWT.Add(".gitmodules"); err != nil { + return fmt.Errorf("submodule add: stage .gitmodules: %w", err) + } + + if err := stageGitlinkEntry(parent, relPath, head.Hash()); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Adding existing repo at '%s' to the index\n", relPath) + + return nil +} + +// upsertGitmodulesEntry creates or updates a single `[submodule ""]` +// section in /.gitmodules. The submodule's name matches its path +// — upstream's default and what go-git's Submodules() expects. +func upsertGitmodulesEntry(repoRoot, path, url string) error { + gitmodulesPath := filepath.Join(repoRoot, ".gitmodules") + + modules := config.NewModules() + + if data, err := os.ReadFile(gitmodulesPath); err == nil { + if err := modules.Unmarshal(data); err != nil { + return fmt.Errorf("parse existing .gitmodules: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("read .gitmodules: %w", err) + } + + modules.Submodules[path] = &config.Submodule{Name: path, Path: path, URL: url} + + out, err := modules.Marshal() + if err != nil { + return fmt.Errorf("marshal .gitmodules: %w", err) + } + + return os.WriteFile(gitmodulesPath, out, 0o644) +} + +// stageGitlinkEntry inserts (or replaces) an index entry at `path` with +// mode 160000 (gitlink) pointing at `hash`. The replace-or-append logic +// keeps the index ordered enough for go-git's encoder, which sorts on +// write. +func stageGitlinkEntry(repo *git.Repository, path string, hash plumbing.Hash) error { + idx, err := repo.Storer.Index() + if err != nil { + return fmt.Errorf("read index: %w", err) + } + + replaced := false + + for i, e := range idx.Entries { + if e.Name == path && e.Stage == index.Merged { + idx.Entries[i] = &index.Entry{ + Hash: hash, + Name: path, + Mode: filemode.Submodule, + } + replaced = true + + break + } + } + + if !replaced { + idx.Entries = append(idx.Entries, &index.Entry{ + Hash: hash, + Name: path, + Mode: filemode.Submodule, + }) + } + + sort.Slice(idx.Entries, func(i, j int) bool { + return idx.Entries[i].Name < idx.Entries[j].Name + }) + + return repo.Storer.SetIndex(idx) +} + +// openWorktree opens the repository found relative to the cwd and returns +// its worktree. It's the shape several submodule subcommands need; using a +// helper here keeps each RunE one line shorter than open-then-Worktree. +func openWorktree() (*git.Worktree, error) { + r, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + + w, err := r.Worktree() + if err != nil { + return nil, fmt.Errorf("open worktree: %w", err) + } + + return w, nil +} From 398ee9ea10e25bf821b390f03a43168f6ece429c Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Thu, 14 May 2026 05:33:23 +0100 Subject: [PATCH 20/24] gogit: propagate -c overrides to .git/config for storage construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gogit -c = ` used to populate an in-memory map only. Commands that consult that map directly (via hasConfigOverride / configBool) saw the override, but go-git's filesystem storage reads .git/config eagerly at PlainOpen-time and never saw it — so storage- governed config like index.skipHash was effectively unreachable from the CLI. Fix: in applyConfigOverridesFromFlags, after populating the map, also patch each override into .git/config so storage construction picks them up. Snapshot the original contents and restore on exit (success or error) so the file is unchanged from the user's perspective. The in-memory map still exists for callers that depend on it. If the process is killed mid-command (e.g. SIGKILL) the on-disk .git/config will be left with the overrides persisted. Acceptable tradeoff for the simplicity; documented inline. Verified: `gogit -c index.skipHash=true add f` now produces an index with a zero trailer; the parent's .git/config is unchanged after the command. Errors during the patched command still restore the original config. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 4f69273d3704 --- cmd/gogit/config.go | 90 ++++++++++++++++++++++++++++++++++++++++++++- cmd/gogit/main.go | 7 ++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/cmd/gogit/config.go b/cmd/gogit/config.go index 5eeb8a3..0687064 100644 --- a/cmd/gogit/config.go +++ b/cmd/gogit/config.go @@ -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" ) 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 + configBackup []byte + configBackupCreated bool ) // splitKV splits "=" into (key, value, true). Invalid input @@ -41,7 +51,10 @@ func resetConfigOverrides() { } // 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) @@ -52,7 +65,80 @@ func applyConfigOverridesFromFlags() error { 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 + } + + 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) + } + 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) + } + + configBackupPath = "" + configBackup = nil + configBackupCreated = false } // hasConfigOverride reports whether key has been explicitly set via -c. diff --git a/cmd/gogit/main.go b/cmd/gogit/main.go index 1b559a1..e5a75b3 100644 --- a/cmd/gogit/main.go +++ b/cmd/gogit/main.go @@ -106,6 +106,13 @@ func main() { os.Args = append(os.Args[:1], rest...) err := rootCmd.Execute() + + // Restore any .git/config we patched on this command's behalf via `-c` + // overrides. Runs on both success and error paths so the on-disk file + // is back to its starting contents before the process exits. A SIGKILL + // or similar abrupt termination would skip this — accepted tradeoff. + restoreConfigBackup() + if err != nil { var rerr *transport.RemoteError if errors.As(err, &rerr) { From fe664828809f1867ca5b8bf48709bc5707770cee Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Thu, 14 May 2026 09:16:54 +0100 Subject: [PATCH 21/24] gogit: drop ensureCloneTargetAvailable, rely on go-git's own check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-git's PlainCloneContext already invokes checkTargetDirIsEmpty (repository.go:582 → checkTargetDirIsEmpty) before initialising a clone, returning ErrTargetDirNotEmpty for any non-empty target and "path is not a directory" for non-directory targets. Our wrapper was re-implementing the same guard. Removing the duplicate also drops the upstream-shaped error string we were emitting; go-git's "destination path already exists and is not empty " replaces it. The t5601 cases that check this path (22, 23) only assert exit codes, so the message change is behaviour-equivalent for our graduation. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: dc91b7137048 --- cmd/gogit/clone.go | 40 +++++----------------------------------- cmd/gogit/submodule.go | 4 ---- 2 files changed, 5 insertions(+), 39 deletions(-) diff --git a/cmd/gogit/clone.go b/cmd/gogit/clone.go index 5b2b25a..e5677c3 100644 --- a/cmd/gogit/clone.go +++ b/cmd/gogit/clone.go @@ -12,6 +12,11 @@ import ( "github.com/spf13/cobra" ) +// Note: go-git's PlainCloneContext calls checkTargetDirIsEmpty before +// initialising the clone, so we don't need a wrapper to guard against +// non-empty / non-directory targets — go-git already returns +// ErrTargetDirNotEmpty in those cases. + var ( cloneBare bool cloneProgress bool @@ -67,10 +72,6 @@ var cloneCmd = &cobra.Command{ fmt.Fprintf(cmd.ErrOrStderr(), "Cloning into '%s'...\n", dir) - if err := ensureCloneTargetAvailable(dir); err != nil { - return err - } - _, err = git.PlainClone(dir, &opts) return err @@ -100,37 +101,6 @@ func resolveCloneURL(arg string) string { return abs } -// ensureCloneTargetAvailable matches upstream's pre-clone check: the target -// must not already be a non-empty directory, and must not be a non-directory -// path (e.g. an existing file). go-git's PlainClone will happily merge a -// clone into a populated directory, which lets clones silently overwrite -// unrelated content. -func ensureCloneTargetAvailable(dir string) error { - info, err := os.Stat(dir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - - return err - } - - if !info.IsDir() { - return fmt.Errorf("fatal: destination path %q already exists and is not an empty directory", dir) - } - - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - - if len(entries) > 0 { - return fmt.Errorf("fatal: destination path %q already exists and is not an empty directory", dir) - } - - return nil -} - // 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 { diff --git a/cmd/gogit/submodule.go b/cmd/gogit/submodule.go index 78e0577..51fa68d 100644 --- a/cmd/gogit/submodule.go +++ b/cmd/gogit/submodule.go @@ -163,10 +163,6 @@ func runSubmoduleAdd(cmd *cobra.Command, args []string) error { cloneURL := resolveCloneURL(repoArg) cloneTarget := filepath.Join(parentRoot, relPath) - if err := ensureCloneTargetAvailable(cloneTarget); err != nil { - return err - } - sub, err := git.PlainClone(cloneTarget, &git.CloneOptions{URL: cloneURL}) if err != nil { return fmt.Errorf("submodule add: clone %q: %w", repoArg, err) From 75d91da0bc02a16480508239031b899c984bbda0 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Thu, 14 May 2026 09:18:53 +0100 Subject: [PATCH 22/24] gogit: move submodule add logic to internal/submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Add` operation is upstreamable as Worktree.AddSubmodule(url, path) in go-git itself: clone the URL, write .gitmodules, stage a gitlink in the parent's index. Moving it under internal/submodule/ makes the upstreaming target legible at a glance, in keeping with how internal/plumbing/{format,object} mirror their eventual go-git homes. cmd/gogit/submodule.go shrinks to the cobra wiring plus argument parsing (path defaulting, URL resolution). internal/submodule.Add takes a *git.Repository, the URL, and a path, and returns the cloned submodule's repository handle. No behaviour change — submodule add smoke still produces the same .gitmodules entry, gitlink index entry, and parent worktree state in both sha1 and sha256 modes. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: f3551598388e --- cmd/gogit/submodule.go | 156 +++++--------------------------------- internal/submodule/add.go | 136 +++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 136 deletions(-) create mode 100644 internal/submodule/add.go diff --git a/cmd/gogit/submodule.go b/cmd/gogit/submodule.go index 51fa68d..bb016b5 100644 --- a/cmd/gogit/submodule.go +++ b/cmd/gogit/submodule.go @@ -2,16 +2,11 @@ package main import ( "fmt" - "os" "path/filepath" - "sort" "strings" + internalsubmodule "github.com/go-git/cli/internal/submodule" "github.com/go-git/go-git/v6" - "github.com/go-git/go-git/v6/config" - "github.com/go-git/go-git/v6/plumbing" - "github.com/go-git/go-git/v6/plumbing/filemode" - "github.com/go-git/go-git/v6/plumbing/format/index" "github.com/spf13/cobra" ) @@ -116,144 +111,33 @@ var submoduleAddCmd = &cobra.Command{ Short: "Add the given repository as a submodule", Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { - return runSubmoduleAdd(cmd, args) - }, - DisableFlagsInUseLine: true, - SilenceUsage: true, - SilenceErrors: true, -} - -// runSubmoduleAdd implements `gogit submodule add []`: -// resolve the URL (local paths get expanded to absolute), clone the -// submodule into , write a `.gitmodules` entry, then stage the -// submodule path in the parent's index as a gitlink (mode 160000 with -// the submodule's HEAD as the hash). go-git's Worktree.Add doesn't -// gitlink-stage, so the index entry is written directly via -// Storer.Index / SetIndex. -func runSubmoduleAdd(cmd *cobra.Command, args []string) error { - repoArg := args[0] - - parent, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) - if err != nil { - return fmt.Errorf("open parent repository: %w", err) - } - - defer parent.Close() - - parentWT, err := parent.Worktree() - if err != nil { - return fmt.Errorf("open parent worktree: %w", err) - } - - parentRoot := parentWT.Filesystem().Root() - - relPath := args[0] - if len(args) == 2 { - relPath = args[1] - } else { - relPath = filepath.Base(strings.TrimSuffix(relPath, ".git")) - } - - relPath = filepath.ToSlash(filepath.Clean(relPath)) - - if filepath.IsAbs(relPath) || strings.HasPrefix(relPath, "../") || relPath == ".." { - return fmt.Errorf("submodule add: path %q must be inside the repository", relPath) - } - - cloneURL := resolveCloneURL(repoArg) - cloneTarget := filepath.Join(parentRoot, relPath) - - sub, err := git.PlainClone(cloneTarget, &git.CloneOptions{URL: cloneURL}) - if err != nil { - return fmt.Errorf("submodule add: clone %q: %w", repoArg, err) - } - - head, err := sub.Head() - if err != nil { - return fmt.Errorf("submodule add: read clone HEAD: %w", err) - } - - if err := upsertGitmodulesEntry(parentRoot, relPath, cloneURL); err != nil { - return err - } - - if _, err := parentWT.Add(".gitmodules"); err != nil { - return fmt.Errorf("submodule add: stage .gitmodules: %w", err) - } - - if err := stageGitlinkEntry(parent, relPath, head.Hash()); err != nil { - return err - } - - fmt.Fprintf(cmd.OutOrStdout(), "Adding existing repo at '%s' to the index\n", relPath) - - return nil -} - -// upsertGitmodulesEntry creates or updates a single `[submodule ""]` -// section in /.gitmodules. The submodule's name matches its path -// — upstream's default and what go-git's Submodules() expects. -func upsertGitmodulesEntry(repoRoot, path, url string) error { - gitmodulesPath := filepath.Join(repoRoot, ".gitmodules") - - modules := config.NewModules() - - if data, err := os.ReadFile(gitmodulesPath); err == nil { - if err := modules.Unmarshal(data); err != nil { - return fmt.Errorf("parse existing .gitmodules: %w", err) + parent, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return fmt.Errorf("open parent repository: %w", err) } - } else if !os.IsNotExist(err) { - return fmt.Errorf("read .gitmodules: %w", err) - } - modules.Submodules[path] = &config.Submodule{Name: path, Path: path, URL: url} + defer parent.Close() - out, err := modules.Marshal() - if err != nil { - return fmt.Errorf("marshal .gitmodules: %w", err) - } - - return os.WriteFile(gitmodulesPath, out, 0o644) -} - -// stageGitlinkEntry inserts (or replaces) an index entry at `path` with -// mode 160000 (gitlink) pointing at `hash`. The replace-or-append logic -// keeps the index ordered enough for go-git's encoder, which sorts on -// write. -func stageGitlinkEntry(repo *git.Repository, path string, hash plumbing.Hash) error { - idx, err := repo.Storer.Index() - if err != nil { - return fmt.Errorf("read index: %w", err) - } - - replaced := false + relPath := args[0] + if len(args) == 2 { + relPath = args[1] + } else { + relPath = filepath.Base(strings.TrimSuffix(relPath, ".git")) + } - for i, e := range idx.Entries { - if e.Name == path && e.Stage == index.Merged { - idx.Entries[i] = &index.Entry{ - Hash: hash, - Name: path, - Mode: filemode.Submodule, - } - replaced = true + cloneURL := resolveCloneURL(args[0]) - break + if _, err := internalsubmodule.Add(parent, cloneURL, relPath); err != nil { + return err } - } - if !replaced { - idx.Entries = append(idx.Entries, &index.Entry{ - Hash: hash, - Name: path, - Mode: filemode.Submodule, - }) - } - - sort.Slice(idx.Entries, func(i, j int) bool { - return idx.Entries[i].Name < idx.Entries[j].Name - }) + fmt.Fprintf(cmd.OutOrStdout(), "Adding existing repo at '%s' to the index\n", relPath) - return repo.Storer.SetIndex(idx) + return nil + }, + DisableFlagsInUseLine: true, + SilenceUsage: true, + SilenceErrors: true, } // openWorktree opens the repository found relative to the cwd and returns diff --git a/internal/submodule/add.go b/internal/submodule/add.go new file mode 100644 index 0000000..b7f974f --- /dev/null +++ b/internal/submodule/add.go @@ -0,0 +1,136 @@ +// Package submodule holds submodule operations that aren't yet exposed by +// go-git itself. The logic here mirrors what would eventually become +// Worktree.AddSubmodule and related helpers in +// github.com/go-git/go-git/v6; the package layout keeps that intent +// explicit so callers can find the upstreaming target at a glance. +package submodule + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/filemode" + "github.com/go-git/go-git/v6/plumbing/format/index" +) + +// Add registers a new submodule at `path` (relative to repo's worktree) +// pointing at `url`. It clones the URL into the worktree at `path`, writes +// a corresponding entry to `.gitmodules`, stages `.gitmodules`, and stages +// a gitlink (mode 160000, hash = clone HEAD) at `path` in the parent's +// index. Returns the cloned submodule repository. +// +// Mirrors `git submodule add ` on its happy path. Gaps vs +// upstream: the submodule lives at `/.git`, not under +// `.git/modules//` with a gitfile pointer — implementing that +// indirection is a follow-up that should land alongside upstreaming. +func Add(repo *git.Repository, url, path string) (*git.Repository, error) { + if repo == nil { + return nil, fmt.Errorf("submodule.Add: nil parent repository") + } + + clean := filepath.ToSlash(filepath.Clean(path)) + if filepath.IsAbs(clean) || strings.HasPrefix(clean, "../") || clean == ".." { + return nil, fmt.Errorf("submodule.Add: path %q must be inside the repository", path) + } + + worktree, err := repo.Worktree() + if err != nil { + return nil, fmt.Errorf("submodule.Add: open parent worktree: %w", err) + } + + target := filepath.Join(worktree.Filesystem().Root(), clean) + + sub, err := git.PlainClone(target, &git.CloneOptions{URL: url}) + if err != nil { + return nil, fmt.Errorf("submodule.Add: clone %q: %w", url, err) + } + + head, err := sub.Head() + if err != nil { + return nil, fmt.Errorf("submodule.Add: read clone HEAD: %w", err) + } + + if err := writeGitmodulesEntry(worktree.Filesystem().Root(), clean, url); err != nil { + return nil, err + } + + if _, err := worktree.Add(".gitmodules"); err != nil { + return nil, fmt.Errorf("submodule.Add: stage .gitmodules: %w", err) + } + + if err := stageGitlink(repo, clean, head.Hash()); err != nil { + return nil, err + } + + return sub, nil +} + +// writeGitmodulesEntry creates or replaces the `[submodule ""]` +// section in /.gitmodules. The submodule's name matches its +// path — upstream's default and what go-git's Submodules() expects. +func writeGitmodulesEntry(repoRoot, path, url string) error { + gitmodulesPath := filepath.Join(repoRoot, ".gitmodules") + + modules := config.NewModules() + + if data, err := os.ReadFile(gitmodulesPath); err == nil { + if err := modules.Unmarshal(data); err != nil { + return fmt.Errorf("submodule.Add: parse existing .gitmodules: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("submodule.Add: read .gitmodules: %w", err) + } + + modules.Submodules[path] = &config.Submodule{Name: path, Path: path, URL: url} + + out, err := modules.Marshal() + if err != nil { + return fmt.Errorf("submodule.Add: marshal .gitmodules: %w", err) + } + + return os.WriteFile(gitmodulesPath, out, 0o644) +} + +// stageGitlink inserts (or replaces) an index entry at `path` with mode +// 160000 (gitlink) pointing at `hash`. go-git's Worktree.Add reads from +// the filesystem and never produces a gitlink, so this is open-coded +// against Storer.Index / SetIndex. +func stageGitlink(repo *git.Repository, path string, hash plumbing.Hash) error { + idx, err := repo.Storer.Index() + if err != nil { + return fmt.Errorf("submodule.Add: read index: %w", err) + } + + entry := &index.Entry{ + Hash: hash, + Name: path, + Mode: filemode.Submodule, + } + + replaced := false + + for i, e := range idx.Entries { + if e.Name == path && e.Stage == index.Merged { + idx.Entries[i] = entry + replaced = true + + break + } + } + + if !replaced { + idx.Entries = append(idx.Entries, entry) + } + + sort.Slice(idx.Entries, func(i, j int) bool { + return idx.Entries[i].Name < idx.Entries[j].Name + }) + + return repo.Storer.SetIndex(idx) +} From aae2bfe8c5bbdbcf31e5f9192b01b0047fd6091b Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Thu, 14 May 2026 09:26:17 +0100 Subject: [PATCH 23/24] gogit: resolve clone target to absolute before handing to PlainClone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-git's checkTargetDirIsEmpty uses osfs.Default, which is bound to "/" — relative paths look up against the wrong root and silently report "empty", so the upstream guard never fires for a non-empty target like `./target-4`. Resolving the destination via filepath.Abs before calling PlainClone makes the guard work as documented. Verified: `gogit clone src target-4` where target-4 contains a file now fails with "destination path already exists and is not empty" and exit 1, restoring the t5601 case 22 / 23 behaviour we lost when we removed our duplicate wrapper. Empty and nonexistent relative targets still clone successfully. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 37e51c089d70 --- cmd/gogit/clone.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cmd/gogit/clone.go b/cmd/gogit/clone.go index e5677c3..34fcc1e 100644 --- a/cmd/gogit/clone.go +++ b/cmd/gogit/clone.go @@ -13,9 +13,10 @@ import ( ) // Note: go-git's PlainCloneContext calls checkTargetDirIsEmpty before -// initialising the clone, so we don't need a wrapper to guard against -// non-empty / non-directory targets — go-git already returns -// ErrTargetDirNotEmpty in those cases. +// 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 @@ -72,7 +73,12 @@ 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 }, From ccff723f295d7b4ceeaaca5788fd0fde03aa1417 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Thu, 14 May 2026 09:27:35 +0100 Subject: [PATCH 24/24] conformance: expand existing graduations to sha256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the five originally sha1-only entries pass cleanly under sha256 too; tag them @sha1,sha256 so each runs in both modes: t2008-checkout-subdir.sh — basic checkout, hash-agnostic t5308-pack-detect-duplicates.sh — pack dedup, no oid pinning t1600-index.sh 1-5,7 — index v2/v3/v4 round-trip works at 32-byte width Two stay sha1-only with inline reasoning: t5325-reverse-index.sh — sha256 pack writer / reader trailer checksum mismatch (real gogit/go-git bug to file separately). t1601-index-bogus.sh — premise is "null sha1 in tree" / "read-tree refuses null sha1" — doesn't translate to sha256 since the same construction yields a non-null sha256 hash. Assisted-by: Claude Opus 4.7 Signed-off-by: Paulo Gomes Entire-Checkpoint: 2710bcc4ee2f --- conformance/tests.txt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/conformance/tests.txt b/conformance/tests.txt index 52ffcde..acd4acb 100644 --- a/conformance/tests.txt +++ b/conformance/tests.txt @@ -5,10 +5,18 @@ # (default: sha1 only). # selector — upstream `--run=` form, e.g. `1-5,7` or `!2`. -t2008-checkout-subdir.sh -t5308-pack-detect-duplicates.sh +t2008-checkout-subdir.sh @sha1,sha256 +t5308-pack-detect-duplicates.sh @sha1,sha256 +# t5325 stays sha1-only: setup case 1 fails in sha256 mode with +# "malformed pack file: checksum mismatch" — gogit's pack writer +# produces a sha256-formatted pack whose trailer doesn't round-trip +# through go-git's reader. Real bug worth filing separately. t5325-reverse-index.sh -t1600-index.sh 1-5,7 +t1600-index.sh @sha1,sha256 1-5,7 +# t1601 stays sha1-only: the premise is "tree with null sha1" / "read-tree +# refuses null sha1", which doesn't translate to sha256 — building the +# same tree under sha256 produces a non-null sha256 hash and the +# null-rejection no longer triggers. t1601-index-bogus.sh # t0001 sha256 surface — runtime-numbered cases (the `for hash in sha1 sha256` # loop expands 1 source-level case into 2, shifting the upper cases by one):