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)

Reply via email to