From 0f5a2649f5dafc5e3bf81812e8921ca9e5f1960f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:05:17 +0000 Subject: [PATCH 1/6] fix(origin): accept drive-letter file URIs and run the suite on Windows url.Parse made C: the host of git+file://C:/path, so every repository on a Windows machine was refused; the file transport now takes file:///C:/path (RFC 8089) and the file://C:/path spelling, and the local-path errors name the form for this machine. The e2e harness builds syspi.exe on Windows. CI adds windows-latest to the gate with hg and svn from chocolatey, and .gitattributes keeps LF so gofmt holds there. Co-Authored-By: jason.han --- .gitattributes | 2 + .github/workflows/ci.yml | 9 ++++- changes/unreleased/windows-paths.fixed.md | 1 + internal/origin/origin.go | 46 +++++++++++++++++++++-- internal/origin/origin_test.go | 12 ++++-- tests/e2e/e2e_test.go | 4 ++ 6 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 .gitattributes create mode 100644 changes/unreleased/windows-paths.fixed.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..784ab38 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Go sources and scripts are committed with LF; a Windows checkout must not get CRLF (gofmt, sh). +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 535668a..ae552cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} timeout-minutes: 20 steps: @@ -32,6 +32,13 @@ jobs: - name: Install mercurial and subversion (macOS) if: runner.os == 'macOS' run: brew install mercurial subversion + - name: Install mercurial and subversion (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install -y --no-progress hg sliksvn + Add-Content $env:GITHUB_PATH 'C:\Program Files\Mercurial' + Add-Content $env:GITHUB_PATH 'C:\Program Files\SlikSvn\bin' - name: Versions run: go version && git --version && hg --version | head -1 && svn --version --quiet - name: Build diff --git a/changes/unreleased/windows-paths.fixed.md b/changes/unreleased/windows-paths.fixed.md new file mode 100644 index 0000000..cbe6ff4 --- /dev/null +++ b/changes/unreleased/windows-paths.fixed.md @@ -0,0 +1 @@ +- On Windows, a repository on this machine is accepted as `git+file:///C:/path` (and `git+file://C:/path`), `file://` resources with a drive letter resolve, and the suite runs on Windows; CI gates on Windows with hg and svn installed. diff --git a/internal/origin/origin.go b/internal/origin/origin.go index 5d04e0d..56f2c0e 100644 --- a/internal/origin/origin.go +++ b/internal/origin/origin.go @@ -189,7 +189,8 @@ func Parse(resource string) (*Origin, error) { return parseHTTP(res) } if rest, ok := strings.CutPrefix(res, "file://"); ok { - if i := strings.IndexByte(rest, '/'); i > 0 { + rest, ok = fileURIPath(rest) + if !ok { return nil, fmt.Errorf("resource %q: file URIs must name a path on this machine (file:///path)", res) } return parseLocal(res, rest) @@ -270,7 +271,8 @@ func parseGeneric(res, tail string) (*Origin, error) { // git+https:///. func parseGitAlias(res, tail string) (*Origin, error) { if strings.HasPrefix(tail, "/") { - return nil, fmt.Errorf("resource %q: pkg:git needs a host; a repository on this machine is git+file:///%s", res, strings.TrimLeft(tail, "/")) + abs, _ := filepath.Abs(filepath.FromSlash("/" + strings.TrimLeft(tail, "/"))) + return nil, fmt.Errorf("resource %q: pkg:git needs a host; a repository on this machine is git+file://%s", res, FileURIPath(abs)) } if tail == "" || !strings.Contains(tail, "/") { return nil, fmt.Errorf("resource %q: expected pkg:git//[@][#]", res) @@ -299,6 +301,12 @@ func parseVCSURL(res, raw string) (*Origin, error) { if !transports[vcs][transport] { return nil, fmt.Errorf("resource %q: %s+%s is not a vcs_url scheme for %s (ECMA-427 Annex B.3.5 allows %s)", res, vcs, transport, vcs, allowed(vcs)) } + if transport == "file" { + // file:///C:/path is the RFC 8089 form; file://C:/path is accepted as input. + if drive.MatchString(rest) { + rest = "/" + rest + } + } u, err := url.Parse(transport + "://" + rest) if err != nil { return nil, fmt.Errorf("resource %q: not a valid URL: %v", res, err) @@ -314,7 +322,11 @@ func parseVCSURL(res, raw string) (*Origin, error) { if u.Host != "" || !strings.HasPrefix(u.Path, "/") { return nil, fmt.Errorf("resource %q: a repository on this machine is %s+file:///absolute/path", res, vcs) } - o.Path = filepath.ToSlash(filepath.Clean(u.Path)) + o.Path = u.Path + if drive.MatchString(o.Path[1:]) { + o.Path = o.Path[1:] + } + o.Path = filepath.ToSlash(filepath.Clean(o.Path)) o.Name = strings.TrimSuffix(path.Base(o.Path), ".git") switch vcs { case "svn": @@ -387,6 +399,32 @@ func parseHTTP(res string) (*Origin, error) { return &Origin{Kind: kind, Source: spec, Version: ver, Resource: res}, nil } +// drive matches the start of a Windows path, C:/ or C:\. +var drive = regexp.MustCompile(`^[A-Za-z]:[/\\]`) + +// FileURIPath is the path part of a file:// URI naming abs: /C:/dir on Windows, /dir elsewhere. +func FileURIPath(abs string) string { + abs = filepath.ToSlash(abs) + if !strings.HasPrefix(abs, "/") { + abs = "/" + abs + } + return abs +} + +// fileURIPath turns what follows file:// into a path on this machine; a host is refused. +func fileURIPath(rest string) (string, bool) { + if drive.MatchString(rest) { + return rest, true + } + if !strings.HasPrefix(rest, "/") { + return "", false + } + if drive.MatchString(rest[1:]) { + return rest[1:], true + } + return rest, true +} + // parseLocal handles file:// URIs and plain paths: a .kpar is a local // archive, anything else an editable project directory. func parseLocal(res, p string) (*Origin, error) { @@ -406,7 +444,7 @@ func parseLocal(res, p string) (*Origin, error) { } if ver != "" { abs, _ := filepath.Abs(p) - return nil, fmt.Errorf("resource %q: a path with @ is not an identity; a repository on this machine is git+file://%s@%s (or svn+file, hg+file)", res, filepath.ToSlash(abs), ver) + return nil, fmt.Errorf("resource %q: a path with @ is not an identity; a repository on this machine is git+file://%s@%s (or svn+file, hg+file)", res, FileURIPath(abs), ver) } if sub != "" { p = path.Join(p, sub) diff --git a/internal/origin/origin_test.go b/internal/origin/origin_test.go index e44f963..ede937d 100644 --- a/internal/origin/origin_test.go +++ b/internal/origin/origin_test.go @@ -3,6 +3,7 @@ package origin import ( + "path/filepath" "strings" "testing" ) @@ -26,7 +27,7 @@ func TestCanonical(t *testing.T) { {"svn://svn.example.org/pkg@1.0.0", "pkg:generic/pkg?vcs_url=svn%2Bsvn:%2F%2Fsvn.example.org%2Fpkg%401.0.0", "svn://svn.example.org/pkg"}, {"svn+https://svn.example.org/repos/pkg", "pkg:generic/pkg?vcs_url=svn%2Bhttps:%2F%2Fsvn.example.org%2Frepos%2Fpkg", "https://svn.example.org/repos/pkg"}, {"svn+ssh://svn.example.org/repos/pkg", "pkg:generic/pkg?vcs_url=svn%2Bssh:%2F%2Fsvn.example.org%2Frepos%2Fpkg", "svn+ssh://svn.example.org/repos/pkg"}, - {"git+file:///srv/git/widgets@1.0.0#packages/gears", "pkg:generic/widgets?vcs_url=git%2Bfile:%2F%2F%2Fsrv%2Fgit%2Fwidgets%401.0.0%23packages%2Fgears", "/srv/git/widgets"}, + {"git+file:///srv/git/widgets@1.0.0#packages/gears", "pkg:generic/widgets?vcs_url=git%2Bfile:%2F%2F%2Fsrv%2Fgit%2Fwidgets%401.0.0%23packages%2Fgears", filepath.FromSlash("/srv/git/widgets")}, {"svn+file:///srv/svn/widgets", "pkg:generic/widgets?vcs_url=svn%2Bfile:%2F%2F%2Fsrv%2Fsvn%2Fwidgets", "file:///srv/svn/widgets"}, } { o, err := Parse(tc.in) @@ -67,6 +68,9 @@ func TestArchivesAndDirectories(t *testing.T) { } } +// srvGitWidgets is /srv/git/widgets as a file-URI path on this machine (/D:/srv/git/widgets on Windows). +var srvGitWidgets = func() string { abs, _ := filepath.Abs(filepath.FromSlash("/srv/git/widgets")); return FileURIPath(abs) }() + func TestRefused(t *testing.T) { for _, tc := range []struct{ in, want string }{ {"pkg:svn/svn.example.org/pkg@1.0.0", "pkg:svn is not a Package-URL type; write pkg:generic/?vcs_url="}, @@ -78,9 +82,9 @@ func TestRefused(t *testing.T) { {"https://github.com/acme/widgets.git", "only dereferenced as a project interchange file"}, {"git@github.com:acme/widgets.git@1.0.0", "write git+ssh://github.com/acme/widgets"}, {"ssh://git@github.com/acme/widgets@1.0.0", "unsupported URL scheme; a repository is written as pkg:generic"}, - {"file:///srv/git/widgets@1.0.0", "a repository on this machine is git+file:///srv/git/widgets@1.0.0"}, - {"/srv/git/widgets@1.0.0", "a repository on this machine is git+file:///srv/git/widgets@1.0.0"}, - {"pkg:git//srv/git/widgets@1.0.0", "a repository on this machine is git+file:///srv/git/widgets@1.0.0"}, + {"file:///srv/git/widgets@1.0.0", "a repository on this machine is git+file://" + srvGitWidgets + "@1.0.0"}, + {"/srv/git/widgets@1.0.0", "a repository on this machine is git+file://" + srvGitWidgets + "@1.0.0"}, + {"pkg:git//srv/git/widgets@1.0.0", "a repository on this machine is git+file://" + srvGitWidgets + "@1.0.0"}, {"git+https://user:pw@github.com/acme/widgets", "user name or password in the host is not supported (ECMA-427 Annex B.3.5)"}, {"hg://hg.example.org/repo", "hg+hg is not a vcs_url scheme for hg (ECMA-427 Annex B.3.5 allows hg+http, hg+https, hg+static-http, hg+ssh, hg+file)"}, {"git+ftp://host/repo", "git+ftp is not a vcs_url scheme for git"}, diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 12d2543..76944d5 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -13,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -36,6 +37,9 @@ func syspiBinary(t *testing.T) string { return } binPath = filepath.Join(dir, "syspi") + if runtime.GOOS == "windows" { + binPath += ".exe" + } cmd := exec.Command("go", "build", "-o", binPath, "../../cmd/syspi") if out, err := cmd.CombinedOutput(); err != nil { buildErr = &buildFailure{err, string(out)} From 63110051a0a2224f27e7b51ef70803521d0dbd89 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:05:36 +0000 Subject: [PATCH 2/6] ci: run the gate steps under bash on every runner Co-Authored-By: jason.han --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae552cc..8af468a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} timeout-minutes: 20 + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 From 91201e75c3a9be0542d71a40f628c55a3bf55f05 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:12:12 +0000 Subject: [PATCH 3/6] fix(vcs): clone with core.autocrlf=false and make the fixtures Windows-safe A Windows git with core.autocrlf=true rewrote every line ending in the checkout, so the tree digest and the .meta.json checksums no longer matched the other VCSs; syspi now clones with core.autocrlf=false. svn file URLs carry the drive as file:///C:/path. Test fixtures: svn URLs through fileURL, no ':' in directory names, the credential helper path in slash form, the lock-source rewrite escapes backslashes once, and the read-only-directory rollback test skips on Windows. Co-Authored-By: jason.han --- internal/install/install_test.go | 10 +++++++--- internal/origin/origin.go | 2 +- internal/vcs/git.go | 4 ++-- internal/vcs/vcs_test.go | 11 ++++++++++- tests/e2e/auth_test.go | 2 +- tests/e2e/e2e_test.go | 13 +++++++++++-- 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/internal/install/install_test.go b/internal/install/install_test.go index adc74e2..2e902f8 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "time" @@ -297,7 +298,7 @@ func TestLockMatchesAcrossGitSuffixSpellings(t *testing.T) { } first, _ := os.ReadFile(filepath.Join(dir, project.LockFile)) // An older lock recorded the source in its input spelling; it is still the same location. - oldSrc := strings.ReplaceAll(string(first), `"source": "`+strings.ReplaceAll(filepath.FromSlash(widgets), `\\`, `\\\\`)+`"`, `"source": "git+file://`+filepath.ToSlash(widgets)+`"`) + oldSrc := strings.ReplaceAll(string(first), `"source": "`+strings.ReplaceAll(filepath.FromSlash(widgets), "\\", "\\\\")+`"`, `"source": "git+file://`+filepath.ToSlash(widgets)+`"`) if oldSrc == string(first) { t.Fatalf("lock did not contain the widgets source:\n%s", first) } @@ -549,13 +550,16 @@ func TestPublishRollsBackWhenTheLockCannotBeWritten(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root ignores directory permissions") } + if runtime.GOOS == "windows" { + t.Skip("a read-only directory does not refuse new files on Windows") + } widgets, _ := fixture(t) - dir := consumer(t, project.Usage{Resource: "git+file://" + widgets + "@1.0.0"}) + dir := consumer(t, project.Usage{Resource: "git+file://" + filepath.ToSlash(widgets) + "@1.0.0"}) run(t, dir, Options{}) before, _ := os.ReadFile(filepath.Join(dir, project.LockFile)) // Move to 1.1.0 (replaces the installed tree) with the project directory read-only, // so the lock cannot be written after lib/ was updated. - write(t, dir, project.ProjectFile, `{"name":"app","version":"0.1.0","usage":[{"resource":"git+file://`+widgets+`@1.1.0"}]}`) + write(t, dir, project.ProjectFile, `{"name":"app","version":"0.1.0","usage":[{"resource":"git+file://`+filepath.ToSlash(widgets)+`@1.1.0"}]}`) if err := os.Chmod(dir, 0o555); err != nil { t.Fatal(err) } diff --git a/internal/origin/origin.go b/internal/origin/origin.go index 56f2c0e..e6e9382 100644 --- a/internal/origin/origin.go +++ b/internal/origin/origin.go @@ -330,7 +330,7 @@ func parseVCSURL(res, raw string) (*Origin, error) { o.Name = strings.TrimSuffix(path.Base(o.Path), ".git") switch vcs { case "svn": - o.Source = "file://" + o.Path + o.Source = "file://" + FileURIPath(o.Path) default: o.Source = filepath.FromSlash(o.Path) } diff --git a/internal/vcs/git.go b/internal/vcs/git.go index e3676db..661d122 100644 --- a/internal/vcs/git.go +++ b/internal/vcs/git.go @@ -76,13 +76,13 @@ func (g *gitSource) Export(ctx context.Context, v Version, dst string) error { } defer os.RemoveAll(scratch) clone := filepath.Join(scratch, "repo") - if _, err := run(ctx, "", "git", "-c", "advice.detachedHead=false", "clone", "--quiet", "--depth", "1", "--branch", v.Tag, "--", g.url, clone); err != nil { + if _, err := run(ctx, "", "git", "-c", "advice.detachedHead=false", "-c", "core.autocrlf=false", "clone", "--quiet", "--depth", "1", "--branch", v.Tag, "--", g.url, clone); err != nil { if !strings.Contains(err.Error(), "shallow") { return err } // A dumb HTTP remote cannot serve a shallow clone; take the full history. os.RemoveAll(clone) - if _, err := run(ctx, "", "git", "-c", "advice.detachedHead=false", "clone", "--quiet", "--branch", v.Tag, "--", g.url, clone); err != nil { + if _, err := run(ctx, "", "git", "-c", "advice.detachedHead=false", "-c", "core.autocrlf=false", "clone", "--quiet", "--branch", v.Tag, "--", g.url, clone); err != nil { return err } } diff --git a/internal/vcs/vcs_test.go b/internal/vcs/vcs_test.go index ccc72ce..abfae2c 100644 --- a/internal/vcs/vcs_test.go +++ b/internal/vcs/vcs_test.go @@ -98,12 +98,21 @@ func hgFixture(t *testing.T) string { return repo } +// fileURL is the file:// URL of an absolute path on this machine (file:///C:/x on Windows). +func fileURL(p string) string { + p = filepath.ToSlash(p) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return "file://" + p +} + func svnFixture(t *testing.T) string { t.Helper() root := t.TempDir() repo := filepath.Join(root, "repo") sh(t, root, nil, "svnadmin", "create", repo) - url := "file://" + filepath.ToSlash(repo) + url := fileURL(repo) stage := filepath.Join(root, "stage") os.MkdirAll(filepath.Join(stage, "trunk"), 0o755) os.MkdirAll(filepath.Join(stage, "tags"), 0o755) diff --git a/tests/e2e/auth_test.go b/tests/e2e/auth_test.go index 6179a04..3aed221 100644 --- a/tests/e2e/auth_test.go +++ b/tests/e2e/auth_test.go @@ -61,7 +61,7 @@ func TestAuthGitCredentialHelper(t *testing.T) { // With a credential helper configured through the environment, as a user would. helper := filepath.Join(root, "helper.sh") os.WriteFile(helper, []byte("#!/bin/sh\necho username=alice\necho password=s3cret\n"), 0o755) - env := []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=" + helper} + env := []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=" + filepath.ToSlash(helper)} app2 := initApp(t, root, "app-cred") if out, err := shErr(app2, env, bin, "add", res); err != nil { t.Fatalf("add with helper: %v\n%s", err, out) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 76944d5..26a0cee 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -149,10 +149,19 @@ func hgFixture(t *testing.T, root string) string { return repo } +// fileURL is the file:// URL of an absolute path on this machine (file:///C:/x on Windows). +func fileURL(p string) string { + p = filepath.ToSlash(p) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return "file://" + p +} + func svnFixture(t *testing.T, root string) string { repo := filepath.Join(root, "widgets-svn") sh(t, root, "svnadmin", "create", repo) - url := "file://" + filepath.ToSlash(repo) + url := fileURL(repo) stage := filepath.Join(root, "svn-stage") os.MkdirAll(filepath.Join(stage, "tags"), 0o755) packageTree(t, filepath.Join(stage, "trunk"), "1.0.0") @@ -387,7 +396,7 @@ func TestRefusals(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - app := filepath.Join(root, "app-"+strings.ReplaceAll(tc.name, " ", "-")) + app := filepath.Join(root, "app-"+strings.NewReplacer(" ", "-", ":", "-", "/", "-").Replace(tc.name)) os.MkdirAll(app, 0o755) sh(t, app, bin, "init", "-name", "app") before, _ := os.ReadFile(filepath.Join(app, ".project.json")) From 745b09dcdfeaada4937e047a2678212756b1c75c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:15:44 +0000 Subject: [PATCH 4/6] fix(vcs): run hg --noninteractive so a missing credential aborts instead of prompting On Windows hg takes the NUL stdin for a terminal, prompts for the user name and dies with "response expected", which hid the authorization hint. The svn netrc test derives the repository path from the fixture root rather than the file URL. Co-Authored-By: jason.han --- internal/vcs/hg.go | 12 +++++++----- tests/e2e/auth_test.go | 15 ++++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/internal/vcs/hg.go b/internal/vcs/hg.go index 1841d63..c72a98d 100644 --- a/internal/vcs/hg.go +++ b/internal/vcs/hg.go @@ -26,10 +26,12 @@ func (h *hgSource) ListVersions(ctx context.Context) ([]Version, error) { } defer os.RemoveAll(scratch) clone := filepath.Join(scratch, "repo") - if _, err := run(ctx, "", "hg", "clone", "--quiet", "--noupdate", "--", h.url, clone); err != nil { + // --noninteractive: hg would otherwise prompt for HTTP credentials (and on Windows + // takes the NUL stdin for a terminal); the abort then names the authorization. + if _, err := run(ctx, "", "hg", "--noninteractive", "clone", "--quiet", "--noupdate", "--", h.url, clone); err != nil { return nil, err } - out, err := run(ctx, clone, "hg", "tags", "--quiet") + out, err := run(ctx, clone, "hg", "--noninteractive", "tags", "--quiet") if err != nil { return nil, err } @@ -44,7 +46,7 @@ func (h *hgSource) ListVersions(ctx context.Context) ([]Version, error) { // Revision is the 40-hex changeset node id the tag points at. func (h *hgSource) Revision(ctx context.Context, v Version) (string, error) { - out, err := run(ctx, "", "hg", "identify", "--id", "--debug", "-r", v.Tag, "--", h.url) + out, err := run(ctx, "", "hg", "--noninteractive", "identify", "--id", "--debug", "-r", v.Tag, "--", h.url) if err != nil { return "", err } @@ -76,13 +78,13 @@ func (h *hgSource) Export(ctx context.Context, v Version, dst string) error { } defer os.RemoveAll(scratch) clone := filepath.Join(scratch, "repo") - if _, err := run(ctx, "", "hg", "clone", "--quiet", "--noupdate", "-r", node, "--", h.url, clone); err != nil { + if _, err := run(ctx, "", "hg", "--noninteractive", "clone", "--quiet", "--noupdate", "-r", node, "--", h.url, clone); err != nil { return err } // --no-decode keeps bytes as committed. The .hg_archival.txt stamp and the // .hgtags bookkeeping file are Mercurial's own, not the package's, and are // excluded so the tree hash matches the same package served from git or svn. - if _, err := run(ctx, clone, "hg", "archive", "--quiet", "--type", "files", "-r", node, "--no-decode", "--exclude", ".hg_archival.txt", "--exclude", ".hgtags", dst); err != nil { + if _, err := run(ctx, clone, "hg", "--noninteractive", "archive", "--quiet", "--type", "files", "-r", node, "--no-decode", "--exclude", ".hg_archival.txt", "--exclude", ".hgtags", dst); err != nil { return err } return nil diff --git a/tests/e2e/auth_test.go b/tests/e2e/auth_test.go index 3aed221..28cc109 100644 --- a/tests/e2e/auth_test.go +++ b/tests/e2e/auth_test.go @@ -120,11 +120,16 @@ func TestAuthSvnNetrc(t *testing.T) { requireOrSkip(t, "svn", "svnadmin", "svnserve") bin := syspiBinary(t) root := t.TempDir() - url := svnFixture(t, root) - repo := strings.TrimPrefix(url, "file://") - conf := filepath.Join(repo, "conf") - os.WriteFile(filepath.Join(conf, "svnserve.conf"), []byte("[general]\nanon-access = none\nauth-access = read\npassword-db = passwd\nrealm = pkg\n"), 0o644) - os.WriteFile(filepath.Join(conf, "passwd"), []byte("[users]\nalice = s3cret\n"), 0o644) + svnFixture(t, root) + conf := filepath.Join(root, "widgets-svn", "conf") + for name, content := range map[string]string{ + "svnserve.conf": "[general]\nanon-access = none\nauth-access = read\npassword-db = passwd\nrealm = pkg\n", + "passwd": "[users]\nalice = s3cret\n", + } { + if err := os.WriteFile(filepath.Join(conf, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } port := freePort(t) pid := filepath.Join(root, "svnserve.pid") cmd := exec.Command("svnserve", "-d", "--foreground", "--listen-host", "127.0.0.1", "--listen-port", strconv.Itoa(port), "-r", root, "--pid-file", pid) From 7b5704467655d33c0ec58d5d00b5f665c1315598 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:27:44 +0000 Subject: [PATCH 5/6] fix(vcs): hand svn the netrc password without a trailing newline SlikSvn 1.14 on Windows takes a bare LF as part of the password (CRLF and no newline both work); svn on Linux and macOS reads to EOF, so the newline goes. Co-Authored-By: jason.han --- internal/vcs/svn.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/vcs/svn.go b/internal/vcs/svn.go index 926e390..90cf221 100644 --- a/internal/vcs/svn.go +++ b/internal/vcs/svn.go @@ -69,7 +69,8 @@ func svnAuthArgsFor(repo string, lookup func(string) (netrc.Credential, bool), f if !ok || c.Login == "" || !fromStdin { return nil, nil } - return []string{"--username", c.Login, "--password-from-stdin", "--no-auth-cache"}, strings.NewReader(c.Password + "\n") + // No trailing newline: svn on Windows keeps a bare LF as part of the password. + return []string{"--username", c.Login, "--password-from-stdin", "--no-auth-cache"}, strings.NewReader(c.Password) } // svn runs the user's svn with the netrc credentials for the repository From 1ba9a34c9fe4486cd6e6327087ac03205393ce4a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:28:32 +0000 Subject: [PATCH 6/6] test(vcs): the svn password on stdin carries no newline Co-Authored-By: jason.han --- internal/vcs/svn_auth_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/vcs/svn_auth_test.go b/internal/vcs/svn_auth_test.go index 464bc40..f42904c 100644 --- a/internal/vcs/svn_auth_test.go +++ b/internal/vcs/svn_auth_test.go @@ -21,7 +21,7 @@ func TestSvnAuthArgsFor(t *testing.T) { if strings.Join(args, " ") != "--username alice --password-from-stdin --no-auth-cache" { t.Fatalf("args %q", args) } - if pw, _ := io.ReadAll(stdin); string(pw) != "s3cret\n" { + if pw, _ := io.ReadAll(stdin); string(pw) != "s3cret" { t.Fatalf("stdin %q", pw) } for _, a := range args {