From 0b4e69c2b6a6c421c482ddbac8cd716da7d30660 Mon Sep 17 00:00:00 2001 From: Yonn Date: Wed, 28 Oct 2026 20:33:48 +0100 Subject: [PATCH 1/4] fix: keep local changes on pull and check out remote branches as tracking branches Pull force-checked out the merge result, so uncommitted edits were silently discarded (#1307). It now refuses to pull when tracked files have local changes, like git does, and merges against the actual merge base instead of an empty ancestor. Unrelated histories still merge without an ancestor. Checking out a remote branch left HEAD detached (#1240). It now creates or reuses the matching local branch with its upstream set. Also fixes the wrong error code reported by createBranch and a cache key mismatch that made the diff content cache never hit. --- .../Local/LocalGitServiceProvider.swift | 14 ++- SwiftGit2/SwiftGit2/Repository.swift | 99 +++++++++++++++++-- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/CodeApp/Managers/FileSystem/Local/LocalGitServiceProvider.swift b/CodeApp/Managers/FileSystem/Local/LocalGitServiceProvider.swift index f0644b66d..fec051b62 100644 --- a/CodeApp/Managers/FileSystem/Local/LocalGitServiceProvider.swift +++ b/CodeApp/Managers/FileSystem/Local/LocalGitServiceProvider.swift @@ -297,7 +297,12 @@ class LocalGitServiceProvider: GitServiceProvider { func checkout(reference: ReferenceType) async throws { try await WorkerQueueTask { let repository = try self.checkedRepository() - try repository.checkout(reference, strategy: [.Safe]).get() + var target = reference + // Checking out a remote branch directly leaves HEAD detached; use a local tracking branch instead. + if let branch = reference as? Branch, branch.isRemote, !branch.name.hasSuffix("/HEAD") { + target = try repository.localTrackingBranch(for: branch).get() + } + try repository.checkout(target, strategy: [.Safe]).get() self.contentCache.removeAllObjects() } } @@ -350,14 +355,15 @@ class LocalGitServiceProvider: GitServiceProvider { return try await WorkerQueueTask { let repository = try self.checkedRepository() - if let cached = self.contentCache.object(forKey: path as NSString) { - return (cached as String) - } let path = try path .replacingOccurrences(of: self.workingURL.absoluteString, with: "") .removingPercentEncoding + if let cached = self.contentCache.object(forKey: path as NSString) { + return (cached as String) + } + let oid = try self.fileOidAtPathForLastCommit(path: path) let blob = try repository.blob(oid).get() diff --git a/SwiftGit2/SwiftGit2/Repository.swift b/SwiftGit2/SwiftGit2/Repository.swift index fac7bb0d0..0c3b78179 100644 --- a/SwiftGit2/SwiftGit2/Repository.swift +++ b/SwiftGit2/SwiftGit2/Repository.swift @@ -216,7 +216,7 @@ public final class Repository { guard result == GIT_OK.rawValue else{ git_reference_free(pointer) - return Result.failure(NSError(gitError: value, pointOfFailure: "git_branch_create")) + return Result.failure(NSError(gitError: result, pointOfFailure: "git_branch_create")) } let finalResult = referenceWithLibGit2Reference(pointer!) @@ -227,6 +227,49 @@ public final class Repository { return Result.success(finalResult as! Branch) } + /// Returns the local branch for `remoteBranch` (e.g. `main` for `origin/main`), creating it + /// with its upstream set when it does not exist yet, like `git checkout main` does. + public func localTrackingBranch(for remoteBranch: Branch) -> Result { + guard remoteBranch.isRemote, let separator = remoteBranch.name.firstIndex(of: "/") else { + return .failure(NSError( + domain: libGit2ErrorDomain, + code: 1, + userInfo: [NSLocalizedDescriptionKey: "\(remoteBranch.name) is not a remote branch."] + )) + } + let localName = String(remoteBranch.name[remoteBranch.name.index(after: separator)...]) + + // Only create the branch when it is really missing, since createBranch force-overwrites. + var existingPointer: OpaquePointer? = nil + let existingResult = git_reference_lookup(&existingPointer, self.pointer, "refs/heads/\(localName)") + if existingResult == GIT_OK.rawValue, let existingPointer { + defer { git_reference_free(existingPointer) } + guard let existing = Branch(existingPointer) else { + return .failure(NSError(gitError: existingResult, pointOfFailure: "git_branch_name")) + } + return .success(existing) + } else if existingResult != GIT_ENOTFOUND.rawValue { + return .failure(NSError(gitError: existingResult, pointOfFailure: "git_reference_lookup")) + } + + return commit(remoteBranch.commit.oid) + .flatMap { createBranch(at: $0, branchName: localName) } + .flatMap { localBranch in + var referencePointer: OpaquePointer? = nil + let lookupResult = git_reference_lookup(&referencePointer, self.pointer, localBranch.longName) + guard lookupResult == GIT_OK.rawValue else { + return .failure(NSError(gitError: lookupResult, pointOfFailure: "git_reference_lookup")) + } + defer { git_reference_free(referencePointer) } + + let upstreamResult = git_branch_set_upstream(referencePointer, remoteBranch.name) + guard upstreamResult == GIT_OK.rawValue else { + return .failure(NSError(gitError: upstreamResult, pointOfFailure: "git_branch_set_upstream")) + } + return .success(localBranch) + } + } + public func deleteBranch(branch: Branch) throws { var refPointer: OpaquePointer? = nil let lookupError = git_reference_lookup(&refPointer, self.pointer, branch.longName) @@ -431,6 +474,40 @@ public final class Repository { return referenceWithLibGit2Reference(newReferencePointer!) } + /// Returns nil for unrelated histories, which are then merged without a common ancestor. + private func mergeBase(_ one: OID, _ two: OID) throws -> OID? { + var base = git_oid() + var one = one.oid + var two = two.oid + let result = git_merge_base(&base, self.pointer, &one, &two) + if result == GIT_ENOTFOUND.rawValue { + return nil + } + guard result == GIT_OK.rawValue else { + throw NSError(gitError: result, pointOfFailure: "git_merge_base") + } + return OID(base) + } + + private func ensureCleanWorkingTree() throws { + let trackedChanges: Diff.Status = [ + .indexNew, .indexModified, .indexDeleted, .indexRenamed, .indexTypeChange, + .workTreeModified, .workTreeDeleted, .workTreeRenamed, .workTreeTypeChange, + .conflicted, + ] + // Empty options exclude untracked files, matching git's own pull safety check. + let entries = try status(options: []).get() + guard !entries.contains(where: { !$0.status.intersection(trackedChanges).isEmpty }) else { + throw NSError( + domain: libGit2ErrorDomain, + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Your local changes would be overwritten by pull. Commit or discard them before pulling.", + ] + ) + } + } + private func mergeBranchIntoCurrentBranch(branch: Branch, signature: Signature) throws { let localBranch = try self.currentBranch() @@ -443,7 +520,12 @@ public final class Repository { if (analysis.contains(.upToDate)){ // Do nothing return - }else if (analysis.contains(.fastForward) || analysis.contains(.unborn)){ + } + + // Both merge paths below force-checkout the result, which would discard local edits. + try ensureCleanWorkingTree() + + if (analysis.contains(.fastForward) || analysis.contains(.unborn)){ // Fast-forward branch let newReference = try updateReferenceTarget( reference: localBranch, @@ -457,11 +539,10 @@ public final class Repository { let localTree = try safeTreeForCommitId(localBranch.commit.oid).get() let remoteTree = try safeTreeForCommitId(branch.commit.oid).get() - // TODO: Find common ancestor - let ancestorTree: Tree? = nil + let ancestorCommit = try mergeBase(localBranch.commit.oid, branch.commit.oid) // Merge - let index = try mergeTree(tree: localTree, otherTree: remoteTree, ancestor: ancestorTree) + let index = try mergeTree(tree: localTree, otherTree: remoteTree, ancestorCommit: ancestorCommit) defer { git_index_free(index) } // Check for conflict @@ -504,11 +585,10 @@ public final class Repository { try commit(branch.commit.oid).get() ] - // FIXME: This is stepping on the local tree _ = try self.commit( tree: newTree.oid, parents: parents, - message: "Merge branch \(localBranch.name)", + message: "Merge branch '\(branch.name)' into \(localBranch.name)", signature: signature ).get() @@ -1481,12 +1561,13 @@ public final class Repository { /// Returns an index which represents the result of the merge, or nil if an error /// occurred. /// - private func mergeTree(tree: Tree, otherTree: Tree, ancestor: Tree?) throws -> OpaquePointer { + private func mergeTree(tree: Tree, otherTree: Tree, ancestorCommit: OID?) throws -> OpaquePointer { var index: OpaquePointer? = nil return try withGitObjects([tree.oid, otherTree.oid], type: GIT_OBJECT_TREE){ objects in - let ancestorTreePointer = (ancestor == nil ? nil : try unsafeTreeForCommitId(ancestor!.oid).get()) + let ancestorTreePointer = try ancestorCommit.map { try unsafeTreeForCommitId($0).get() } + defer { git_object_free(ancestorTreePointer) } let gitResult = git_merge_trees ( &index, From 0278a86b5e5722270d666ecc4c09dac1afd21600 Mon Sep 17 00:00:00 2001 From: Yonn Date: Wed, 28 Oct 2026 20:33:59 +0100 Subject: [PATCH 2/4] fix: run package.json scripts like a shell npm run split scripts on spaces and only understood a single node binary, so most real scripts failed with "command doesn't exist or is not supported" (#568, #801, #485, #889). Scripts are now parsed properly: - &&, || and ; with quoting and escapes - FOO=bar prefixes and cross-env, passed through to the node process - node_modules/.bin lookup walking up from the package, like npm - pre/post scripts and extra arguments after -- - pipes, redirects, globs and $VARS fall back to ios_system - scripts run from the package root, also when started in a subfolder The node exit code is now reported back to the app, so a failing step stops the chain. A node command started right after another one also waits for the extension to come back up instead of failing to connect. --- CodeApp/Managers/AppExtensionService.swift | 59 ++- CodeApp/Types/Shared/AppExtensionError.swift | 1 + CodeApp/Utilities/node.swift | 465 ++++++++++++++----- CodeUITests/CodeUITests.swift | 50 ++ NodeExtension/ActionRequestHandler.swift | 13 +- NodeExtension/Node.swift | 15 +- NodeExtension/NodeRunner/NodeRunner.h | 2 +- NodeExtension/NodeRunner/NodeRunner.mm | 5 +- 8 files changed, 458 insertions(+), 152 deletions(-) diff --git a/CodeApp/Managers/AppExtensionService.swift b/CodeApp/Managers/AppExtensionService.swift index 638fdbbd6..2872265f0 100644 --- a/CodeApp/Managers/AppExtensionService.swift +++ b/CodeApp/Managers/AppExtensionService.swift @@ -68,11 +68,34 @@ class AppExtensionService: NSObject { try? await Task.sleep(nanoseconds: 1 * 1_000_000_000) } + /// The extension exits after every command and is relaunched by the interruption handler, + /// so a command started right after another one has to wait for the server to listen again. + private func connect(sending firstMessage: String) async throws -> URLSessionWebSocketTask { + let url = URL(string: "ws://127.0.0.1:\(String(AppExtensionService.PORT))/websocket")! + var attempt = 0 + while true { + let task = URLSession.shared.webSocketTask(with: url) + self.task = task + task.resume() + do { + try await task.send(.string(firstMessage)) + return task + } catch let error as URLError + where error.code == .cannotConnectToHost && attempt < 40 + { + task.cancel() + attempt += 1 + try await Task.sleep(nanoseconds: 250_000_000) + } + } + } + func call( args: [String], t_stdin: UnsafeMutablePointer, - t_stdout: UnsafeMutablePointer - ) async throws { + t_stdout: UnsafeMutablePointer, + environment: [String: String] = [:] + ) async throws -> Int32 { await prepareServer() defer { @@ -86,10 +109,18 @@ class AppExtensionService: NSObject { } signal(SIGINT, signalCallback) - let task = URLSession.shared.webSocketTask( - with: URL(string: "ws://127.0.0.1:\(String(AppExtensionService.PORT))/websocket")!) - self.task = task - task.resume() + let frame = ExecutionRequestFrame( + args: args, + redirectStderr: true, + workingDirectoryBookmark: try? URL( + fileURLWithPath: FileManager.default.currentDirectoryPath + ).bookmarkData(), + isLanguageService: false, + environment: environment + ) + + let task = try await connect(sending: frame.stringRepresentation) + print("Sending -> \(frame.stringRepresentation)") let handle = FileHandle(fileDescriptor: fileno(t_stdin), closeOnDealloc: false) handle.readabilityHandler = { fileHandle in @@ -101,19 +132,6 @@ class AppExtensionService: NSObject { } } - let frame = ExecutionRequestFrame( - args: args, - redirectStderr: true, - workingDirectoryBookmark: try? URL( - fileURLWithPath: FileManager.default.currentDirectoryPath - ).bookmarkData(), - isLanguageService: false - ) - - try await task.send(.string(frame.stringRepresentation)) - - print("Sending -> \(frame.stringRepresentation)") - while let message = try? await task.receive() { switch message { case .string(let text): @@ -123,5 +141,8 @@ class AppExtensionService: NSObject { continue } } + + let closeCode = task.closeCode.rawValue + return (4000..<5000).contains(closeCode) ? Int32(closeCode - 4000) : 0 } } diff --git a/CodeApp/Types/Shared/AppExtensionError.swift b/CodeApp/Types/Shared/AppExtensionError.swift index 08072bb3e..b627f593a 100644 --- a/CodeApp/Types/Shared/AppExtensionError.swift +++ b/CodeApp/Types/Shared/AppExtensionError.swift @@ -23,6 +23,7 @@ struct ExecutionRequestFrame: Codable { var redirectStderr: Bool var workingDirectoryBookmark: Data? var isLanguageService: Bool + var environment: [String: String]? = nil } enum AppExtensionError: String { diff --git a/CodeApp/Utilities/node.swift b/CodeApp/Utilities/node.swift index 08d8662e8..d1265cc00 100644 --- a/CodeApp/Utilities/node.swift +++ b/CodeApp/Utilities/node.swift @@ -22,7 +22,10 @@ private var thread_stdin_copy: UnsafeMutablePointer? = nil private var thread_stdout_copy: UnsafeMutablePointer? = nil private var thread_stderr_copy: UnsafeMutablePointer? = nil -func launchCommandInExtension(args: [String]?) -> Int32 { +// Variables from `FOO=bar` script prefixes, so `node` started through ios_system (pipes, redirects) still gets them. +private var scriptEnvironment: [String: String] = [:] + +func launchCommandInExtension(args: [String]?, environment: [String: String] = [:]) -> Int32 { guard let args else { return 1 } @@ -33,13 +36,17 @@ func launchCommandInExtension(args: [String]?) -> Int32 { setvbuf(thread_stdout_copy, nil, _IONBF, 0) setvbuf(thread_stdin_copy, nil, _IONBF, 0) + let environment = scriptEnvironment.merging(environment) { _, new in new } + var exitCode: Int32 = 0 let sema = DispatchSemaphore(value: 0) Task { do { - try await AppExtensionService.shared.call( - args: args, t_stdin: thread_stdin_copy!, t_stdout: thread_stdout_copy!) + exitCode = try await AppExtensionService.shared.call( + args: args, t_stdin: thread_stdin_copy!, t_stdout: thread_stdout_copy!, + environment: environment) } catch { print("WSError", error) + exitCode = 1 } sema.signal() } @@ -47,7 +54,7 @@ func launchCommandInExtension(args: [String]?) -> Int32 { refreshNodeCommands() - return 0 + return exitCode } @_cdecl("java") @@ -89,118 +96,22 @@ public func npm(argc: Int32, argv: UnsafeMutablePointer 1 else { - return 1 - } - - var workingPath = FileManager.default.currentDirectoryPath - if !workingPath.hasSuffix("/") { - workingPath.append("/") - } - workingPath.append("package.json") + if args.first == "run" || args.first == "run-script" { + return runPackageScript( + name: args.count > 1 ? args[1] : nil, + extraArguments: Array(args.dropFirst(2)), + globalBinDirectory: appGroupSharedLibrary.appendingPathComponent("lib/bin")) + } - do { - let data = try Data( - contentsOf: URL(fileURLWithPath: workingPath), options: .mappedIfSafe) - let jsonResult = try JSONSerialization.jsonObject(with: data, options: .mutableLeaves) - if let jsonResult = jsonResult as? [String: AnyObject], - let scripts = jsonResult["scripts"] as? [String: String] - { - if var script = scripts[args[1]] { - - guard let cmd = script.components(separatedBy: " ").first else { - return 1 - } - if cmd == "node" { - return launchCommandInExtension(args: script.components(separatedBy: " ")) - } - - script.removeFirst(cmd.count) - - if script.hasPrefix(" ") { - script.removeFirst() - } - - let cmdArgs = script.components(separatedBy: " ") - - // Checking for globally installed bin - let nodeBinPath = appGroupSharedLibrary.appendingPathComponent("lib/bin").path - - if let paths = try? FileManager.default.contentsOfDirectory(atPath: nodeBinPath) - { - print(nodeBinPath) - for i in paths { - let binCmd = i.replacingOccurrences(of: nodeBinPath, with: "") - if cmd == binCmd { - let moduleURL = appGroupSharedLibrary.appendingPathComponent( - "lib/bin/\(cmd)" - ) - .resolvingSymlinksInPath() - - let prettierPath = moduleURL.path - - if var content = try? String(contentsOf: moduleURL) { - if !content.contains("process.exit = () => {}") { - content = content.replacingOccurrences( - of: "#!/usr/bin/env node", - with: "#!/usr/bin/env node\nprocess.exit = () => {}") - try? content.write( - to: moduleURL, atomically: true, encoding: .utf8) - } - } - - print(["node", prettierPath, script]) - return launchCommandInExtension(args: [ - "node", prettierPath, script, - ]) - } - } - } - - // Checking for locally installed bin - var bin = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - bin.appendPathComponent("node_modules/.bin/\(cmd)") - bin.resolveSymlinksInPath() - - guard FileManager.default.fileExists(atPath: bin.path) else { - fputs( - "npm ERR! command doesn't exist or is not supported: \(scripts[args[1]]!)\n", - thread_stderr) - return 1 - } - - if var content = try? String(contentsOf: bin) { - if !content.contains("process.exit = () => {}") { - content = content.replacingOccurrences( - of: "#!/usr/bin/env node", - with: "#!/usr/bin/env node\nprocess.exit = () => {}") - try? content.write(to: bin, atomically: true, encoding: .utf8) - } - } - - return launchCommandInExtension(args: ["node", bin.path] + cmdArgs) - } else { - fputs("npm ERR! missing script: \(args[1])\n", thread_stderr) - } - } - } catch { - fputs(error.localizedDescription, thread_stderr) - } + var packagejson = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + packagejson.appendPathComponent("package.json") - return 1 + if !FileManager.default.fileExists(atPath: packagejson.path) { + try? "{}".write(to: packagejson, atomically: true, encoding: .utf8) } let npmURL = Resources.npm.appendingPathComponent("node_modules/.bin/npm") @@ -222,25 +133,17 @@ public func npx(argc: Int32, argv: UnsafeMutablePointer {}") { - content = content.replacingOccurrences( - of: "#!/usr/bin/env node", with: "#!/usr/bin/env node\nprocess.exit = () => {}") - try? content.write(to: bin, atomically: true, encoding: .utf8) - } - } + prepareNodeBin(bin) args.removeFirst() @@ -274,3 +177,319 @@ public func nodeg(argc: Int32, argv: UnsafeMutablePointer {}") + else { + return + } + content = content.replacingOccurrences( + of: "#!/usr/bin/env node", with: "#!/usr/bin/env node\nprocess.exit = () => {}") + try? content.write(to: url, atomically: true, encoding: .utf8) +} + +private func resolvedExistingFile(_ url: URL) -> URL? { + let resolved = url.resolvingSymlinksInPath() + return FileManager.default.fileExists(atPath: resolved.path) ? resolved : nil +} + +/// Walks up from `directory` like npm does, so scripts in workspaces find hoisted binaries. +private func findAncestor(of directory: URL, containing relativePath: String) -> URL? { + var current = directory.standardizedFileURL + while true { + if let match = resolvedExistingFile(current.appendingPathComponent(relativePath)) { + return match + } + let parent = current.deletingLastPathComponent() + if parent.path == current.path { + return nil + } + current = parent + } +} + +private func findLocalBin(_ name: String, from directory: URL) -> URL? { + guard !name.contains("/") else { return nil } + return findAncestor(of: directory, containing: "node_modules/.bin/\(name)") +} + +/// Splits a script on unquoted `&&`, `||` and `;`, keeping the operator that precedes each command. +func splitScriptLine(_ line: String) -> [(ScriptOperator?, String)] { + var commands: [(ScriptOperator?, String)] = [] + var current = "" + var pendingOperator: ScriptOperator? = nil + var quote: Character? = nil + let characters = Array(line) + var index = 0 + + func flush(_ next: ScriptOperator?) { + let command = current.trimmingCharacters(in: .whitespaces) + if !command.isEmpty { + commands.append((pendingOperator, command)) + } + current = "" + pendingOperator = next + } + + while index < characters.count { + let character = characters[index] + let next: Character? = index + 1 < characters.count ? characters[index + 1] : nil + + if character == "\\", let next, quote != "'" { + current.append(character) + current.append(next) + index += 2 + continue + } + + if let activeQuote = quote { + current.append(character) + if character == activeQuote { + quote = nil + } + } else if character == "'" || character == "\"" { + quote = character + current.append(character) + } else if character == "&" && next == "&" { + flush(.and) + index += 1 + } else if character == "|" && next == "|" { + flush(.or) + index += 1 + } else if character == ";" { + flush(.sequence) + } else { + current.append(character) + } + index += 1 + } + flush(nil) + return commands +} + +/// Splits a command into words, honoring quotes and backslash escapes. +func tokenizeCommand(_ command: String) -> [ScriptWord] { + var words: [ScriptWord] = [] + var value = "" + var raw = "" + var needsShell = false + var inWord = false + var quote: Character? = nil + var iterator = command.makeIterator() + + func finishWord() { + words.append(ScriptWord(value: value, raw: raw, needsShell: needsShell)) + value = "" + raw = "" + needsShell = false + inWord = false + } + + while let character = iterator.next() { + if quote == nil && (character == " " || character == "\t") { + if inWord { + finishWord() + } + continue + } + inWord = true + raw.append(character) + + if character == "\\" && quote != "'", let escaped = iterator.next() { + raw.append(escaped) + value.append(escaped) + } else if let activeQuote = quote { + if character == activeQuote { + quote = nil + } else { + needsShell = needsShell || (activeQuote == "\"" && character == "$") + value.append(character) + } + } else if character == "'" || character == "\"" { + quote = character + } else { + needsShell = needsShell || "|<>$*?&".contains(character) + value.append(character) + } + } + if inWord { + finishWord() + } + return words +} + +func environmentAssignment(_ word: String) -> (name: String, value: String)? { + guard let separator = word.firstIndex(of: "="), separator != word.startIndex else { + return nil + } + let name = word[.. String { + let safeCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_./=:@%+,")) + if !word.isEmpty && word.unicodeScalars.allSatisfy(safeCharacters.contains) { + return word + } + return "'" + word.replacingOccurrences(of: "'", with: "'\\''") + "'" +} + +private func runScriptCommand(_ command: String, packageDirectory: URL, globalBinDirectory: URL) + -> Int32 +{ + var words = tokenizeCommand(command) + var environment: [String: String] = [:] + while let word = words.first { + if word.value == "cross-env" { + words.removeFirst() + continue + } + guard let assignment = environmentAssignment(word.value) else { break } + environment[assignment.name] = assignment.value + words.removeFirst() + } + + guard let executable = words.first?.value else { return 0 } + let arguments = words.dropFirst() + let bin = + executable == "node" + ? nil + : findLocalBin(executable, from: packageDirectory) + ?? resolvedExistingFile(globalBinDirectory.appendingPathComponent(executable)) + + if !arguments.contains(where: \.needsShell) { + if executable == "node" { + return launchCommandInExtension( + args: ["node"] + arguments.map(\.value), environment: environment) + } + if let bin { + prepareNodeBin(bin) + return launchCommandInExtension( + args: ["node", bin.path] + arguments.map(\.value), environment: environment) + } + } + + // ios_system handles pipes, redirects, globs and $VAR expansion; node binaries run through its `node` command. + var commandWords = words.map(\.raw) + if let bin { + prepareNodeBin(bin) + commandWords[0] = "node " + shellQuoted(bin.path) + } + + let previousScriptEnvironment = scriptEnvironment + scriptEnvironment.merge(environment) { _, new in new } + let previousValues = environment.keys.map { key in + (key, getenv(key).map { String(cString: $0) }) + } + for (key, value) in environment { + setenv(key, value, 1) + } + defer { + scriptEnvironment = previousScriptEnvironment + for (key, value) in previousValues { + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + } + return runBuiltinCommand(commandWords.joined(separator: " ")) +} + +/// With `joinMainThread` disabled ios_system returns before the command finishes, so wait like Executor does. +private func runBuiltinCommand(_ command: String) -> Int32 { + let pid = ios_fork() + let launchStatus = ios_system(command) + ios_waitpid(pid) + ios_releaseThreadId(pid) + return launchStatus != 0 ? launchStatus : ios_getCommandStatus() +} + +private func runScriptLine(_ line: String, packageDirectory: URL, globalBinDirectory: URL) -> Int32 +{ + var status: Int32 = 0 + for (scriptOperator, command) in splitScriptLine(line) { + if (scriptOperator == .and && status != 0) || (scriptOperator == .or && status == 0) { + continue + } + status = runScriptCommand( + command, packageDirectory: packageDirectory, globalBinDirectory: globalBinDirectory) + } + return status +} + +private func runPackageScript(name: String?, extraArguments: [String], globalBinDirectory: URL) + -> Int32 +{ + let workingDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + guard let packageJSON = findAncestor(of: workingDirectory, containing: "package.json") else { + fputs( + "npm ERR! Could not find package.json in \(workingDirectory.path) or any parent directory\n", + thread_stderr) + return 1 + } + let packageDirectory = packageJSON.deletingLastPathComponent() + + let scripts: [String: String] + do { + let data = try Data(contentsOf: packageJSON) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + scripts = json?["scripts"] as? [String: String] ?? [:] + } catch { + fputs( + "npm ERR! Failed to parse \(packageJSON.path): \(error.localizedDescription)\n", + thread_stderr) + return 1 + } + + guard let name else { + for (scriptName, script) in scripts.sorted(by: { $0.key < $1.key }) { + fputs(" \(scriptName)\n \(script)\n", thread_stdout) + } + return 0 + } + + guard let script = scripts[name] else { + fputs("npm ERR! Missing script: \"\(name)\"\n", thread_stderr) + return 1 + } + + let arguments = + extraArguments.first == "--" ? Array(extraArguments.dropFirst()) : extraArguments + let command = ([script] + arguments.map(shellQuoted)).joined(separator: " ") + let steps = [scripts["pre\(name)"], command, scripts["post\(name)"]].compactMap { $0 } + + // ios_system tracks the directory per session, so plain chdir would not reach built-in commands. + ios_setDirectoryURL(packageDirectory) + defer { ios_setDirectoryURL(workingDirectory) } + + for step in steps { + fputs("\n> \(step)\n\n", thread_stdout) + let status = runScriptLine( + step, packageDirectory: packageDirectory, globalBinDirectory: globalBinDirectory) + if status != 0 { + return status + } + } + return 0 +} diff --git a/CodeUITests/CodeUITests.swift b/CodeUITests/CodeUITests.swift index b2beab417..43e7abf49 100644 --- a/CodeUITests/CodeUITests.swift +++ b/CodeUITests/CodeUITests.swift @@ -81,3 +81,53 @@ final class CodeUITests: XCTestCase { XCTAssertEqual(parsed!.path, "/codeapp.git") } } + +final class NpmScriptParsingTests: XCTestCase { + + func testSplitScriptLineKeepsOperatorsAndQuotedText() { + let commands = splitScriptLine( + "rm -rf dist && NODE_ENV=production nuxt build || echo 'a && b'; echo done") + + XCTAssertEqual( + commands.map(\.1), + [ + "rm -rf dist", "NODE_ENV=production nuxt build", "echo 'a && b'", "echo done", + ]) + XCTAssertNil(commands[0].0) + XCTAssertEqual(commands[1].0, .and) + XCTAssertEqual(commands[2].0, .or) + XCTAssertEqual(commands[3].0, .sequence) + } + + func testSplitScriptLineDoesNotTreatRedirectAsAnd() { + XCTAssertEqual(splitScriptLine("tsc 2>&1").map(\.1), ["tsc 2>&1"]) + } + + func testTokenizeCommandHandlesQuotesAndEscapes() { + let words = tokenizeCommand(#"vite --host "0.0.0.0" --base 'my dir' a\ b"#) + XCTAssertEqual( + words.map(\.value), ["vite", "--host", "0.0.0.0", "--base", "my dir", "a b"]) + XCTAssertFalse(words.contains(where: \.needsShell)) + } + + func testTokenizeCommandFlagsShellSyntax() { + XCTAssertTrue(tokenizeCommand("eslint src/**/*.ts")[1].needsShell) + XCTAssertTrue(tokenizeCommand(#"vite --port "$PORT""#)[2].needsShell) + XCTAssertTrue(tokenizeCommand("tsc > build.log")[1].needsShell) + XCTAssertFalse(tokenizeCommand("echo 'a|b'")[1].needsShell) + } + + func testEnvironmentAssignment() { + XCTAssertEqual(environmentAssignment("NODE_ENV=production")?.name, "NODE_ENV") + XCTAssertEqual(environmentAssignment("NODE_ENV=production")?.value, "production") + XCTAssertNil(environmentAssignment("--port=3000")) + XCTAssertNil(environmentAssignment("=value")) + XCTAssertNil(environmentAssignment("1VAR=value")) + } + + func testShellQuoted() { + XCTAssertEqual(shellQuoted("--port"), "--port") + XCTAssertEqual(shellQuoted("my host"), "'my host'") + XCTAssertEqual(shellQuoted("it's"), #"'it'\''s'"#) + } +} diff --git a/NodeExtension/ActionRequestHandler.swift b/NodeExtension/ActionRequestHandler.swift index d6ae81b39..dd892eabd 100644 --- a/NodeExtension/ActionRequestHandler.swift +++ b/NodeExtension/ActionRequestHandler.swift @@ -52,7 +52,8 @@ class BinaryExecutor { pythonLibraryDirectory: URL?, redirectStderr: Bool, ws: SwiftWS.WebSocket, - isLanguageService: Bool + isLanguageService: Bool, + environment: [String: String] ){ atexit { // Allow stdout to properly print before exit @@ -106,18 +107,21 @@ class BinaryExecutor { } DispatchQueue.global(qos: isLanguageService ? .utility : .default).async { + var exitCode: Int32 = 0 switch executable { case "java", "javac": JavaLauncher.shared.launchJava(args: args, frameworkDirectory: sharedFrameworksDirectory, currentDirectory: workingDirectory) case "node": - NodeLauncher.shared.launchNode(args: args) + exitCode = NodeLauncher.shared.launchNode(args: args, environment: environment) default: SystemCommandLauncher.shared.launchSystemCommand(args: args, pythonLibraryDirectoryURL: pythonLibraryDirectory) } DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.listener.onStdout = nil self.listener.closeConsolePipe() - ws.close(code: 1000, reason: "finished") + // Close codes 4000-4999 are application-defined; the app decodes the exit status from them. + let closeCode = exitCode == 0 ? 1000 : 4000 + Int(min(max(exitCode, 1), 999)) + ws.close(code: closeCode, reason: "finished") } } } @@ -188,7 +192,8 @@ class ActionRequestHandler: NSObject, NSExtensionRequestHandling { } binaryExecutor.executeBinary(args: request.args, workingDirectory: workingDirectoryUrl, sharedFrameworksDirectory: frameworkDirectoryURL, - pythonLibraryDirectory: pythonLibraryDirectoryURL ,redirectStderr: request.redirectStderr, ws: message.target, isLanguageService: request.isLanguageService) + pythonLibraryDirectory: pythonLibraryDirectoryURL ,redirectStderr: request.redirectStderr, ws: message.target, isLanguageService: request.isLanguageService, + environment: request.environment ?? [:]) } } diff --git a/NodeExtension/Node.swift b/NodeExtension/Node.swift index 71f63971a..4dc66af7d 100644 --- a/NodeExtension/Node.swift +++ b/NodeExtension/Node.swift @@ -12,7 +12,7 @@ let sharedURL = FileManager.default.containerURL(forSecurityApplicationGroupIden class NodeLauncher { static let shared = NodeLauncher() - func launchNode(args: [String]){ + func launchNode(args: [String], environment: [String: String] = [:]) -> Int32 { setenv("npm_config_prefix", sharedURL.appendingPathComponent("lib").path, 1) setenv("npm_config_cache", FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.path, 1) setenv("npm_config_userconfig", FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(".npmrc").path, 1) @@ -22,8 +22,17 @@ class NodeLauncher { setenv("HOME", sharedURL.path, 1) setenv("FORCE_COLOR", "3", 1) let injectionJSPath = Bundle.main.path(forResource: "injection", ofType: "js")! - setenv("NODE_OPTIONS", "--jitless --require \"\(injectionJSPath)\"", 1) + var nodeOptions = "--jitless --require \"\(injectionJSPath)\"" + for (key, value) in environment { + // The injection script must always load, so user NODE_OPTIONS are appended instead of replacing it. + if key == "NODE_OPTIONS" { + nodeOptions += " " + value + } else { + setenv(key, value, 1) + } + } + setenv("NODE_OPTIONS", nodeOptions, 1) - NodeRunner.startEngine(withArguments: args) + return NodeRunner.startEngine(withArguments: args) } } diff --git a/NodeExtension/NodeRunner/NodeRunner.h b/NodeExtension/NodeRunner/NodeRunner.h index 52bbb02e3..57abc3484 100644 --- a/NodeExtension/NodeRunner/NodeRunner.h +++ b/NodeExtension/NodeRunner/NodeRunner.h @@ -7,7 +7,7 @@ #import @interface NodeRunner : NSObject {} -+ (void) startEngineWithArguments:(NSArray*)arguments; ++ (int) startEngineWithArguments:(NSArray*)arguments; @end #endif diff --git a/NodeExtension/NodeRunner/NodeRunner.mm b/NodeExtension/NodeRunner/NodeRunner.mm index 9a61640a2..393fe8a50 100644 --- a/NodeExtension/NodeRunner/NodeRunner.mm +++ b/NodeExtension/NodeRunner/NodeRunner.mm @@ -8,7 +8,7 @@ @implementation NodeRunner //node's libUV requires all arguments being on contiguous memory. -+ (void) startEngineWithArguments:(NSArray*)arguments ++ (int) startEngineWithArguments:(NSArray*)arguments { int c_arguments_size=0; @@ -48,7 +48,8 @@ + (void) startEngineWithArguments:(NSArray*)arguments } //Start node, with argc and argv. - node_start(argument_count,argv); + int exitCode = node_start(argument_count,argv); free(args_buffer); + return exitCode; } @end From 920bf5feb3bc13fe55dbb40ba8fe0e982aa4696f Mon Sep 17 00:00:00 2001 From: Yonn Date: Wed, 28 Oct 2026 20:33:59 +0100 Subject: [PATCH 3/4] fix: SSH crash without TTY, http remotes, relative path and read-only keyboard - Don't resume the shell continuation twice when the server has no TTY (#1229) - Accept plain http:// remotes, e.g. self-hosted Forgejo on a LAN (#1294) - Copy Relative Path is relative to the workspace, not the active editor (#1324) - Read-only mode no longer brings up the keyboard (#972) --- .../Managers/EditorImplementation/MonacoImplementation.swift | 3 ++- .../Managers/FileSystem/Local/LocalGitCredentialsHelper.swift | 3 ++- .../Managers/FileSystem/SFTP/SFTPTerminalServiceProvider.swift | 1 + CodeApp/Views/ExplorerFileTreeSection.swift | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CodeApp/Managers/EditorImplementation/MonacoImplementation.swift b/CodeApp/Managers/EditorImplementation/MonacoImplementation.swift index bfbf6bde3..cf6c0d7a4 100644 --- a/CodeApp/Managers/EditorImplementation/MonacoImplementation.swift +++ b/CodeApp/Managers/EditorImplementation/MonacoImplementation.swift @@ -134,7 +134,8 @@ class MonacoImplementation: NSObject { await applyOptions(options: "minimap: {enabled: \(options.miniMapEnabled)}") await applyOptions(options: "lineNumbers: \(options.lineNumbersEnabled)") await applyOptions(options: "smoothScrolling: \(options._smoothScrollingEnabled)") - await applyOptions(options: "readOnly: \(options.readOnly)") + await applyOptions( + options: "readOnly: \(options.readOnly), domReadOnly: \(options.readOnly)") await applyOptions(options: "tabSize: \(String(options.tabRenderSize))") await applyOptions(options: "renderWhitespace: '\(options.renderWhiteSpaces)'") await applyOptions(options: "wordWrap: '\(options.wordWrap)'") diff --git a/CodeApp/Managers/FileSystem/Local/LocalGitCredentialsHelper.swift b/CodeApp/Managers/FileSystem/Local/LocalGitCredentialsHelper.swift index 23f636de7..12dbc576e 100644 --- a/CodeApp/Managers/FileSystem/Local/LocalGitCredentialsHelper.swift +++ b/CodeApp/Managers/FileSystem/Local/LocalGitCredentialsHelper.swift @@ -34,7 +34,8 @@ final class LocalGitCredentialsHelper { case https, ssh init?(rawValue: String) { - if rawValue == "https" { + // Plain http remotes (e.g. self-hosted Forgejo on a LAN) use the same plaintext credentials. + if rawValue == "https" || rawValue == "http" { self = .https } else if rawValue == "ssh" { self = .ssh diff --git a/CodeApp/Managers/FileSystem/SFTP/SFTPTerminalServiceProvider.swift b/CodeApp/Managers/FileSystem/SFTP/SFTPTerminalServiceProvider.swift index 2d7f01944..918c09510 100644 --- a/CodeApp/Managers/FileSystem/SFTP/SFTPTerminalServiceProvider.swift +++ b/CodeApp/Managers/FileSystem/SFTP/SFTPTerminalServiceProvider.swift @@ -84,6 +84,7 @@ class SFTPTerminalServiceProvider: NSObject, TerminalServiceProvider { } catch { print("Unable to start shell,", error) continuation.resume(throwing: SFTPError.UnableToStartShell) + return } continuation.resume() diff --git a/CodeApp/Views/ExplorerFileTreeSection.swift b/CodeApp/Views/ExplorerFileTreeSection.swift index e50f9fb90..359a818d8 100644 --- a/CodeApp/Views/ExplorerFileTreeSection.swift +++ b/CodeApp/Views/ExplorerFileTreeSection.swift @@ -179,7 +179,7 @@ struct ExplorerFileTree: View { ) { _ in let pasteboard = UIPasteboard.general guard let targetURL = URL(string: item.url), - let baseURL = (App.activeEditor as? EditorInstanceWithURL)?.url + let baseURL = URL(string: App.workSpaceStorage.currentDirectory.url) else { return } From dbb575c2bafc18c3e7d07e0ff8c7f55da40c4b80 Mon Sep 17 00:00:00 2001 From: Yonn Date: Wed, 28 Oct 2026 20:33:59 +0100 Subject: [PATCH 4/4] build: disable the debug dylib so ios_system finds the built-in commands Since Xcode 16 debug builds move the app code into CodeUI.debug.dylib and ios_system can no longer look up node, npm, npx, java and wasm (#1159). --- Code.xcodeproj/project.pbxproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 1929ed795..96860fe1b 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -4238,6 +4238,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_DEBUG_DYLIB = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11;