From b0097e752f429debe1e0e07b7b65fe7712a63557 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sun, 18 Oct 2026 13:27:36 +0200 Subject: [PATCH 1/4] Shell.resolve: a backslash roots a path on Windows `\dir` (the current drive's root) and `\\server\share` UNC paths were joined to the working directory as if relative, so `git push \\server\share\repo.git` (SwiftPorts) looked under the work tree. Like a drive-letter path, a backslash-rooted one is now absolute, outside the unix-style virtual namespace: as given without a mapping, voided under one. On other platforms a backslash stays an ordinary character. Co-Authored-By: Claude Opus 5.5 --- Package.resolved | 6 +++--- Sources/ShellKit/Shell+Path.swift | 14 +++++++------- Tests/ShellKitTests/ShellTests.swift | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Package.resolved b/Package.resolved index 2179a8a..d0c1ba2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f549ee4f482c73bec051b377de7b419a29337e3fb588d352ad1c7c6fad491b80", + "originHash" : "f3be3e2bc12b09669080e2780616cd0eb702c7a1e844b6aca5fe930314fc04f6", "pins" : [ { "identity" : "swift-argument-parser", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-subprocess", "state" : { - "revision" : "13d087685b95d64d6aac9b94500d347bbe84c39b", - "version" : "0.4.0" + "revision" : "11633673a41f509f8945f23c96c7acd4adafd679", + "version" : "0.5.0" } }, { diff --git a/Sources/ShellKit/Shell+Path.swift b/Sources/ShellKit/Shell+Path.swift index d9c1468..22a838f 100644 --- a/Sources/ShellKit/Shell+Path.swift +++ b/Sources/ShellKit/Shell+Path.swift @@ -180,13 +180,13 @@ extension Shell { url = URL(fileURLWithPath: path) } else { #if os(Windows) - if path.count >= 2, - let second = path.dropFirst().first, second == ":" { - // Drive-letter absolutes live outside the unix-style - // virtual namespace; under a mapping they fall through - // to the unmapped sentinel below. - let driveURL = URL(fileURLWithPath: path) - guard sandbox?.pathMapping != nil else { return driveURL } + if path.hasPrefix("\\") || path.dropFirst().first == ":" { + // Drive-letter (`C:\x`) and backslash-rooted (`\x`, + // `\\server\share`) absolutes live outside the + // unix-style virtual namespace; under a mapping they + // fall through to the unmapped sentinel below. + let rootedURL = URL(fileURLWithPath: path) + guard sandbox?.pathMapping != nil else { return rootedURL } return Self.voidedUnmappedPath(path) } #endif diff --git a/Tests/ShellKitTests/ShellTests.swift b/Tests/ShellKitTests/ShellTests.swift index 6f58044..ec14099 100644 --- a/Tests/ShellKitTests/ShellTests.swift +++ b/Tests/ShellKitTests/ShellTests.swift @@ -89,6 +89,32 @@ import ShellCommandKit // Shell.register(_:) bridge #expect(url.path == "/tmp/some-where/file.txt") } + #if os(Windows) + @Test func resolveBackslashRootedPathsAsAbsolute() async throws { + var env = Environment() + env.workingDirectory = #"C:\work"# + let shell = Shell(environment: env) + // Rooted by a backslash, as by a drive letter: not joined to the + // working directory. + for path in [#"\\server\share\repo.git"#, #"\repo.git"#, #"C:\repo.git"#] { + #expect(shell.resolve(path) == URL(fileURLWithPath: path)) + } + // Outside the virtual namespace, so voided under a mapping. + shell.sandbox = .confined( + to: PathMapping(mounts: [.init(virtual: "/work", host: #"C:\work"#)]), + home: "/work") + #expect(shell.resolve(#"\\server\share\repo.git"#).path + .contains(Shell.unmappedPathSentinel)) + } + #else + @Test func resolveTakesABackslashAsAnOrdinaryCharacter() async throws { + var env = Environment() + env.workingDirectory = "/tmp/some-where" + let shell = Shell(environment: env) + #expect(shell.resolve(#"\file.txt"#).path == #"/tmp/some-where/\file.txt"#) + } + #endif + @Test func resolveStaticHonoursCurrent() async throws { var env = Environment() env.workingDirectory = "/var/scope" From 5db57aca4d67b4c1b5b8696ab5507b4698932d25 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sun, 18 Oct 2026 13:33:07 +0200 Subject: [PATCH 2/4] Shell.resolve: the current drive's root is the shell's drive Codex review on #24: on Windows, `\repo.git` (and `/repo.git`) is the root of the current drive, and a shell working on `D:\work` means `D:\repo.git`, whatever drive the process is on. Without a mapping, a path rooted at the current drive's root now takes the drive of the shell's working directory; UNC paths and drive-letter paths keep their own root, and a working directory without a drive leaves the path to the process as before. Co-Authored-By: Claude Opus 5.5 --- Sources/ShellKit/Shell+Path.swift | 31 +++++++++++++++++++++++++--- Tests/ShellKitTests/ShellTests.swift | 14 ++++++++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Sources/ShellKit/Shell+Path.swift b/Sources/ShellKit/Shell+Path.swift index 22a838f..53ef226 100644 --- a/Sources/ShellKit/Shell+Path.swift +++ b/Sources/ShellKit/Shell+Path.swift @@ -177,6 +177,13 @@ extension Shell { public func resolve(_ path: String) -> URL { let url: URL if path.hasPrefix("/") { + #if os(Windows) + // Without a mapping, `/x` is a host path: the root of the + // shell's drive. + if sandbox?.pathMapping == nil, let rooted = onShellDrive(path) { + return URL(fileURLWithPath: rooted) + } + #endif url = URL(fileURLWithPath: path) } else { #if os(Windows) @@ -185,9 +192,8 @@ extension Shell { // `\\server\share`) absolutes live outside the // unix-style virtual namespace; under a mapping they // fall through to the unmapped sentinel below. - let rootedURL = URL(fileURLWithPath: path) - guard sandbox?.pathMapping != nil else { return rootedURL } - return Self.voidedUnmappedPath(path) + guard sandbox?.pathMapping == nil else { return Self.voidedUnmappedPath(path) } + return URL(fileURLWithPath: onShellDrive(path) ?? path) } #endif let cwd = environment.workingDirectory @@ -227,6 +233,25 @@ extension Shell { return URL(fileURLWithPath: translated.host) } + #if os(Windows) + /// `path` on the shell's drive when it is rooted at the current + /// drive's root (`\x` or `/x`, but not a `\\server\share` UNC + /// path): Windows takes the current drive for it, and the shell's is + /// its working directory's, which needn't be the process's. `nil` + /// for any other path, or when the working directory names no drive. + private func onShellDrive(_ path: String) -> String? { + let separators: Set = ["/", "\\"] + guard let first = path.first, separators.contains(first) else { return nil } + // A second separator makes it a UNC path. + if let second = path.dropFirst().first, separators.contains(second) { return nil } + let cwd = environment.workingDirectory + guard let letter = cwd.first, letter.isASCII, letter.isLetter, + cwd.dropFirst().first == ":" + else { return nil } + return String(cwd.prefix(2)) + path + } + #endif + /// Resolve `path` against ``current``'s working directory. /// Equivalent to `Shell.current.resolve(path)`; provided for /// call-site convenience. diff --git a/Tests/ShellKitTests/ShellTests.swift b/Tests/ShellKitTests/ShellTests.swift index ec14099..e320a0f 100644 --- a/Tests/ShellKitTests/ShellTests.swift +++ b/Tests/ShellKitTests/ShellTests.swift @@ -92,13 +92,21 @@ import ShellCommandKit // Shell.register(_:) bridge #if os(Windows) @Test func resolveBackslashRootedPathsAsAbsolute() async throws { var env = Environment() - env.workingDirectory = #"C:\work"# + // A drive the process is unlikely to be on. + env.workingDirectory = #"Q:\work"# let shell = Shell(environment: env) // Rooted by a backslash, as by a drive letter: not joined to the - // working directory. - for path in [#"\\server\share\repo.git"#, #"\repo.git"#, #"C:\repo.git"#] { + // working directory. A UNC path or a drive keeps its own root… + for path in [#"\\server\share\repo.git"#, "//server/share/repo.git", #"C:\repo.git"#] { #expect(shell.resolve(path) == URL(fileURLWithPath: path)) } + // …and the current drive's root is the shell's, not the process's. + #expect(shell.resolve(#"\repo.git"#) == URL(fileURLWithPath: #"Q:\repo.git"#)) + #expect(shell.resolve("/repo.git") == URL(fileURLWithPath: "Q:/repo.git")) + #expect(shell.resolve("/") == URL(fileURLWithPath: "Q:/")) + // Without a drive to take, the process's. + let driveless = Shell(environment: Environment()) + #expect(driveless.resolve(#"\repo.git"#) == URL(fileURLWithPath: #"\repo.git"#)) // Outside the virtual namespace, so voided under a mapping. shell.sandbox = .confined( to: PathMapping(mounts: [.init(virtual: "/work", host: #"C:\work"#)]), From 3e9e3506e6178d2daeb2fa6d2c23b86d07248b2a Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sun, 18 Oct 2026 13:34:14 +0200 Subject: [PATCH 3/4] Shell.resolve: `/x` stays as given; `\x` takes the shell's drive The previous commit also gave `/x` the shell's drive. `Environment()` defaults the working directory to the process's, so on Windows every `/x` would have come back spelled `D:/x`, against resolve's contract that an absolute path without a mapping comes back as given (and resolveAbsolutePathPassesThrough). `/x` is the unix-style namespace and passes through unchanged again; only the backslash-rooted spelling, which this PR adds, is taken onto the shell's drive. Co-Authored-By: Claude Opus 5.5 --- Sources/ShellKit/Shell+Path.swift | 22 +++++++--------------- Tests/ShellKitTests/ShellTests.swift | 3 +-- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/Sources/ShellKit/Shell+Path.swift b/Sources/ShellKit/Shell+Path.swift index 53ef226..0278101 100644 --- a/Sources/ShellKit/Shell+Path.swift +++ b/Sources/ShellKit/Shell+Path.swift @@ -177,13 +177,6 @@ extension Shell { public func resolve(_ path: String) -> URL { let url: URL if path.hasPrefix("/") { - #if os(Windows) - // Without a mapping, `/x` is a host path: the root of the - // shell's drive. - if sandbox?.pathMapping == nil, let rooted = onShellDrive(path) { - return URL(fileURLWithPath: rooted) - } - #endif url = URL(fileURLWithPath: path) } else { #if os(Windows) @@ -234,16 +227,15 @@ extension Shell { } #if os(Windows) - /// `path` on the shell's drive when it is rooted at the current - /// drive's root (`\x` or `/x`, but not a `\\server\share` UNC - /// path): Windows takes the current drive for it, and the shell's is - /// its working directory's, which needn't be the process's. `nil` - /// for any other path, or when the working directory names no drive. + /// `path` on the shell's drive when a backslash roots it at the + /// current drive's root (`\x`, not a `\\server\share` UNC path): + /// Windows takes the current drive for it, and the shell's is its + /// working directory's, which needn't be the process's. `nil` for + /// any other path, or when the working directory names no drive. private func onShellDrive(_ path: String) -> String? { - let separators: Set = ["/", "\\"] - guard let first = path.first, separators.contains(first) else { return nil } + guard path.hasPrefix("\\") else { return nil } // A second separator makes it a UNC path. - if let second = path.dropFirst().first, separators.contains(second) { return nil } + if let second = path.dropFirst().first, second == "\\" || second == "/" { return nil } let cwd = environment.workingDirectory guard let letter = cwd.first, letter.isASCII, letter.isLetter, cwd.dropFirst().first == ":" diff --git a/Tests/ShellKitTests/ShellTests.swift b/Tests/ShellKitTests/ShellTests.swift index e320a0f..932991d 100644 --- a/Tests/ShellKitTests/ShellTests.swift +++ b/Tests/ShellKitTests/ShellTests.swift @@ -102,8 +102,7 @@ import ShellCommandKit // Shell.register(_:) bridge } // …and the current drive's root is the shell's, not the process's. #expect(shell.resolve(#"\repo.git"#) == URL(fileURLWithPath: #"Q:\repo.git"#)) - #expect(shell.resolve("/repo.git") == URL(fileURLWithPath: "Q:/repo.git")) - #expect(shell.resolve("/") == URL(fileURLWithPath: "Q:/")) + #expect(shell.resolve(#"\"#) == URL(fileURLWithPath: #"Q:\"#)) // Without a drive to take, the process's. let driveless = Shell(environment: Environment()) #expect(driveless.resolve(#"\repo.git"#) == URL(fileURLWithPath: #"\repo.git"#)) From 821733468da79ed4aab6f47cf9e4ace75e4d26e1 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sun, 18 Oct 2026 13:39:56 +0200 Subject: [PATCH 4/4] ShellTests: the driveless case gets a working directory without a drive `Environment()` starts in the process's directory, which on the Windows runner is `D:\a\...`, so the "no drive to take" check took D:. Co-Authored-By: Claude Opus 5.5 --- Tests/ShellKitTests/ShellTests.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Tests/ShellKitTests/ShellTests.swift b/Tests/ShellKitTests/ShellTests.swift index 932991d..2d78561 100644 --- a/Tests/ShellKitTests/ShellTests.swift +++ b/Tests/ShellKitTests/ShellTests.swift @@ -103,9 +103,13 @@ import ShellCommandKit // Shell.register(_:) bridge // …and the current drive's root is the shell's, not the process's. #expect(shell.resolve(#"\repo.git"#) == URL(fileURLWithPath: #"Q:\repo.git"#)) #expect(shell.resolve(#"\"#) == URL(fileURLWithPath: #"Q:\"#)) - // Without a drive to take, the process's. - let driveless = Shell(environment: Environment()) - #expect(driveless.resolve(#"\repo.git"#) == URL(fileURLWithPath: #"\repo.git"#)) + // Without a drive to take (a unix-style working directory), the + // process's. `Environment()` itself starts in the process's + // directory, drive included. + var driveless = Environment() + driveless.workingDirectory = "/virtual/cwd" + #expect(Shell(environment: driveless).resolve(#"\repo.git"#) + == URL(fileURLWithPath: #"\repo.git"#)) // Outside the virtual namespace, so voided under a mapping. shell.sandbox = .confined( to: PathMapping(mounts: [.init(virtual: "/work", host: #"C:\work"#)]),