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; 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/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/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/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/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/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 } 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 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,