Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package ghq for openSUSE:Factory checked in at 2026-11-13 18:30:05 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/ghq (Old) and /work/SRC/openSUSE:Factory/.ghq.new.1251 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "ghq" Fri Nov 13 18:30:05 2026 rev:3 rq:1396972 version:1.12.0 Changes: -------- --- /work/SRC/openSUSE:Factory/ghq/ghq.changes 2026-10-01 16:46:35.244889883 +0200 +++ /work/SRC/openSUSE:Factory/.ghq.new.1251/ghq.changes 2026-11-13 18:33:24.015394313 +0100 @@ -1,0 +2,16 @@ +Sun Nov 08 12:52:34 UTC 2026 - Gakuto Furuya <[email protected]> + +- Update to version 1.12.0: + * Reject unsafe repository URL paths + * Process all repository arguments in ghq rm + * Avoid data races in parallel ghq get operations + * Avoid panics for inaccessible repository roots + * Reject unknown --vcs values + * Use the primary root with multiple GHQ_ROOT entries + * Synchronize submodule URLs before updating and descending + * Clarify URL-specific configuration + * Update Go dependencies (urfave/cli/v3 3.14.0 and golang.org/x/*) +- Use the distribution Go toolchain without automatic downloads + during build and tests. + +------------------------------------------------------------------- Old: ---- ghq-1.11.2.tar.zst New: ---- ghq-1.12.0.tar.zst ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ ghq.spec ++++++ --- /var/tmp/diff_new_pack.2uE4Ag/_old 2026-11-13 18:33:27.178528233 +0100 +++ /var/tmp/diff_new_pack.2uE4Ag/_new 2026-11-13 18:33:27.180528318 +0100 @@ -17,7 +17,7 @@ Name: ghq -Version: 1.11.2 +Version: 1.12.0 Release: 0 Summary: Remote repository management made easy License: MIT @@ -67,6 +67,7 @@ %autosetup -a 1 -p1 %build +export GOTOOLCHAIN=local export GOFLAGS="-buildmode=pie -trimpath -mod=vendor -modcacherw" go build -o bin/%{name} -ldflags="-X main.revision=%{version}" @@ -77,6 +78,7 @@ install -Dm644 misc/fish/%{name}.fish %{buildroot}%{_datadir}/fish/vendor_completions.d/%{name}.fish %check +export GOTOOLCHAIN=local export GOFLAGS="-mod=vendor -modcacherw" git config --global user.name test git config --global user.email [email protected] ++++++ _service ++++++ --- /var/tmp/diff_new_pack.2uE4Ag/_old 2026-11-13 18:33:27.218529927 +0100 +++ /var/tmp/diff_new_pack.2uE4Ag/_new 2026-11-13 18:33:27.222530096 +0100 @@ -3,7 +3,7 @@ <param name="url">https://github.com/x-motemen/ghq</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">v1.11.2</param> + <param name="revision">v1.12.0</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> @@ -14,7 +14,7 @@ <param name="compression">zst</param> </service> <service name="go_modules" mode="manual"> - <param name="archive">ghq-1.11.2.tar.zst</param> + <param name="archive">ghq-1.12.0.tar.zst</param> </service> </services> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.2uE4Ag/_old 2026-11-13 18:33:27.247531154 +0100 +++ /var/tmp/diff_new_pack.2uE4Ag/_new 2026-11-13 18:33:27.251531324 +0100 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/x-motemen/ghq</param> - <param name="changesrevision">b9273dd116d09b423073b83e7c0d82ef508d985a</param></service></servicedata> + <param name="changesrevision">2db7c02043f00d12edde29eb27cac9257d6fcf58</param></service></servicedata> (No newline at EOF) ++++++ ghq-1.11.2.tar.zst -> ghq-1.12.0.tar.zst ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/CHANGELOG.md new/ghq-1.12.0/CHANGELOG.md --- old/ghq-1.11.2/CHANGELOG.md 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/CHANGELOG.md 2026-11-03 16:00:56.000000000 +0100 @@ -1,5 +1,31 @@ # Changelog +## [v1.12.0](https://github.com/x-motemen/ghq/compare/v1.11.2...v1.12.0) - 2026-11-03 + +- Reject unsafe repository URL paths by @hezhizhen in https://github.com/x-motemen/ghq/pull/557 +- fix(rm): process all repository arguments by @hezhizhen in https://github.com/x-motemen/ghq/pull/568 +- fix(get): avoid data races in parallel mode by @hezhizhen in https://github.com/x-motemen/ghq/pull/569 +- fix: avoid panics for inaccessible repository roots by @hezhizhen in https://github.com/x-motemen/ghq/pull/570 +- fix: reject unknown --vcs values by @hezhizhen in https://github.com/x-motemen/ghq/pull/571 +- docs: clarify URL-specific configuration by @dottex in https://github.com/x-motemen/ghq/pull/572 +- Fix paths with multiple GHQ_ROOT entries by @hezhizhen in https://github.com/x-motemen/ghq/pull/573 +- Sync submodule URLs before updating by @yoichi in https://github.com/x-motemen/ghq/pull/574 +- chore(deps): bump reviewdog/action-actionlint from 1.77.0 to 1.80.0 by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/565 +- chore(deps): bump reviewdog/action-misspell from 1.30.1 to 1.30.2 by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/563 +- chore(deps): bump crate-ci/typos from 1.50.3 to 1.51.1 by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/562 +- chore(deps): bump the golang-x group with 3 updates by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/561 +- chore(deps): bump the actions group with 2 updates by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/560 +- chore(deps): bump Songmu/tagpr from 1.21.1 to 1.21.2 by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/564 +- chore(deps): bump github.com/urfave/cli/v3 from 3.13.0 to 3.14.0 by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/558 +- fix(git): synchronize submodule URLs before descending by @Songmu in https://github.com/x-motemen/ghq/pull/576 +- Add Actions lockfile and complementary Renovate updates by @Songmu in https://github.com/x-motemen/ghq/pull/577 +- chore(deps): bump golang.org/x/net from 0.61.0 to 0.62.0 in the golang-x group by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/578 +- Keep GitHub Actions updates with Dependabot by @Songmu in https://github.com/x-motemen/ghq/pull/579 +- chore(deps): bump Songmu/tagpr from 1.21.2 to 1.21.4 by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/580 +- chore(deps): bump reviewdog/action-actionlint from 1.80.0 to 1.81.1 by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/581 +- chore(deps): bump reviewdog/action-staticcheck from 1.32.1 to 1.33.0 by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/583 +- chore(deps): bump reviewdog/action-misspell from 1.30.2 to 1.30.4 by @dependabot[bot] in https://github.com/x-motemen/ghq/pull/582 + ## [v1.11.2](https://github.com/x-motemen/ghq/compare/v1.11.1...v1.11.2) - 2026-09-29 - Restore release asset compatibility by @Songmu in https://github.com/x-motemen/ghq/pull/555 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/README.adoc new/ghq-1.12.0/README.adoc --- old/ghq-1.11.2/README.adoc 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/README.adoc 2026-11-03 16:00:56.000000000 +0100 @@ -148,6 +148,29 @@ root = ~/myproj .... +The subsection URL selects remote URLs to which these settings apply; it is +not a local path or a URL rewrite. In this example, repositories under +`https://git.example.com/repos/` use Git and are cloned beneath `~/myproj`. +For example, `ghq get https://git.example.com/repos/team/tool` creates a new +clone at `~/myproj/git.example.com/repos/team/tool`. The hostname and repository +path are preserved; `root` only changes the directory beneath which they are placed. +See https://git-scm.com/docs/git-config#Documentation/git-config.txt---get-urlmatch[Git's URL matching rules] +for details. + +HTTPS and SSH URLs are matched separately. To apply the same settings to +`[email protected]:repos/team/tool`, which ghq normalizes to +`ssh://[email protected]/repos/team/tool`, add an SSH subsection: + +.... +[ghq "ssh://[email protected]/repos/"] +vcs = git +root = ~/myproj +.... + +If the repository already exists under one of the configured roots, ghq reuses +that clone rather than moving it to the URL-specific root. Setting `GHQ_ROOT` +overrides both the common and URL-specific roots. + == ENVIRONMENT VARIABLES GHQ_ROOT:: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/cmd_create_test.go new/ghq-1.12.0/cmd_create_test.go --- old/ghq-1.11.2/cmd_create_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/cmd_create_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -110,6 +110,16 @@ os.MkdirAll(f, 0755) }, errStr: "already exists and not empty", + }, { + name: "multiple roots", + input: []string{"create", "--vcs=git", "motemen/ghq-multiple-roots"}, + setup: func(t *testing.T) { + setEnv(t, envGhqRoot, tmpd+string(os.PathListSeparator)+filepath.Join(tmpd, "secondary")) + _localRepositoryRoots = nil + localRepoOnce = &sync.Once{} + }, + want: []string{"git", "init"}, + wantDir: filepath.Join(tmpd, "github.com/motemen/ghq-multiple-roots"), }} for _, tc := range testCases { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/cmd_get.go new/ghq-1.12.0/cmd_get.go --- old/ghq-1.11.2/cmd_get.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/cmd_get.go 2026-11-03 16:00:56.000000000 +0100 @@ -66,7 +66,6 @@ argCnt int getInfo getInfo // For fetching and looking a single repo scr scanner - err error ) if len(args) > 0 { scr = &sliceScanner{slice: args} @@ -87,11 +86,15 @@ } argCnt += 1 if parallel { + isFirst := argCnt == 1 sem <- struct{}{} eg.Go(func() error { defer func() { <-sem }() info, getErr := g.get(ctx, target) - getInfo, err = info, getErr + // Only one worker saves the result, read after eg.Wait below. + if isFirst { + getInfo = info + } if getErr != nil { logger.Logf("error", "failed to get %q: %s", target, getErr) } else if info.localRepository != nil { @@ -100,6 +103,7 @@ return nil }) } else { + var err error if getInfo, err = g.get(ctx, target); err != nil { return fmt.Errorf("failed to get %q: %w", target, err) } @@ -111,10 +115,10 @@ } } } - if err = scr.Err(); err != nil { + if err := scr.Err(); err != nil { return fmt.Errorf("error occurred while reading input: %w", err) } - if err = eg.Wait(); err != nil { + if err := eg.Wait(); err != nil { return err } if andLook { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/cmd_get_test.go new/ghq-1.12.0/cmd_get_test.go --- old/ghq-1.11.2/cmd_get_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/cmd_get_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -8,6 +8,7 @@ "os/exec" "path/filepath" "reflect" + "slices" "strings" "testing" @@ -614,3 +615,102 @@ } }) } + +func TestDoGet_parallelErrors(t *testing.T) { + for _, fromStdin := range []bool{false, true} { + t.Run(fmt.Sprintf("stdin=%t", fromStdin), func(t *testing.T) { + withFakeGitBackend(t, func(t *testing.T, tmpRoot string, _ *_cloneArgs, _ *_updateArgs) { + var inputs, wantPaths, failures []string + // Exceed the parallel worker limit and mix successes with errors. + for i := range 12 { + if i%2 == 0 { + repo := fmt.Sprintf("github.com/ghq-test/parallel-%d", i) + localDir := filepath.Join(tmpRoot, repo) + if err := os.MkdirAll(filepath.Join(localDir, ".git"), 0755); err != nil { + t.Fatal(err) + } + inputs = append(inputs, repo) + wantPaths = append(wantPaths, localDir) + } else { + repo := fmt.Sprintf("https://[invalid-%d", i) + inputs = append(inputs, repo) + failures = append(failures, repo) + } + } + + var logs bytes.Buffer + logger.SetOutput(&logs) + defer logger.SetOutput(os.Stderr) + args := []string{"", "get", "--parallel"} + if !fromStdin { + args = append(args, inputs...) + } + var runErr error + run := func() { runErr = newApp().Run(context.Background(), args) } + var out string + var err error + if fromStdin { + out, _, err = captureWithInput(inputs, run) + } else { + out, _, err = capture(run) + } + if err != nil { + t.Fatal(err) + } + if runErr != nil { + t.Fatalf("parallel get should log individual errors and continue: %v", runErr) + } + gotPaths := strings.Split(strings.TrimSpace(out), "\n") + slices.Sort(gotPaths) + slices.Sort(wantPaths) + if !reflect.DeepEqual(gotPaths, wantPaths) { + t.Errorf("printed paths = %q, want %q", gotPaths, wantPaths) + } + for _, repo := range failures { + message := fmt.Sprintf("failed to get %q:", repo) + if strings.Count(logs.String(), message) != 1 { + t.Errorf("expected one error for %q, got logs: %s", repo, &logs) + } + } + }) + }) + } +} + +func TestDoGet_parallelLook(t *testing.T) { + for _, repoCount := range []int{1, 8} { + t.Run(fmt.Sprintf("repos=%d", repoCount), func(t *testing.T) { + withFakeGitBackend(t, func(t *testing.T, tmpRoot string, _ *_cloneArgs, _ *_updateArgs) { + args := []string{"", "get", "--parallel", "--look"} + for i := range repoCount { + repo := fmt.Sprintf("github.com/ghq-test/parallel-%d", i) + if err := os.MkdirAll(filepath.Join(tmpRoot, repo, ".git"), 0755); err != nil { + t.Fatal(err) + } + args = append(args, repo) + } + + originalRunner := cmdutil.CommandRunner + defer func() { cmdutil.CommandRunner = originalRunner }() + var shellCmd *exec.Cmd + cmdutil.CommandRunner = func(cmd *exec.Cmd) error { + shellCmd = cmd + return nil + } + if err := newApp().Run(context.Background(), args); err != nil { + t.Fatal(err) + } + if shellCmd == nil { + t.Fatal("expected --look to start a shell") + } + wantDir := filepath.Join(tmpRoot, "github.com/ghq-test/parallel-0") + if shellCmd.Dir != wantDir { + t.Errorf("shell directory = %q, want %q", shellCmd.Dir, wantDir) + } + if !slices.Contains(shellCmd.Env, "GHQ_LOOK=github.com/ghq-test/parallel-0") { + t.Errorf("shell environment is missing GHQ_LOOK for the first repository") + } + }) + }) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/cmd_list_test.go new/ghq-1.12.0/cmd_list_test.go --- old/ghq-1.11.2/cmd_list_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/cmd_list_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -1,7 +1,9 @@ package main import ( + "bytes" "context" + "errors" "os" "path/filepath" "runtime" @@ -9,6 +11,8 @@ "strings" "sync" "testing" + + "github.com/x-motemen/ghq/logger" ) func TestCommandList(t *testing.T) { @@ -214,6 +218,74 @@ } } +func TestDoList_unsearchableRootParent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires Unix directory permissions") + } + parent := newTempDir(t) + root := filepath.Join(parent, "root") + if err := os.Mkdir(root, 0755); err != nil { + t.Fatal(err) + } + otherRoot := newTempDir(t) + if err := os.MkdirAll(filepath.Join(otherRoot, "github.com/motemen/ghq/.git"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(parent, 0600); err != nil { + t.Fatal(err) + } + defer os.Chmod(parent, 0755) + if _, err := os.Stat(root); !os.IsPermission(err) { + t.Skipf("cannot reproduce permission-denied stat: %v", err) + } + defer func(orig []string) { _localRepositoryRoots = orig }(_localRepositoryRoots) + t.Setenv(envGhqRoot, strings.Join([]string{root, otherRoot}, string(os.PathListSeparator))) + _localRepositoryRoots = nil + localRepoOnce = &sync.Once{} + + var out, logs bytes.Buffer + app := newApp() + app.Writer = &out + logger.SetOutput(&logs) + defer logger.SetOutput(os.Stderr) + if err := app.Run(context.Background(), []string{"ghq", "list"}); err != nil { + t.Fatal(err) + } + if out.String() != "github.com/motemen/ghq\n" { + t.Errorf("got: %q, expect repository from accessible root", out.String()) + } + if !strings.Contains(logs.String(), root+": Permission denied") { + t.Errorf("missing permission warning for %s: %s", root, &logs) + } +} + +func TestDoList_rootStatError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires symlinks") + } + root := filepath.Join(newTempDir(t), "loop") + if err := os.Symlink(root, root); err != nil { + t.Fatal(err) + } + _, statErr := os.Stat(root) + if statErr == nil || os.IsNotExist(statErr) || os.IsPermission(statErr) { + t.Fatalf("expected a non-permission stat error, got: %v", statErr) + } + defer func(orig []string) { _localRepositoryRoots = orig }(_localRepositoryRoots) + t.Setenv(envGhqRoot, root) + _localRepositoryRoots = nil + localRepoOnce = &sync.Once{} + + err := newApp().Run(context.Background(), []string{"ghq", "list"}) + var pathErr *os.PathError + if !errors.As(err, &pathErr) { + t.Fatalf("expected stat error, got: %v", err) + } + if pathErr.Op != "stat" || pathErr.Path != root || !errors.Is(err, errors.Unwrap(statErr)) { + t.Errorf("expected original stat error for %s, got: %v", root, err) + } +} + func TestDoList_withSystemHiddenDir(t *testing.T) { if runtime.GOOS == "windows" { t.SkipNow() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/cmd_migrate_test.go new/ghq-1.12.0/cmd_migrate_test.go --- old/ghq-1.11.2/cmd_migrate_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/cmd_migrate_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -20,7 +20,7 @@ {"init"}, {"remote", "add", "origin", remoteURL}, {"-c", "user.name=test", "-c", "[email protected]", - "commit", "--allow-empty", "-m", "init"}, + "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "init"}, } { c := exec.Command("git", args...) c.Dir = dir diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/cmd_rm.go new/ghq-1.12.0/cmd_rm.go --- old/ghq-1.11.2/cmd_rm.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/cmd_rm.go 2026-11-03 16:00:56.000000000 +0100 @@ -12,8 +12,20 @@ ) func doRm(ctx context.Context, cmd *cli.Command) error { + names := cmd.Args().Slice() + if len(names) == 0 { + return fmt.Errorf("repository name is required") + } + for _, name := range names { + if err := removeRepository(cmd, name); err != nil { + return err + } + } + return nil +} + +func removeRepository(cmd *cli.Command, name string) error { var ( - name = cmd.Args().First() dry = cmd.Bool("dry-run") w = cmd.Root().Writer bare = cmd.Bool("bare") @@ -27,6 +39,10 @@ if err != nil { return err } + // Apply host-specific rules. Unknown hosts may use one path component. + if _, err := NewRemoteRepository(u); err != nil { + return err + } localRepo, err := LocalRepositoryFromURL(u, bareModeFromClassicBool(bare)) if err != nil { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/cmd_rm_test.go new/ghq-1.12.0/cmd_rm_test.go --- old/ghq-1.11.2/cmd_rm_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/cmd_rm_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -48,6 +48,15 @@ expectErr: false, }, { + name: "unknown host with one path component", + input: []string{"rm", "https://example.com/repo"}, + setup: func(t *testing.T) { + if err := os.MkdirAll(filepath.Join(tmpd, "example.com", "repo", ".git"), 0755); err != nil { + t.Fatal(err) + } + }, + }, + { name: "empty directory", input: []string{"rm", "motemen/ghqqq"}, setup: func(t *testing.T) { @@ -118,6 +127,33 @@ } } +func TestRmMultipleRepositories(t *testing.T) { + root := newTempDir(t) + setRepositoryPathTestRoot(t, root) + names := []string{"motemen/ghq-first", "motemen/ghq-second"} + for _, name := range names { + if err := os.MkdirAll(filepath.Join(root, "github.com", filepath.FromSlash(name), ".git"), 0755); err != nil { + t.Fatal(err) + } + } + + var runErr error + _, _, err := captureWithInput([]string{"y", "y"}, func() { + runErr = newApp().Run(context.Background(), append([]string{"ghq", "rm"}, names...)) + }) + if err != nil { + t.Fatal(err) + } + if runErr != nil { + t.Fatal(runErr) + } + for _, name := range names { + if _, err := os.Stat(filepath.Join(root, "github.com", filepath.FromSlash(name))); !os.IsNotExist(err) { + t.Errorf("repository %s was not removed: %v", name, err) + } + } +} + func TestRmDryRunCommand(t *testing.T) { defer func(orig func(cmd *exec.Cmd) error) { cmdutil.CommandRunner = orig @@ -373,3 +409,57 @@ } }) } + +func TestRmRepositoryPathCollapse(t *testing.T) { + type testCase struct { + ref, path string + bare bool + } + cases := []testCase{ + {ref: "https://github.com", path: "github.com"}, + {ref: "https://example.com", path: "example.com"}, + {ref: "https://github.com/owner", path: "github.com/owner"}, + {ref: "https://github.com:443/owner", path: "github.com/owner"}, + {ref: "https://GITHUB.COM/owner", path: "GITHUB.COM/owner"}, + {ref: "https://github.com./owner", path: "github.com./owner"}, + {ref: "https://github.com/.git", path: "github.com"}, + {ref: "https://github.com/owner/.git", path: "github.com/owner.git", bare: true}, + {ref: "https://github.com/../../outside", path: "../outside"}, + } + if runtime.GOOS == "windows" { + cases = append(cases, testCase{ref: "https://github.com/%2e%2e%20/%2e%2e%20/outside", path: "../outside"}) + } + for _, tc := range cases { + t.Run(tc.ref, func(t *testing.T) { + root := filepath.Join(newTempDir(t), "root") + setRepositoryPathTestRoot(t, root) + marker := filepath.Join(root, filepath.FromSlash(tc.path), "repo", "keep") + if err := os.MkdirAll(filepath.Dir(marker), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker, []byte("keep"), 0644); err != nil { + t.Fatal(err) + } + args := []string{"ghq", "rm"} + if tc.bare { + args = append(args, "--bare") + } + var runErr error + _, stderr, err := captureWithInput([]string{"y"}, func() { + runErr = newApp().Run(context.Background(), append(args, tc.ref)) + }) + if err != nil { + t.Fatal(err) + } + if runErr == nil || !strings.Contains(runErr.Error(), "repository") { + t.Errorf("expected repository rejection, got %v", runErr) + } + if strings.Contains(stderr, "[y/N]") { + t.Error("requested confirmation for a collapsed path") + } + if data, err := os.ReadFile(marker); err != nil || string(data) != "keep" { + t.Errorf("existing repository was changed: %q, %v", data, err) + } + }) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/commands.go new/ghq-1.12.0/commands.go --- old/ghq-1.11.2/commands.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/commands.go 2026-11-03 16:00:56.000000000 +0100 @@ -34,7 +34,7 @@ &cli.BoolFlag{Name: "p", Usage: "Clone with SSH"}, &cli.BoolFlag{Name: "shallow", Usage: "Do a shallow clone"}, &cli.BoolFlag{Name: "look", Aliases: []string{"l"}, Usage: "Look after get"}, - &cli.StringFlag{Name: "vcs", Usage: "Specify `vcs` backend for cloning"}, + &cli.StringFlag{Name: "vcs", Usage: "Specify `vcs` backend for cloning", Action: validateVCSFlag}, &cli.BoolFlag{Name: "silent", Aliases: []string{"s"}, Usage: "clone or update silently"}, &cli.BoolFlag{Name: "no-recursive", Usage: "prevent recursive fetching"}, &cli.StringFlag{Name: "branch", Aliases: []string{"b"}, @@ -98,11 +98,20 @@ Usage: "Create a new repository", Action: doCreate, Flags: []cli.Flag{ - &cli.StringFlag{Name: "vcs", Usage: "Specify `vcs` backend explicitly"}, + &cli.StringFlag{Name: "vcs", Usage: "Specify `vcs` backend explicitly", Action: validateVCSFlag}, &cli.BoolFlag{Name: "bare", Usage: "Create a bare repository"}, }, } +func validateVCSFlag(_ context.Context, _ *cli.Command, vcs string) error { + if vcs != "" { + if _, ok := vcsRegistry[vcs]; !ok { + return fmt.Errorf("unknown VCS %q", vcs) + } + } + return nil +} + type commandDoc struct { Parent string Arguments string diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/commands_test.go new/ghq-1.12.0/commands_test.go --- old/ghq-1.11.2/commands_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/commands_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -1,12 +1,110 @@ package main import ( + "context" "net/url" + "os" + "os/exec" "path/filepath" + "strings" "sync" "testing" + + "github.com/urfave/cli/v3" + "github.com/x-motemen/ghq/cmdutil" ) +func TestVCSFlagRejectsUnknown(t *testing.T) { + for _, tc := range []struct { + name, command string + flags []string + existing bool + fromStdin bool + input []string + }{ + {name: "get", command: "get"}, + {name: "clone alias", command: "clone"}, + {name: "parallel", command: "get", flags: []string{"--parallel"}}, + {name: "existing", command: "get", existing: true}, + {name: "update", command: "get", flags: []string{"--update"}, existing: true}, + {name: "stdin", command: "get", fromStdin: true, input: []string{"motemen/ghq-test-repo"}}, + {name: "empty stdin", command: "get", fromStdin: true}, + {name: "parallel empty stdin", command: "get", flags: []string{"--parallel"}, fromStdin: true}, + {name: "create", command: "create"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := newTempDir(t) + t.Setenv(envGhqRoot, root) + defer func(orig []string) { _localRepositoryRoots = orig }(_localRepositoryRoots) + _localRepositoryRoots = []string{root} + if tc.existing { + if err := os.MkdirAll(filepath.Join(root, "github.com/motemen/ghq-test-repo/.git"), 0755); err != nil { + t.Fatal(err) + } + } + defer func(orig func(*exec.Cmd) error) { cmdutil.CommandRunner = orig }(cmdutil.CommandRunner) + var commands []string + cmdutil.CommandRunner = func(cmd *exec.Cmd) error { + commands = append(commands, cmd.String()) + return nil + } + args := append([]string{"ghq", tc.command}, tc.flags...) + args = append(args, "--vcs=unknown-vcs") + if !tc.fromStdin { + args = append(args, "motemen/ghq-test-repo") + } + var runErr error + out, _, err := captureWithInput(tc.input, func() { + runErr = newApp().Run(context.Background(), args) + }) + if err != nil { + t.Fatal(err) + } + if runErr == nil || !strings.Contains(runErr.Error(), `unknown VCS "unknown-vcs"`) { + t.Errorf("expected unknown VCS error identifying the value, got: %v", runErr) + } + if len(commands) != 0 { + t.Errorf("unexpected VCS commands: %v", commands) + } + if out != "" { + t.Errorf("unexpected output: %q", out) + } + if !tc.existing { + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("repository directories created for an unknown VCS: %v", entries) + } + } + }) + } +} + +func TestVCSFlagAcceptsKnownValues(t *testing.T) { + for _, command := range []*cli.Command{commandGet, commandCreate} { + for _, vcs := range []string{"", "git", "github", "codecommit", "svn", "subversion", "git-svn", "hg", "mercurial", "darcs", "pijul", "fossil", "bzr", "bazaar"} { + t.Run(command.Name+"/"+vcs, func(t *testing.T) { + cmd := *command + called := false + cmd.Action = func(context.Context, *cli.Command) error { + called = true + return nil + } + app := newApp() + app.Commands = []*cli.Command{&cmd} + if err := app.Run(context.Background(), []string{"ghq", cmd.Name, "--vcs=" + vcs}); err != nil { + t.Fatal(err) + } + if !called { + t.Error("command action was not called") + } + }) + } + } +} + type _cloneArgs struct { remote *url.URL local string diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/getter.go new/ghq-1.12.0/getter.go --- old/ghq-1.11.2/getter.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/getter.go 2026-11-03 16:00:56.000000000 +0100 @@ -94,8 +94,15 @@ return getInfo{}, err } } - if l := detectLocalRepoRoot(remoteURL.Path, repoURL.Path); l != "" { - localRepoRoot = filepath.Join(local.RootPath, remoteURL.Hostname(), l) + // A canonical URL without path components has no repository path + // to map; retain the already validated local path for go-import roots. + if strings.Trim(filepath.ToSlash(repoURL.Path), "/") != "" { + if _, err := localRepositoryRelPathFromURL(repoURL); err != nil { + return getInfo{}, fmt.Errorf("invalid canonical repository URL for %q: %w", remoteURL.String(), err) + } + if l := detectLocalRepoRoot(remoteURL.Path, repoURL.Path); l != "" { + localRepoRoot = filepath.Join(local.RootPath, remoteURL.Hostname(), l) + } } // localRepoRoot at this point does NOT include the bare-mode diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/getter_test.go new/ghq-1.12.0/getter_test.go --- old/ghq-1.11.2/getter_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/getter_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -1,6 +1,56 @@ package main -import "testing" +import ( + "context" + "net/url" + "path/filepath" + "strings" + "testing" +) + +type goImportTestRepository struct { + *OtherRepository + + repoURL *url.URL +} + +func (repo *goImportTestRepository) VCS() (*VCSBackend, *url.URL, error) { + return GitBackend, repo.repoURL, nil +} + +func TestGetterGoImportCanonicalPathBoundary(t *testing.T) { + for _, tc := range []struct { + canonical string + valid bool + }{ + {canonical: "https://example.com", valid: true}, + {canonical: "https://example.com/.git"}, + } { + t.Run(tc.canonical, func(t *testing.T) { + withFakeGitBackend(t, func(t *testing.T, root string, clone *_cloneArgs, _ *_updateArgs) { + t.Setenv(envGhqRoot, root) + remote := &goImportTestRepository{ + OtherRepository: &OtherRepository{url: mustParseURL("https://example.com/foo")}, + repoURL: mustParseURL(tc.canonical), + } + g := getter{} + _, err := g.getRemoteRepository(context.Background(), remote, "") + if !tc.valid { + if err == nil || clone.local != "" { + t.Fatalf("unsafe canonical URL: error %v, clone directory %q", err, clone.local) + } + return + } + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(root, "example.com", "foo"); clone.local != want { + t.Errorf("clone directory = %q, want %q", clone.local, want) + } + }) + }) + } +} func TestDetectLocalRepoRoot(t *testing.T) { testCases := []struct { @@ -67,3 +117,32 @@ }) } } + +func TestGetterCanonicalPathBoundary(t *testing.T) { + for _, tc := range []struct{ ref, want string }{ + {ref: "https://github.com/owner/.git/tree/main"}, + {ref: "https://github.com/a//b/"}, + {ref: "https://github.com/owner/../repo.git"}, + {ref: "https://github.com/owner/repo/tree/main", want: "github.com/owner/repo"}, + } { + t.Run(tc.ref, func(t *testing.T) { + withFakeGitBackend(t, func(t *testing.T, root string, clone *_cloneArgs, _ *_updateArgs) { + t.Setenv(envGhqRoot, root) + g := getter{} + _, err := g.get(context.Background(), tc.ref) + if tc.want == "" { + if err == nil || clone.local != "" || !strings.Contains(err.Error(), tc.ref) { + t.Fatalf("expected rejection identifying input URL: error %v, clone directory %q", err, clone.local) + } + return + } + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(root, filepath.FromSlash(tc.want)); clone.local != want { + t.Errorf("clone directory = %q, want %q", clone.local, want) + } + }) + }) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/go.mod new/ghq-1.12.0/go.mod --- old/ghq-1.11.2/go.mod 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/go.mod 2026-11-03 16:00:56.000000000 +0100 @@ -2,15 +2,17 @@ go 1.26.0 +toolchain go1.27.3 + require ( github.com/Songmu/gitconfig v0.2.2 github.com/mattn/go-isatty v0.0.24 github.com/motemen/go-colorine v0.0.0-20180816141035-45d19169413a github.com/otiai10/copy v1.14.1 github.com/saracen/walker v0.1.4 - github.com/urfave/cli/v3 v3.13.0 - golang.org/x/net v0.59.0 - golang.org/x/sync v0.23.0 + github.com/urfave/cli/v3 v3.14.0 + golang.org/x/net v0.62.0 + golang.org/x/sync v0.24.0 ) require ( @@ -23,7 +25,7 @@ github.com/cli/safeexec v1.0.1 // indirect github.com/daviddengcn/go-colortext v1.0.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 gopkg.in/yaml.v3 v3.0.1 // indirect ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/go.sum new/ghq-1.12.0/go.sum --- old/ghq-1.11.2/go.sum 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/go.sum 2026-11-03 16:00:56.000000000 +0100 @@ -30,18 +30,18 @@ github.com/saracen/walker v0.1.4/go.mod h1:2F+hfOidTHfXP2AmlKOqpO+yewf8fIvNUDBNJogpJbk= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/urfave/cli/v3 v3.13.0 h1:Dr6jqMfIyyFsRVn7Nz5mqLsMY+ZMpfh3a0aMs+umPVY= -github.com/urfave/cli/v3 v3.13.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4= +github.com/urfave/cli/v3 v3.14.0 h1:a8414NQlHJs0c/iBsulKLzlES0n/lEAskbL2LKpU4/s= +github.com/urfave/cli/v3 v3.14.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/helpers_test.go new/ghq-1.12.0/helpers_test.go --- old/ghq-1.11.2/helpers_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/helpers_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -5,6 +5,7 @@ "io" "net/url" "os" + "sync" "testing" ) @@ -135,3 +136,13 @@ } }) } + +func setRepositoryPathTestRoot(t *testing.T, root string) { + t.Helper() + t.Setenv(envGhqRoot, root) + origRoots, origErr, origOnce := _localRepositoryRoots, _localRepoErr, localRepoOnce + _localRepositoryRoots, _localRepoErr, localRepoOnce = nil, nil, &sync.Once{} + t.Cleanup(func() { + _localRepositoryRoots, _localRepoErr, localRepoOnce = origRoots, origErr, origOnce + }) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/local_repository.go new/ghq-1.12.0/local_repository.go --- old/ghq-1.11.2/local_repository.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/local_repository.go 2026-11-03 16:00:56.000000000 +0100 @@ -107,15 +107,13 @@ // LocalRepositoryFromURL resolve LocalRepository from URL func LocalRepositoryFromURL(remoteURL *url.URL, mode BareMode) (*LocalRepository, error) { - pathParts := append( - []string{remoteURL.Hostname()}, strings.Split(remoteURL.Path, "/")..., - ) - relPath := strings.TrimSuffix(filepath.Join(pathParts...), ".git") - pathParts[len(pathParts)-1] = strings.TrimSuffix(pathParts[len(pathParts)-1], ".git") + relPath, err := localRepositoryRelPathFromURL(remoteURL) + if err != nil { + return nil, err + } if mode == BareClassic { // Force to append ".git" even if remoteURL does not end with ".git". relPath = relPath + ".git" - pathParts[len(pathParts)-1] = pathParts[len(pathParts)-1] + ".git" } var ( @@ -150,14 +148,45 @@ FullPath: filepath.Join(prim, relPath), RelPath: relPath, RootPath: prim, - PathParts: pathParts, + PathParts: strings.Split(relPath, "/"), }, nil } +// localRepositoryRelPathFromURL validates URL components before cleaning the +// path, then returns a relative path with the repository's .git suffix removed. +func localRepositoryRelPathFromURL(remoteURL *url.URL) (string, error) { + host := filepath.ToSlash(remoteURL.Hostname()) + if strings.Contains(host, "/") { + return "", fmt.Errorf("invalid repository path in URL %q: path separators in hostname", remoteURL.String()) + } + repoPath := strings.TrimRight(filepath.ToSlash(remoteURL.Path), "/") + pathParts := append([]string{host}, strings.Split(repoPath, "/")...) + last := len(pathParts) - 1 + pathParts[last] = strings.TrimSuffix(pathParts[last], ".git") + if pathParts[last] == "" { + return "", fmt.Errorf("invalid repository path in URL %q: missing repository name", remoteURL.String()) + } + for _, part := range pathParts { + if part == "." || part == ".." { + return "", fmt.Errorf("invalid repository path in URL %q: dot path components are not allowed", remoteURL.String()) + } + // Windows normalization can remove trailing periods and spaces, + // making these names alias other directories. + if filepath.Separator == '\\' && strings.TrimRight(part, ". ") != part { + return "", fmt.Errorf("invalid repository path in URL %q: trailing periods or spaces are not allowed on Windows", remoteURL.String()) + } + } + relPath := filepath.Join(pathParts...) + if !filepath.IsLocal(relPath) { + return "", fmt.Errorf("invalid repository path in URL %q: must be below ghq root", remoteURL.String()) + } + return filepath.ToSlash(relPath), nil +} + func getRoot(u string) (string, error) { prim := os.Getenv(envGhqRoot) if prim != "" { - return prim, nil + return primaryLocalRepositoryRoot() } var err error if !codecommitLikeURLPattern.MatchString(u) { @@ -345,6 +374,11 @@ if os.IsNotExist(err) { continue } + if os.IsPermission(err) { + logger.Log("warning", fmt.Sprintf("%s: Permission denied", root)) + continue + } + return err } if fi.Mode()&0444 == 0 { logger.Log("warning", fmt.Sprintf("%s: Permission denied", root)) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/local_repository_test.go new/ghq-1.12.0/local_repository_test.go --- old/ghq-1.11.2/local_repository_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/local_repository_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -150,31 +150,31 @@ url: "ssh://[email protected]/motemen/ghq.git", mode: BareNone, expect: filepath.Join(tmproot, "github.com/motemen/ghq"), - relPath: filepath.FromSlash("github.com/motemen/ghq"), + relPath: "github.com/motemen/ghq", }, { name: "classic bare", url: "ssh://[email protected]/motemen/ghq.git", mode: BareClassic, expect: filepath.Join(tmproot, "github.com/motemen/ghq.git"), - relPath: filepath.FromSlash("github.com/motemen/ghq.git"), + relPath: "github.com/motemen/ghq.git", }, { name: "classic bare with URL missing .git suffix", url: "ssh://[email protected]/motemen/ghq", mode: BareClassic, expect: filepath.Join(tmproot, "github.com/motemen/ghq.git"), - relPath: filepath.FromSlash("github.com/motemen/ghq.git"), + relPath: "github.com/motemen/ghq.git", }, { name: "clean bare", url: "ssh://[email protected]/motemen/ghq.git", mode: BareClean, expect: filepath.Join(tmproot, "github.com/motemen/ghq"), - relPath: filepath.FromSlash("github.com/motemen/ghq"), + relPath: "github.com/motemen/ghq", }, { name: "clean bare with URL missing .git suffix", url: "ssh://[email protected]/motemen/ghq", mode: BareClean, expect: filepath.Join(tmproot, "github.com/motemen/ghq"), - relPath: filepath.FromSlash("github.com/motemen/ghq"), + relPath: "github.com/motemen/ghq", }} for _, tc := range testCases { @@ -450,3 +450,63 @@ t.Errorf("localRepositoryRoots(true) = %+v, want: %+v", got, want) } } + +func TestLocalRepositoryFromURLPathBoundary(t *testing.T) { + setRepositoryPathTestRoot(t, newTempDir(t)) + refs := []string{ + "https://example.com", + "https://github.com/../../outside", + "https://github.com/..%2f..%2foutside", + "https://github.com/owner/%2e/repo.git", + "https://github.com/owner/../repo.git", + "https://github.com/../owner/repo.git", + "https://github.com/.git", + "https://github.com/owner/..git", + "https://github.com/owner/...git", + "file:///", + } + if runtime.GOOS == "windows" { + refs = append(refs, + "https://github.com/owner/%5c..", + "file:///C:/outside", + "https://github.com/%2e%2e%20/%2e%2e%20/outside", + "https://github.com/owner/repo%20.git", + "https://github.com/owner/repo..git") + } + for _, ref := range refs { + t.Run(ref, func(t *testing.T) { + repo, err := LocalRepositoryFromURL(mustParseURL(ref), BareNone) + if err == nil || repo != nil { + t.Fatalf("unsafe URL returned repository %v, error %v", repo, err) + } + if !strings.Contains(err.Error(), ref) { + t.Errorf("error does not identify input URL: %v", err) + } + }) + } +} + +func TestLocalRepositoryFromURLValidPaths(t *testing.T) { + root := newTempDir(t) + setRepositoryPathTestRoot(t, root) + for _, tc := range []struct { + ref string + parts []string + }{ + {"https://github.com//owner//repo.git/", []string{"github.com", "owner", "repo"}}, + {"https://github.com/owner/..git.git", []string{"github.com", "owner", "..git"}}, + {"https://example.com/repo.git", []string{"example.com", "repo"}}, + {"file:///owner/repo.git", []string{"owner", "repo"}}, + } { + t.Run(tc.ref, func(t *testing.T) { + repo, err := LocalRepositoryFromURL(mustParseURL(tc.ref), BareNone) + if err != nil { + t.Fatal(err) + } + want := strings.Join(tc.parts, "/") + if repo.RelPath != want || repo.FullPath != filepath.Join(root, filepath.FromSlash(want)) || !reflect.DeepEqual(repo.PathParts, tc.parts) { + t.Errorf("repository = %+v, want relative path %q and parts %q", repo, want, tc.parts) + } + }) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/main.go new/ghq-1.12.0/main.go --- old/ghq-1.11.2/main.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/main.go 2026-11-03 16:00:56.000000000 +0100 @@ -9,7 +9,7 @@ "github.com/x-motemen/ghq/logger" ) -const version = "1.11.2" +const version = "1.12.0" var revision = "HEAD" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/remote_repository.go new/ghq-1.12.0/remote_repository.go --- old/ghq-1.11.2/remote_repository.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/remote_repository.go 2026-11-03 16:00:56.000000000 +0100 @@ -17,7 +17,8 @@ URL() *url.URL // IsValid checks if the URL is valid. IsValid() bool - // VCS returns the VCS backend that hosts the repository. + // VCS returns the VCS backend and canonical repository URL. + // Callers must validate the returned URL before deriving a local path. VCS() (*VCSBackend, *url.URL, error) } @@ -45,6 +46,9 @@ func (repo *GitHubRepository) VCS() (*VCSBackend, *url.URL, error) { u := *repo.url pathComponents := strings.Split(strings.Trim(strings.TrimSuffix(u.Path, ".git"), "/"), "/") + if len(pathComponents) < 2 || pathComponents[1] == "" { + return nil, nil, fmt.Errorf("invalid repository path in URL %q: missing GitHub repository name", repo.url.String()) + } path := "/" + strings.Join(pathComponents[0:2], "/") if strings.HasSuffix(u.String(), ".git") { path += ".git" @@ -238,7 +242,7 @@ if u.Scheme == "codecommit" { return &CodeCommitRepository{u} } - switch u.Host { + switch strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") { case "github.com": return &GitHubRepository{u} case "gist.github.com": diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/vcs.go new/ghq-1.12.0/vcs.go --- old/ghq-1.11.2/vcs.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/vcs.go 2026-11-03 16:00:56.000000000 +0100 @@ -135,18 +135,28 @@ } err := runInDir(true)(vg.dir, "git", "rev-parse", "@{upstream}") if err != nil { - err := runInDir(vg.silent)(vg.dir, "git", "fetch") + err := runInDir(vg.silent)(vg.dir, "git", "fetch", "--no-recurse-submodules") if err != nil { return err } return nil } - err = runInDir(vg.silent)(vg.dir, "git", "pull", "--ff-only") + err = runInDir(vg.silent)(vg.dir, "git", "pull", "--ff-only", "--no-recurse-submodules") if err != nil { return err } if vg.recursive { - return runInDir(vg.silent)(vg.dir, "git", "submodule", "update", "--init", "--recursive") + err = runInDir(vg.silent)(vg.dir, "git", "submodule", "sync") + if err != nil { + return err + } + err = runInDir(vg.silent)(vg.dir, "git", "-c", "fetch.recurseSubmodules=false", "-c", "submodule.recurse=false", "submodule", "update", "--init") + if err != nil { + return err + } + // Update each level before descending so sync reads the new .gitmodules. + return runInDir(vg.silent)(vg.dir, "git", "submodule", "foreach", "--recursive", + "git submodule sync && git -c fetch.recurseSubmodules=false -c submodule.recurse=false submodule update --init") } return nil }, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/ghq-1.11.2/vcs_test.go new/ghq-1.12.0/vcs_test.go --- old/ghq-1.11.2/vcs_test.go 2026-09-29 18:59:55.000000000 +0200 +++ new/ghq-1.12.0/vcs_test.go 2026-11-03 16:00:56.000000000 +0100 @@ -6,6 +6,7 @@ "os/exec" "path/filepath" "reflect" + "strings" "testing" "github.com/x-motemen/ghq/cmdutil" @@ -84,7 +85,7 @@ dir: localDir, }) }, - expect: []string{"git", "pull", "--ff-only"}, + expect: []string{"git", "pull", "--ff-only", "--no-recurse-submodules"}, dir: localDir, }, { name: "[git] fetch", @@ -103,7 +104,7 @@ dir: localDir, }) }, - expect: []string{"git", "fetch"}, + expect: []string{"git", "fetch", "--no-recurse-submodules"}, dir: localDir, }, { name: "[git] recursive", @@ -123,8 +124,9 @@ recursive: true, }) }, - expect: []string{"git", "submodule", "update", "--init", "--recursive"}, - dir: localDir, + expect: []string{"git", "submodule", "foreach", "--recursive", + "git submodule sync && git -c fetch.recurseSubmodules=false -c submodule.recurse=false submodule update --init"}, + dir: localDir, }, { name: "[git] bare clone", f: func() error { @@ -449,6 +451,106 @@ } }) } +} + +func TestGitUpdateSubmoduleURLs(t *testing.T) { + t.Setenv("GIT_ALLOW_PROTOCOL", "file") + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + // TestMain redirects git config; real repositories need their own config. + t.Setenv("GIT_CONFIG", os.Getenv("GIT_CONFIG")) + if err := os.Unsetenv("GIT_CONFIG"); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + nested bool + recursive bool + fetchMode string + }{ + {name: "direct", recursive: true, fetchMode: "on-demand"}, + {name: "nested", nested: true, recursive: true, fetchMode: "false"}, + {name: "no-recursive", nested: true, fetchMode: "true"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := newTempDir(t) + git := func(dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{ + "-c", "user.name=test", "-c", "[email protected]", + "-c", "commit.gpgsign=false", + }, args...)...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + initRepo := func(name string) string { + t.Helper() + dir := filepath.Join(root, name) + git(root, "init", "-b", "master", dir) + git(dir, "commit", "--allow-empty", "-m", "initial") + return dir + } + + oldURL := filepath.ToSlash(initRepo("old")) + oldCommit := git(oldURL, "rev-parse", "HEAD") + newURL := filepath.ToSlash(filepath.Join(root, "new")) + git(root, "clone", oldURL, newURL) + git(newURL, "commit", "--allow-empty", "-m", "new-only commit") + newCommit := git(newURL, "rev-parse", "HEAD") + + parent := initRepo("parent") + git(parent, "submodule", "add", oldURL, "child") + git(parent, "commit", "-am", "add child") + source := parent + if tc.nested { + source = initRepo("outer") + git(source, "submodule", "add", parent, "middle") + git(source, "commit", "-am", "add middle") + } + local := filepath.Join(root, "local") + git(root, "clone", "--recursive", source, local) + git(local, "config", "--local", "fetch.recurseSubmodules", tc.fetchMode) + if !tc.recursive { + git(local, "config", "--local", "submodule.recurse", "true") + } + + git(filepath.Join(parent, "child"), "fetch", newURL, "master") + git(filepath.Join(parent, "child"), "checkout", "FETCH_HEAD") + git(parent, "config", "-f", ".gitmodules", "submodule.child.url", newURL) + git(parent, "commit", "-am", "change child URL and revision") + if tc.nested { + git(filepath.Join(source, "middle"), "fetch", "--no-recurse-submodules", parent, "master") + git(filepath.Join(source, "middle"), "checkout", "FETCH_HEAD") + git(source, "commit", "-am", "advance middle") + } + + if err := GitBackend.Update(&vcsGetOption{dir: local, recursive: tc.recursive}); err != nil { + t.Fatal(err) + } + if got, want := git(local, "rev-parse", "HEAD"), git(source, "rev-parse", "HEAD"); got != want { + t.Errorf("parent HEAD = %s, want %s", got, want) + } + childParent := local + if tc.nested { + childParent = filepath.Join(local, "middle") + } + wantCommit, wantURL := newCommit, newURL + if !tc.recursive { + wantCommit, wantURL = oldCommit, oldURL + } + if got := git(filepath.Join(childParent, "child"), "rev-parse", "HEAD"); got != wantCommit { + t.Errorf("child HEAD = %s, want %s", got, wantCommit) + } + if got := git(childParent, "config", "--local", "--get", "submodule.child.url"); got != wantURL { + t.Errorf("child URL = %s, want %s", got, wantURL) + } + }) + } } func TestCvsDummyBackend(t *testing.T) { ++++++ vendor.tar.gz ++++++ ++++ 5832 lines of diff (skipped)
