From 762ebd49de4817bdfa054dd5547494421089c786 Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Sat, 19 Jul 2025 22:46:32 -0800 Subject: [PATCH 1/5] add tests --- Tests/GitKitTests/GitKitTests.swift | 368 ++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) diff --git a/Tests/GitKitTests/GitKitTests.swift b/Tests/GitKitTests/GitKitTests.swift index bd9e837..42e9252 100644 --- a/Tests/GitKitTests/GitKitTests.swift +++ b/Tests/GitKitTests/GitKitTests.swift @@ -26,6 +26,19 @@ final class GitKitTests: XCTestCase { ("testCommandWithArgs", testCommandWithArgs), ("testClone", testClone), ("testRevParse", testRevParse), + ("testAddAll", testAddAll), + ("testStatusShort", testStatusShort), + ("testConfigOperations", testConfigOperations), + ("testWriteConfigDefaultBranch", testWriteConfigDefaultBranch), + ("testPushPull", testPushPull), + ("testBranchOperations", testBranchOperations), + ("testTagOperations", testTagOperations), + ("testRemoteOperations", testRemoteOperations), + ("testSubmoduleOperations", testSubmoduleOperations), + ("testRevList", testRevList), + ("testLsRemote", testLsRemote), + ("testCommitVariations", testCommitVariations), + ("testLogVariations", testLogVariations), ] // MARK: - helpers @@ -150,6 +163,361 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) } + func testAddAll() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + try FileManager.default.createFile(atPath: "\(path)/test.txt", contents: "test content".data(using: .utf8)) + + try git.run(.addAll) + + let statusOutput = try git.run(.status()) + XCTAssertTrue(statusOutput.contains("new file"), "File should be staged") + + try self.clean(path: path) + } + + func testStatusShort() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + try FileManager.default.createFile(atPath: "\(path)/file.txt", contents: "test".data(using: .utf8)) + try git.run(.addAll) + + // Test status with short flag + let shortStatus = try git.run(.status(short: true)) + let regularStatus = try git.run(.status(short: false)) + + // Short status should be more concise + XCTAssertTrue(shortStatus.count < regularStatus.count, "Short status should be more concise") + + try self.clean(path: path) + } + + func testConfigOperations() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + + // Test write config using raw commands first to set them up + try git.run(.raw("config user.name 'Test User'")) + try git.run(.raw("config user.email 'test@example.com'")) + + // Test read config + let userName = try git.run(.readConfig(name: "user.name")) + let userEmail = try git.run(.readConfig(name: "user.email")) + + XCTAssertEqual(userName, "Test User", "Should read the configured user name") + XCTAssertEqual(userEmail, "test@example.com", "Should read the configured user email") + + try self.clean(path: path) + } + + func testWriteConfigDefaultBranch() throws { + let path = self.currentPath() + + try self.clean(path: path) + + // Set the default branch name to "myDefaultBranch" using global config + let globalGit = Git() // No path - will use global config + try globalGit.run(.raw("config --global init.defaultBranch myDefaultBranch")) + + // Now create the directory and git repo (should use the configured default branch) + let git = Git(path: path) + try git.run(.raw("init")) + + // Make an initial commit so we can check the current branch + try git.run(.commit(message: "initial commit", allowEmpty: true)) + + // Ensure that's the branch we're on + let currentBranch = try git.run(.revParse(abbrevRef: true, revision: "HEAD")) + XCTAssertEqual(currentBranch, "myDefaultBranch", "Should be on the configured default branch") + + // Also verify using git branch command + let branchOutput = try git.run(.raw("branch")) + XCTAssertTrue(branchOutput.contains("* myDefaultBranch"), "Should show current branch as myDefaultBranch") + + // Reset the git config to remove the custom default branch setting + // Use --unset-all to handle cases where the config might be set multiple times + do { + try globalGit.run(.raw("config --global --unset-all init.defaultBranch")) + } catch { + // It's okay if this fails - the config might not exist + } + + // Clean up + try self.clean(path: path) + } + + func testPushPull() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + // Clone a repository to have a remote + try git.run(.clone(url: "https://github.com/binarybirds/shell-kit.git")) + + let repoPath = "\(path)/shell-kit" + let repoGit = Git(path: repoPath) + + // Test fetch + try repoGit.run(.fetch()) + try repoGit.run(.fetch(remote: "origin")) + try repoGit.run(.fetch(remote: "origin", branch: "main")) + + // Test pull variations + try repoGit.run(.pull()) + try repoGit.run(.pull(remote: "origin")) + try repoGit.run(.pull(remote: "origin", branch: "main")) + try repoGit.run(.pull(remote: "origin", branch: "main", rebase: true)) + + // Note: We can't easily test push without write access, but we can test the command generation + // by testing the raw value generation + let pushCommand = Git.Alias.push(remote: "origin", branch: "main") + XCTAssertEqual(pushCommand.rawValue, "push origin main", "Push command should be properly formatted") + + try self.clean(path: path) + } + + func testBranchOperations() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + try git.run(.commit(message: "initial", allowEmpty: true)) + + // Test create branch + try git.run(.create(branch: "feature-branch")) + + // Test checkout to new branch + try git.run(.checkout(branch: "another-branch", create: true)) + + // Test merge (back to main first) + try git.run(.checkout(branch: "main")) + try git.run(.merge(branch: "feature-branch")) + + // Test delete branch + try git.run(.delete(branch: "feature-branch")) + + // Verify branch operations worked + let branchOutput = try git.run(.raw("branch")) + XCTAssertTrue(branchOutput.contains("another-branch"), "Branch should exist") + XCTAssertFalse(branchOutput.contains("feature-branch"), "Deleted branch should not exist") + + try self.clean(path: path) + } + + func testTagOperations() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + try git.run(.commit(message: "initial", allowEmpty: true)) + + // Test tag creation + try git.run(.tag("v1.0.0")) + try git.run(.tag("v1.1.0")) + + // Verify tags were created + let tagOutput = try git.run(.raw("tag")) + XCTAssertTrue(tagOutput.contains("v1.0.0"), "Tag v1.0.0 should exist") + XCTAssertTrue(tagOutput.contains("v1.1.0"), "Tag v1.1.0 should exist") + + try self.clean(path: path) + } + + func testRemoteOperations() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + + // Test add remote + try git.run(.addRemote(name: "origin", url: "https://github.com/test/repo.git")) + try git.run(.addRemote(name: "upstream", url: "https://github.com/upstream/repo.git")) + + // Test rename remote + try git.run(.renameRemote(oldName: "upstream", newName: "upstream-new")) + + // Verify remotes + let remoteOutput = try git.run(.raw("remote -v")) + XCTAssertTrue(remoteOutput.contains("origin"), "Origin remote should exist") + XCTAssertTrue(remoteOutput.contains("upstream-new"), "Renamed remote should exist") + XCTAssertFalse(remoteOutput.contains("upstream\t"), "Old remote name should not exist") + + try self.clean(path: path) + } + + func testSubmoduleOperations() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + try git.run(.commit(message: "initial", allowEmpty: true)) + + // Add a submodule + try git.run(.raw("submodule add https://github.com/binarybirds/shell-kit.git submodules/shell-kit")) + + // Test submodule update variations + try git.run(.submoduleUpdate()) + try git.run(.submoduleUpdate(init: true)) + try git.run(.submoduleUpdate(recursive: true)) + try git.run(.submoduleUpdate(init: true, recursive: true, rebase: true)) + + // Test submodule foreach + try git.run(.submoduleForeach(recursive: false, command: "pwd")) + try git.run(.submoduleForeach(recursive: true, command: "git status")) + + try self.clean(path: path) + } + + func testRevList() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + try git.run(.commit(message: "first", allowEmpty: true)) + try git.run(.commit(message: "second", allowEmpty: true)) + + let commitCount = try git.run(.revList(branch: "HEAD", count: true)) + let commitList = try git.run(.revList(branch: "HEAD")) + let commitRange = try git.run(.revList(branch: "HEAD", revisions: "HEAD~1")) + + XCTAssertEqual(commitCount.trimmingCharacters(in: .whitespacesAndNewlines), "2", "Should have 2 commits") + XCTAssertTrue(commitList.contains("\n"), "Should list multiple commits") + XCTAssertFalse(commitRange.isEmpty, "Should return commit range") + + try self.clean(path: path) + } + + func testLsRemote() throws { + let path = self.currentPath() + + try self.clean(path: path) + + let git = Git(path: path) + + try git.run(.raw("init")) + try git.run(.raw("config user.name 'Test User'")) + try git.run(.raw("config user.email 'test@example.com'")) + + try git.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) + + try git.run(.raw("checkout -b feature/test-feature")) + try git.run(.raw("commit -m 'feature commit' --allow-empty --no-gpg-sign")) + + try git.run(.raw("checkout -b develop")) + try git.run(.raw("commit -m 'develop commit' --allow-empty --no-gpg-sign")) + + try git.run(.raw("checkout main")) + try git.run(.raw("tag v1.0.0")) + try git.run(.raw("tag v1.1.0")) + + let currentDirectory = FileManager.default.currentDirectoryPath + let absolutePath = "\(currentDirectory)/\(path)" + let remoteRefs = try git.run(.lsRemote(url: absolutePath)) + let headsOnly = try git.run(.lsRemote(url: absolutePath, limitToHeads: true)) + + XCTAssertTrue(remoteRefs.contains("refs/heads/main"), "Should contain main branch") + XCTAssertTrue(remoteRefs.contains("refs/heads/feature/test-feature"), "Should contain feature branch") + XCTAssertTrue(remoteRefs.contains("refs/heads/develop"), "Should contain develop branch") + + XCTAssertTrue(remoteRefs.contains("refs/tags/v1.0.0"), "Should contain v1.0.0 tag") + XCTAssertTrue(remoteRefs.contains("refs/tags/v1.1.0"), "Should contain v1.1.0 tag") + + XCTAssertTrue(headsOnly.contains("refs/heads/main"), "Heads-only should contain main branch") + XCTAssertTrue(headsOnly.contains("refs/heads/feature/test-feature"), "Heads-only should contain feature branch") + XCTAssertTrue(headsOnly.contains("refs/heads/develop"), "Heads-only should contain develop branch") + XCTAssertFalse(headsOnly.contains("refs/tags/"), "Heads-only should NOT contain tags") + + let headsOnlyLines = headsOnly.components(separatedBy: CharacterSet.newlines).filter { !$0.isEmpty } + let fullRefsLines = remoteRefs.components(separatedBy: CharacterSet.newlines).filter { !$0.isEmpty } + XCTAssertTrue(headsOnlyLines.count < fullRefsLines.count, "Heads-only should have fewer refs than full listing") + XCTAssertEqual(headsOnlyLines.count, 3, "Should have exactly 3 branches") + XCTAssertTrue(fullRefsLines.count >= 5, "Full refs should include branches and tags") + + try self.clean(path: path) + } + + func testCommitVariations() throws { + let signedCommitAlias = Git.Alias.commit(message: "test signed", allowEmpty: true, gpgSigned: true) + XCTAssertTrue(signedCommitAlias.rawValue.contains("--gpg-sign"), "GPG signed commit should include --gpg-sign flag") + XCTAssertFalse(signedCommitAlias.rawValue.contains("--no-gpg-sign"), "GPG signed commit should NOT include --no-gpg-sign flag") + + let unsignedCommitAlias = Git.Alias.commit(message: "test unsigned", allowEmpty: true, gpgSigned: false) + XCTAssertTrue(unsignedCommitAlias.rawValue.contains("--no-gpg-sign"), "Unsigned commit should include --no-gpg-sign flag") + XCTAssertFalse(unsignedCommitAlias.rawValue.contains("--gpg-sign"), "Unsigned commit should NOT include --gpg-sign flag") + } + + func testLogVariations() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + try git.run(.commit(message: "first commit", allowEmpty: true)) + try git.run(.commit(message: "second commit", allowEmpty: true)) + try git.run(.commit(message: "third commit", allowEmpty: true)) + + let limitedLog = try git.run(.log(numberOfCommits: 2)) + let fullLog = try git.run(.log()) + let onelineLog = try git.run(.log(options: ["--oneline"])) + let prettyLog = try git.run(.log(numberOfCommits: 1, options: ["--pretty=format:%s"])) + let singleCommitLog = try git.run(.log(numberOfCommits: 1)) + + XCTAssertTrue(limitedLog.contains("third commit"), "Limited log should contain third commit") + XCTAssertTrue(limitedLog.contains("second commit"), "Limited log should contain second commit") + XCTAssertFalse(limitedLog.contains("first commit"), "Limited log should NOT contain first commit") + XCTAssertTrue(limitedLog.contains("commit "), "Limited log should contain full commit format") + XCTAssertTrue(limitedLog.contains("Author:"), "Limited log should contain author info") + XCTAssertTrue(limitedLog.contains("Date:"), "Limited log should contain date info") + + XCTAssertTrue(fullLog.contains("first commit"), "Full log should contain first commit") + XCTAssertTrue(fullLog.contains("second commit"), "Full log should contain second commit") + XCTAssertTrue(fullLog.contains("third commit"), "Full log should contain third commit") + XCTAssertTrue(fullLog.count > limitedLog.count, "Full log should be longer than limited log") + + XCTAssertTrue(onelineLog.contains("first commit"), "Oneline log should contain first commit") + XCTAssertTrue(onelineLog.contains("second commit"), "Oneline log should contain second commit") + XCTAssertTrue(onelineLog.contains("third commit"), "Oneline log should contain third commit") + XCTAssertFalse(onelineLog.contains("Author:"), "Oneline log should NOT contain author info") + XCTAssertFalse(onelineLog.contains("Date:"), "Oneline log should NOT contain date info") + // Oneline format should be much more compact + XCTAssertTrue(onelineLog.count < fullLog.count / 2, "Oneline log should be much shorter than full log") + + XCTAssertEqual(prettyLog.trimmingCharacters(in: .whitespacesAndNewlines), "third commit", "Pretty log should contain only the commit message") + XCTAssertFalse(prettyLog.contains("commit "), "Pretty log should NOT contain commit hash") + XCTAssertFalse(prettyLog.contains("Author:"), "Pretty log should NOT contain author info") + XCTAssertFalse(prettyLog.contains("Date:"), "Pretty log should NOT contain date info") + + XCTAssertTrue(singleCommitLog.contains("third commit"), "Single commit log should contain latest commit") + XCTAssertFalse(singleCommitLog.contains("second commit"), "Single commit log should NOT contain second commit") + XCTAssertFalse(singleCommitLog.contains("first commit"), "Single commit log should NOT contain first commit") + + try self.clean(path: path) + } + #if os(macOS) func testAsyncRun() throws { let path = self.currentPath() From 1cb725bdf0dc23c7191a2aa3113c096a51a9b20c Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Sat, 19 Jul 2025 23:03:03 -0800 Subject: [PATCH 2/5] make tests that used to go out over the network instead use local clones --- Tests/GitKitTests/GitKitTests.swift | 182 +++++++++++++++++++++------- 1 file changed, 136 insertions(+), 46 deletions(-) diff --git a/Tests/GitKitTests/GitKitTests.swift b/Tests/GitKitTests/GitKitTests.swift index 42e9252..6e1c5a7 100644 --- a/Tests/GitKitTests/GitKitTests.swift +++ b/Tests/GitKitTests/GitKitTests.swift @@ -100,42 +100,97 @@ final class GitKitTests: XCTestCase { } func testClone() throws { - let path = self.currentPath() - - let expectation = """ - On branch main - Your branch is up to date with 'origin/main'. - - nothing to commit, working tree clean - """ - - try self.clean(path: path) - let git = Git(path: path) - - try git.run(.clone(url: "https://github.com/binarybirds/shell-kit.git")) - let statusOutput = try git.run("cd \(path)/shell-kit && git status") - try self.clean(path: path) - self.assert(type: "output", result: statusOutput, expected: expectation) + let basePath = self.currentPath() + let currentDirectory = FileManager.default.currentDirectoryPath + let sourcePath = "\(currentDirectory)/\(basePath)-source" + let clonePath = "\(currentDirectory)/\(basePath)-clone" + + try self.clean(path: sourcePath) + try self.clean(path: clonePath) + + // Create a source repository to clone from + let sourceGit = Git(path: sourcePath) + try sourceGit.run(.raw("init")) + try sourceGit.run(.raw("config user.name 'Test User'")) + try sourceGit.run(.raw("config user.email 'test@example.com'")) + try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) + + // Clone from the local source repository + let git = Git(path: clonePath) + try git.run(.clone(url: sourcePath)) + + // Verify the clone worked + let clonedRepoName = sourcePath.components(separatedBy: "/").last! + let statusOutput = try git.run("cd \(clonePath)/\(clonedRepoName) && git status") + XCTAssertTrue(statusOutput.contains("On branch main"), "Should be on main branch") + XCTAssertTrue(statusOutput.contains("nothing to commit"), "Should be clean working directory") + + try self.clean(path: sourcePath) + try self.clean(path: clonePath) } func testCloneWithDirectory() throws { - let path = self.currentPath() + let basePath = self.currentPath() + let currentDirectory = FileManager.default.currentDirectoryPath + let sourcePath = "\(currentDirectory)/\(basePath)-source" + let clonePath = "\(currentDirectory)/\(basePath)-clone" + + try self.clean(path: sourcePath) + try self.clean(path: clonePath) + + // Create a source repository to clone from + let sourceGit = Git(path: sourcePath) + try sourceGit.run(.raw("init")) + try sourceGit.run(.raw("config user.name 'Test User'")) + try sourceGit.run(.raw("config user.email 'test@example.com'")) + try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) + + // Clone from the local source repository with custom directory name + let git = Git(path: clonePath) + try git.run(.clone(url: sourcePath, dirName: "MyCustomDirectory")) + + // Verify the clone worked in the custom directory + let statusOutput = try git.run("cd \(clonePath)/MyCustomDirectory && git status") + XCTAssertTrue(statusOutput.contains("On branch main"), "Should be on main branch") + XCTAssertTrue(statusOutput.contains("nothing to commit"), "Should be clean working directory") + + try self.clean(path: sourcePath) + try self.clean(path: clonePath) + } + + func testCheckoutRemoteTracking() throws { + let basePath = self.currentPath() + let currentDirectory = FileManager.default.currentDirectoryPath + let sourcePath = "\(currentDirectory)/\(basePath)-source" + let clonePath = "\(currentDirectory)/\(basePath)-clone" - let expectation = """ - On branch main - Your branch is up to date with 'origin/main'. - - nothing to commit, working tree clean - """ + try self.clean(path: sourcePath) + try self.clean(path: clonePath) - try self.clean(path: path) - let git = Git(path: path) + // Create a source repository to clone from + let sourceGit = Git(path: sourcePath) + try sourceGit.run(.raw("init")) + try sourceGit.run(.raw("config user.name 'Test User'")) + try sourceGit.run(.raw("config user.email 'test@example.com'")) + try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) - try git.run(.clone(url: "https://github.com/binarybirds/shell-kit.git", dirName: "MyCustomDirectory")) - let statusOutput = try git.run("cd \(path)/MyCustomDirectory && git status") - try self.clean(path: path) - self.assert(type: "output", result: statusOutput, expected: expectation) + // Clone from the local source repository + let git = Git(path: clonePath) + try git.run(.clone(url: sourcePath)) + + let clonedRepoName = sourcePath.components(separatedBy: "/").last! + let repoPath = "\(clonePath)/\(clonedRepoName)" + let repoGit = Git(path: repoPath) + + try repoGit.run(.checkout(branch: "feature-branch", create: true, tracking: "origin/main")) + let branchOutput = try repoGit.run(.raw("branch -vv")) + + XCTAssertTrue(branchOutput.contains("feature-branch"), "New branch should be created") + XCTAssertTrue(branchOutput.contains("origin/main"), "Branch should track origin/main") + + try self.clean(path: sourcePath) + try self.clean(path: clonePath) } func testRevParse() throws { @@ -259,15 +314,27 @@ final class GitKitTests: XCTestCase { } func testPushPull() throws { - let path = self.currentPath() + let basePath = self.currentPath() + let currentDirectory = FileManager.default.currentDirectoryPath + let sourcePath = "\(currentDirectory)/\(basePath)-source" + let clonePath = "\(currentDirectory)/\(basePath)-clone" - try self.clean(path: path) - let git = Git(path: path) + try self.clean(path: sourcePath) + try self.clean(path: clonePath) - // Clone a repository to have a remote - try git.run(.clone(url: "https://github.com/binarybirds/shell-kit.git")) + // Create a source repository to clone from + let sourceGit = Git(path: sourcePath) + try sourceGit.run(.raw("init")) + try sourceGit.run(.raw("config user.name 'Test User'")) + try sourceGit.run(.raw("config user.email 'test@example.com'")) + try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) - let repoPath = "\(path)/shell-kit" + // Clone from the local source repository + let git = Git(path: clonePath) + try git.run(.clone(url: sourcePath)) + + let clonedRepoName = sourcePath.components(separatedBy: "/").last! + let repoPath = "\(clonePath)/\(clonedRepoName)" let repoGit = Git(path: repoPath) // Test fetch @@ -281,12 +348,12 @@ final class GitKitTests: XCTestCase { try repoGit.run(.pull(remote: "origin", branch: "main")) try repoGit.run(.pull(remote: "origin", branch: "main", rebase: true)) - // Note: We can't easily test push without write access, but we can test the command generation - // by testing the raw value generation + // Test command generation for push let pushCommand = Git.Alias.push(remote: "origin", branch: "main") XCTAssertEqual(pushCommand.rawValue, "push origin main", "Push command should be properly formatted") - try self.clean(path: path) + try self.clean(path: sourcePath) + try self.clean(path: clonePath) } func testBranchOperations() throws { @@ -365,28 +432,51 @@ final class GitKitTests: XCTestCase { } func testSubmoduleOperations() throws { - let path = self.currentPath() + let basePath = self.currentPath() + let currentDirectory = FileManager.default.currentDirectoryPath + let mainRepoPath = "\(currentDirectory)/\(basePath)-main" + let submoduleRepoPath = "\(currentDirectory)/\(basePath)-submodule" - try self.clean(path: path) - let git = Git(path: path) + try self.clean(path: mainRepoPath) + try self.clean(path: submoduleRepoPath) + + // Create a repository to use as a submodule + let submoduleGit = Git(path: submoduleRepoPath) + try submoduleGit.run(.raw("init")) + try submoduleGit.run(.raw("config user.name 'Test User'")) + try submoduleGit.run(.raw("config user.email 'test@example.com'")) + try submoduleGit.run(.raw("commit -m 'submodule initial commit' --allow-empty --no-gpg-sign")) + // Create main repository and add submodule + let git = Git(path: mainRepoPath) try git.run(.raw("init")) + try git.run(.raw("config user.name 'Test User'")) + try git.run(.raw("config user.email 'test@example.com'")) try git.run(.commit(message: "initial", allowEmpty: true)) - // Add a submodule - try git.run(.raw("submodule add https://github.com/binarybirds/shell-kit.git submodules/shell-kit")) + // Try to set global config to allow file protocol + try git.run(.raw("config --global protocol.file.allow always")) + + // Use absolute path directly (without file:// protocol) + try git.run(.raw("submodule add \(submoduleRepoPath) submodules/test-submodule")) - // Test submodule update variations try git.run(.submoduleUpdate()) try git.run(.submoduleUpdate(init: true)) try git.run(.submoduleUpdate(recursive: true)) try git.run(.submoduleUpdate(init: true, recursive: true, rebase: true)) - // Test submodule foreach try git.run(.submoduleForeach(recursive: false, command: "pwd")) try git.run(.submoduleForeach(recursive: true, command: "git status")) - try self.clean(path: path) + // Verify submodule was added + let statusOutput = try git.run(.raw("submodule status")) + XCTAssertTrue(statusOutput.contains("test-submodule"), "Submodule should be listed in status") + + // Clean up global config + try git.run(.raw("config --global --unset protocol.file.allow")) + + try self.clean(path: mainRepoPath) + try self.clean(path: submoduleRepoPath) } func testRevList() throws { From 0560615f556349f686227fb62cfd3740bcb6321d Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Sat, 19 Jul 2025 23:39:05 -0800 Subject: [PATCH 3/5] wip updating tests --- Tests/GitKitTests/GitKitTests.swift | 113 ++++++++++++---------------- 1 file changed, 47 insertions(+), 66 deletions(-) diff --git a/Tests/GitKitTests/GitKitTests.swift b/Tests/GitKitTests/GitKitTests.swift index 6e1c5a7..b2916ec 100644 --- a/Tests/GitKitTests/GitKitTests.swift +++ b/Tests/GitKitTests/GitKitTests.swift @@ -29,7 +29,7 @@ final class GitKitTests: XCTestCase { ("testAddAll", testAddAll), ("testStatusShort", testStatusShort), ("testConfigOperations", testConfigOperations), - ("testWriteConfigDefaultBranch", testWriteConfigDefaultBranch), + ("testWriteConfigUserSettings", testWriteConfigUserSettings), ("testPushPull", testPushPull), ("testBranchOperations", testBranchOperations), ("testTagOperations", testTagOperations), @@ -108,18 +108,15 @@ final class GitKitTests: XCTestCase { try self.clean(path: sourcePath) try self.clean(path: clonePath) - // Create a source repository to clone from let sourceGit = Git(path: sourcePath) try sourceGit.run(.raw("init")) try sourceGit.run(.raw("config user.name 'Test User'")) try sourceGit.run(.raw("config user.email 'test@example.com'")) try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) - // Clone from the local source repository let git = Git(path: clonePath) try git.run(.clone(url: sourcePath)) - // Verify the clone worked let clonedRepoName = sourcePath.components(separatedBy: "/").last! let statusOutput = try git.run("cd \(clonePath)/\(clonedRepoName) && git status") XCTAssertTrue(statusOutput.contains("On branch main"), "Should be on main branch") @@ -139,18 +136,15 @@ final class GitKitTests: XCTestCase { try self.clean(path: sourcePath) try self.clean(path: clonePath) - // Create a source repository to clone from let sourceGit = Git(path: sourcePath) try sourceGit.run(.raw("init")) try sourceGit.run(.raw("config user.name 'Test User'")) try sourceGit.run(.raw("config user.email 'test@example.com'")) try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) - // Clone from the local source repository with custom directory name let git = Git(path: clonePath) try git.run(.clone(url: sourcePath, dirName: "MyCustomDirectory")) - // Verify the clone worked in the custom directory let statusOutput = try git.run("cd \(clonePath)/MyCustomDirectory && git status") XCTAssertTrue(statusOutput.contains("On branch main"), "Should be on main branch") XCTAssertTrue(statusOutput.contains("nothing to commit"), "Should be clean working directory") @@ -168,14 +162,12 @@ final class GitKitTests: XCTestCase { try self.clean(path: sourcePath) try self.clean(path: clonePath) - // Create a source repository to clone from let sourceGit = Git(path: sourcePath) try sourceGit.run(.raw("init")) try sourceGit.run(.raw("config user.name 'Test User'")) try sourceGit.run(.raw("config user.email 'test@example.com'")) try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) - // Clone from the local source repository let git = Git(path: clonePath) try git.run(.clone(url: sourcePath)) @@ -225,7 +217,7 @@ final class GitKitTests: XCTestCase { let git = Git(path: path) try git.run(.raw("init")) - try FileManager.default.createFile(atPath: "\(path)/test.txt", contents: "test content".data(using: .utf8)) + FileManager.default.createFile(atPath: "\(path)/test.txt", contents: "test content".data(using: .utf8)) try git.run(.addAll) @@ -242,14 +234,12 @@ final class GitKitTests: XCTestCase { let git = Git(path: path) try git.run(.raw("init")) - try FileManager.default.createFile(atPath: "\(path)/file.txt", contents: "test".data(using: .utf8)) + FileManager.default.createFile(atPath: "\(path)/file.txt", contents: "test".data(using: .utf8)) try git.run(.addAll) - // Test status with short flag let shortStatus = try git.run(.status(short: true)) let regularStatus = try git.run(.status(short: false)) - // Short status should be more concise XCTAssertTrue(shortStatus.count < regularStatus.count, "Short status should be more concise") try self.clean(path: path) @@ -263,11 +253,9 @@ final class GitKitTests: XCTestCase { try git.run(.raw("init")) - // Test write config using raw commands first to set them up try git.run(.raw("config user.name 'Test User'")) try git.run(.raw("config user.email 'test@example.com'")) - // Test read config let userName = try git.run(.readConfig(name: "user.name")) let userEmail = try git.run(.readConfig(name: "user.email")) @@ -277,39 +265,35 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) } - func testWriteConfigDefaultBranch() throws { + func testWriteConfigUserSettings() throws { let path = self.currentPath() try self.clean(path: path) - // Set the default branch name to "myDefaultBranch" using global config - let globalGit = Git() // No path - will use global config - try globalGit.run(.raw("config --global init.defaultBranch myDefaultBranch")) - - // Now create the directory and git repo (should use the configured default branch) let git = Git(path: path) try git.run(.raw("init")) - // Make an initial commit so we can check the current branch - try git.run(.commit(message: "initial commit", allowEmpty: true)) + try git.run(.raw("config user.name 'Test User GitKit'")) + try git.run(.raw("config user.email 'test@gitkit.example.com'")) + try git.run(.raw("config core.editor 'vim'")) - // Ensure that's the branch we're on - let currentBranch = try git.run(.revParse(abbrevRef: true, revision: "HEAD")) - XCTAssertEqual(currentBranch, "myDefaultBranch", "Should be on the configured default branch") + let userName = try git.run(.readConfig(name: "user.name")) + let userEmail = try git.run(.readConfig(name: "user.email")) + let coreEditor = try git.run(.readConfig(name: "core.editor")) - // Also verify using git branch command - let branchOutput = try git.run(.raw("branch")) - XCTAssertTrue(branchOutput.contains("* myDefaultBranch"), "Should show current branch as myDefaultBranch") + XCTAssertEqual(userName.trimmingCharacters(in: .whitespacesAndNewlines), "Test User GitKit", "User name should be set correctly") + XCTAssertEqual(userEmail.trimmingCharacters(in: .whitespacesAndNewlines), "test@gitkit.example.com", "User email should be set correctly") + XCTAssertEqual(coreEditor.trimmingCharacters(in: .whitespacesAndNewlines), "vim", "Core editor should be set correctly") - // Reset the git config to remove the custom default branch setting - // Use --unset-all to handle cases where the config might be set multiple times - do { - try globalGit.run(.raw("config --global --unset-all init.defaultBranch")) - } catch { - // It's okay if this fails - the config might not exist - } + try git.run(.commit(message: "test commit", allowEmpty: true)) + + let logOutput = try git.run(.raw("log --format='%an <%ae>' -1")) + XCTAssertTrue(logOutput.contains("Test User GitKit "), "Commit should use the configured user information") + + try git.run(.raw("config user.name 'Updated User'")) + let updatedUserName = try git.run(.readConfig(name: "user.name")) + XCTAssertTrue(updatedUserName.contains("Updated User"), "Should be able to update existing config values") - // Clean up try self.clean(path: path) } @@ -322,14 +306,12 @@ final class GitKitTests: XCTestCase { try self.clean(path: sourcePath) try self.clean(path: clonePath) - // Create a source repository to clone from let sourceGit = Git(path: sourcePath) try sourceGit.run(.raw("init")) try sourceGit.run(.raw("config user.name 'Test User'")) try sourceGit.run(.raw("config user.email 'test@example.com'")) try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) - // Clone from the local source repository let git = Git(path: clonePath) try git.run(.clone(url: sourcePath)) @@ -337,18 +319,15 @@ final class GitKitTests: XCTestCase { let repoPath = "\(clonePath)/\(clonedRepoName)" let repoGit = Git(path: repoPath) - // Test fetch try repoGit.run(.fetch()) try repoGit.run(.fetch(remote: "origin")) try repoGit.run(.fetch(remote: "origin", branch: "main")) - // Test pull variations try repoGit.run(.pull()) try repoGit.run(.pull(remote: "origin")) try repoGit.run(.pull(remote: "origin", branch: "main")) try repoGit.run(.pull(remote: "origin", branch: "main", rebase: true)) - // Test command generation for push let pushCommand = Git.Alias.push(remote: "origin", branch: "main") XCTAssertEqual(pushCommand.rawValue, "push origin main", "Push command should be properly formatted") @@ -365,20 +344,15 @@ final class GitKitTests: XCTestCase { try git.run(.raw("init")) try git.run(.commit(message: "initial", allowEmpty: true)) - // Test create branch try git.run(.create(branch: "feature-branch")) - // Test checkout to new branch try git.run(.checkout(branch: "another-branch", create: true)) - // Test merge (back to main first) try git.run(.checkout(branch: "main")) try git.run(.merge(branch: "feature-branch")) - // Test delete branch try git.run(.delete(branch: "feature-branch")) - // Verify branch operations worked let branchOutput = try git.run(.raw("branch")) XCTAssertTrue(branchOutput.contains("another-branch"), "Branch should exist") XCTAssertFalse(branchOutput.contains("feature-branch"), "Deleted branch should not exist") @@ -395,11 +369,9 @@ final class GitKitTests: XCTestCase { try git.run(.raw("init")) try git.run(.commit(message: "initial", allowEmpty: true)) - // Test tag creation try git.run(.tag("v1.0.0")) try git.run(.tag("v1.1.0")) - // Verify tags were created let tagOutput = try git.run(.raw("tag")) XCTAssertTrue(tagOutput.contains("v1.0.0"), "Tag v1.0.0 should exist") XCTAssertTrue(tagOutput.contains("v1.1.0"), "Tag v1.1.0 should exist") @@ -414,15 +386,12 @@ final class GitKitTests: XCTestCase { let git = Git(path: path) try git.run(.raw("init")) - - // Test add remote + try git.run(.addRemote(name: "origin", url: "https://github.com/test/repo.git")) try git.run(.addRemote(name: "upstream", url: "https://github.com/upstream/repo.git")) - // Test rename remote try git.run(.renameRemote(oldName: "upstream", newName: "upstream-new")) - // Verify remotes let remoteOutput = try git.run(.raw("remote -v")) XCTAssertTrue(remoteOutput.contains("origin"), "Origin remote should exist") XCTAssertTrue(remoteOutput.contains("upstream-new"), "Renamed remote should exist") @@ -440,24 +409,31 @@ final class GitKitTests: XCTestCase { try self.clean(path: mainRepoPath) try self.clean(path: submoduleRepoPath) - // Create a repository to use as a submodule let submoduleGit = Git(path: submoduleRepoPath) try submoduleGit.run(.raw("init")) try submoduleGit.run(.raw("config user.name 'Test User'")) try submoduleGit.run(.raw("config user.email 'test@example.com'")) try submoduleGit.run(.raw("commit -m 'submodule initial commit' --allow-empty --no-gpg-sign")) - // Create main repository and add submodule + // Save the current global config value (if any) and set it temporarily let git = Git(path: mainRepoPath) + var originalConfigValue: String? + do { + originalConfigValue = try git.run(.raw("config --global --get protocol.file.allow")) + } catch { + // Config doesn't exist, which is fine + originalConfigValue = nil + } + try git.run(.raw("init")) try git.run(.raw("config user.name 'Test User'")) try git.run(.raw("config user.email 'test@example.com'")) - try git.run(.commit(message: "initial", allowEmpty: true)) - // Try to set global config to allow file protocol + // Set the protocol.file.allow config temporarily try git.run(.raw("config --global protocol.file.allow always")) - // Use absolute path directly (without file:// protocol) + try git.run(.commit(message: "initial", allowEmpty: true)) + try git.run(.raw("submodule add \(submoduleRepoPath) submodules/test-submodule")) try git.run(.submoduleUpdate()) @@ -468,12 +444,16 @@ final class GitKitTests: XCTestCase { try git.run(.submoduleForeach(recursive: false, command: "pwd")) try git.run(.submoduleForeach(recursive: true, command: "git status")) - // Verify submodule was added let statusOutput = try git.run(.raw("submodule status")) XCTAssertTrue(statusOutput.contains("test-submodule"), "Submodule should be listed in status") - // Clean up global config - try git.run(.raw("config --global --unset protocol.file.allow")) + // Restore the original global config value + if let originalValue = originalConfigValue { + try git.run(.raw("config --global protocol.file.allow \(originalValue)")) + } else { + // Config didn't exist before, so remove it + _ = try? git.run(.raw("config --global --unset protocol.file.allow")) + } try self.clean(path: mainRepoPath) try self.clean(path: submoduleRepoPath) @@ -484,15 +464,16 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) let git = Git(path: path) - + git.verbose = true + try git.run(.raw("init")) try git.run(.commit(message: "first", allowEmpty: true)) try git.run(.commit(message: "second", allowEmpty: true)) - let commitCount = try git.run(.revList(branch: "HEAD", count: true)) - let commitList = try git.run(.revList(branch: "HEAD")) - let commitRange = try git.run(.revList(branch: "HEAD", revisions: "HEAD~1")) - + let commitCount = try git.run(.revList(count: true, revisions: "HEAD")) + let commitList = try git.run(.revList(revisions: "HEAD")) + let commitRange = try git.run(.revList(revisions: "HEAD HEAD~1")) + XCTAssertEqual(commitCount.trimmingCharacters(in: .whitespacesAndNewlines), "2", "Should have 2 commits") XCTAssertTrue(commitList.contains("\n"), "Should list multiple commits") XCTAssertFalse(commitRange.isEmpty, "Should return commit range") From e4ed1fafab9d9acfb7e88ae23ad47e27a8e1ac4f Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Mon, 4 Aug 2025 17:47:45 -0800 Subject: [PATCH 4/5] add more coverage and fix a few things --- Sources/GitKit/Git.swift | 8 ---- Tests/GitKitTests/GitKitTests.swift | 61 ++++++++++++++--------------- 2 files changed, 30 insertions(+), 39 deletions(-) diff --git a/Sources/GitKit/Git.swift b/Sources/GitKit/Git.swift index 09738db..5cb8a40 100644 --- a/Sources/GitKit/Git.swift +++ b/Sources/GitKit/Git.swift @@ -16,7 +16,6 @@ public final class Git: Shell { case cmd(Command, String? = nil) case addAll case status(short: Bool = false) - case clone(url: String , dirName: String? = nil) case commit(message: String, allowEmpty: Bool = false, gpgSigned: Bool = false) case writeConfig(name: String, value: String) case readConfig(name: String) @@ -95,7 +94,6 @@ public final class Git: Shell { if let options = options { params.append(contentsOf: options) } - params.append("--") if let revisions = revisions { params.append(revisions) } @@ -169,12 +167,6 @@ public final class Git: Shell { if let revisions = revisions { params.append(revisions) } - case .revParse(let abbrevRef, let revision): - params = [Command.revParse.rawValue] - if abbrevRef { - params.append("--abbrev-ref") - } - params.append(revision) case .lsRemote(url: let url, limitToHeads: let limitToHeads): params = [Command.lsRemote.rawValue] if limitToHeads { diff --git a/Tests/GitKitTests/GitKitTests.swift b/Tests/GitKitTests/GitKitTests.swift index b2916ec..b72730d 100644 --- a/Tests/GitKitTests/GitKitTests.swift +++ b/Tests/GitKitTests/GitKitTests.swift @@ -28,8 +28,7 @@ final class GitKitTests: XCTestCase { ("testRevParse", testRevParse), ("testAddAll", testAddAll), ("testStatusShort", testStatusShort), - ("testConfigOperations", testConfigOperations), - ("testWriteConfigUserSettings", testWriteConfigUserSettings), + ("testGitConfig", testGitConfig), ("testPushPull", testPushPull), ("testBranchOperations", testBranchOperations), ("testTagOperations", testTagOperations), @@ -39,6 +38,7 @@ final class GitKitTests: XCTestCase { ("testLsRemote", testLsRemote), ("testCommitVariations", testCommitVariations), ("testLogVariations", testLogVariations), + ("testLogWithRevisions", testLogWithRevisions), ] // MARK: - helpers @@ -117,7 +117,7 @@ final class GitKitTests: XCTestCase { let git = Git(path: clonePath) try git.run(.clone(url: sourcePath)) - let clonedRepoName = sourcePath.components(separatedBy: "/").last! + let clonedRepoName = try XCTUnwrap(sourcePath.components(separatedBy: "/").last) let statusOutput = try git.run("cd \(clonePath)/\(clonedRepoName) && git status") XCTAssertTrue(statusOutput.contains("On branch main"), "Should be on main branch") XCTAssertTrue(statusOutput.contains("nothing to commit"), "Should be clean working directory") @@ -171,7 +171,7 @@ final class GitKitTests: XCTestCase { let git = Git(path: clonePath) try git.run(.clone(url: sourcePath)) - let clonedRepoName = sourcePath.components(separatedBy: "/").last! + let clonedRepoName = try XCTUnwrap(sourcePath.components(separatedBy: "/").last) let repoPath = "\(clonePath)/\(clonedRepoName)" let repoGit = Git(path: repoPath) @@ -245,27 +245,7 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) } - func testConfigOperations() throws { - let path = self.currentPath() - - try self.clean(path: path) - let git = Git(path: path) - - try git.run(.raw("init")) - - try git.run(.raw("config user.name 'Test User'")) - try git.run(.raw("config user.email 'test@example.com'")) - - let userName = try git.run(.readConfig(name: "user.name")) - let userEmail = try git.run(.readConfig(name: "user.email")) - - XCTAssertEqual(userName, "Test User", "Should read the configured user name") - XCTAssertEqual(userEmail, "test@example.com", "Should read the configured user email") - - try self.clean(path: path) - } - - func testWriteConfigUserSettings() throws { + func testGitConfig() throws { let path = self.currentPath() try self.clean(path: path) @@ -273,9 +253,9 @@ final class GitKitTests: XCTestCase { let git = Git(path: path) try git.run(.raw("init")) - try git.run(.raw("config user.name 'Test User GitKit'")) - try git.run(.raw("config user.email 'test@gitkit.example.com'")) - try git.run(.raw("config core.editor 'vim'")) + try git.run(.writeConfig(name: "user.name", value: "\"Test User GitKit\"")) + try git.run(.writeConfig(name: "user.email", value: "test@gitkit.example.com")) + try git.run(.writeConfig(name: "core.editor", value: "vim")) let userName = try git.run(.readConfig(name: "user.name")) let userEmail = try git.run(.readConfig(name: "user.email")) @@ -290,7 +270,7 @@ final class GitKitTests: XCTestCase { let logOutput = try git.run(.raw("log --format='%an <%ae>' -1")) XCTAssertTrue(logOutput.contains("Test User GitKit "), "Commit should use the configured user information") - try git.run(.raw("config user.name 'Updated User'")) + try git.run(.writeConfig(name: "user.name", value: "\"Updated User\"")) let updatedUserName = try git.run(.readConfig(name: "user.name")) XCTAssertTrue(updatedUserName.contains("Updated User"), "Should be able to update existing config values") @@ -315,7 +295,7 @@ final class GitKitTests: XCTestCase { let git = Git(path: clonePath) try git.run(.clone(url: sourcePath)) - let clonedRepoName = sourcePath.components(separatedBy: "/").last! + let clonedRepoName = try XCTUnwrap(sourcePath.components(separatedBy: "/").last) let repoPath = "\(clonePath)/\(clonedRepoName)" let repoGit = Git(path: repoPath) @@ -574,7 +554,6 @@ final class GitKitTests: XCTestCase { XCTAssertTrue(onelineLog.contains("third commit"), "Oneline log should contain third commit") XCTAssertFalse(onelineLog.contains("Author:"), "Oneline log should NOT contain author info") XCTAssertFalse(onelineLog.contains("Date:"), "Oneline log should NOT contain date info") - // Oneline format should be much more compact XCTAssertTrue(onelineLog.count < fullLog.count / 2, "Oneline log should be much shorter than full log") XCTAssertEqual(prettyLog.trimmingCharacters(in: .whitespacesAndNewlines), "third commit", "Pretty log should contain only the commit message") @@ -589,6 +568,26 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) } + func testLogWithRevisions() throws { + let path = self.currentPath() + + try self.clean(path: path) + let git = Git(path: path) + + try git.run(.raw("init")) + try git.run(.commit(message: "first commit", allowEmpty: true)) + try git.run(.commit(message: "second commit", allowEmpty: true)) + try git.run(.commit(message: "third commit", allowEmpty: true)) + + let logWithRevisions = try git.run(.log(revisions: "@^^..@^")) + + XCTAssertTrue(logWithRevisions.contains("second commit"), "Log with @^^..@^ revision should contain second commit") + XCTAssertFalse(logWithRevisions.contains("first commit"), "Log with @^^..@^ revision should NOT contain first commit") + XCTAssertFalse(logWithRevisions.contains("third commit"), "Log with @^^..@^ revision should NOT contain third commit") + + try self.clean(path: path) + } + #if os(macOS) func testAsyncRun() throws { let path = self.currentPath() From a26d6f4db8812425db89fe2208d23c79c503ab8f Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Mon, 4 Aug 2025 17:53:00 -0800 Subject: [PATCH 5/5] merge fixes --- Sources/GitKit/Git.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/GitKit/Git.swift b/Sources/GitKit/Git.swift index 5cb8a40..e6c37f9 100644 --- a/Sources/GitKit/Git.swift +++ b/Sources/GitKit/Git.swift @@ -16,6 +16,7 @@ public final class Git: Shell { case cmd(Command, String? = nil) case addAll case status(short: Bool = false) + case clone(url: String , dirName: String? = nil) case commit(message: String, allowEmpty: Bool = false, gpgSigned: Bool = false) case writeConfig(name: String, value: String) case readConfig(name: String) @@ -167,6 +168,12 @@ public final class Git: Shell { if let revisions = revisions { params.append(revisions) } + case .revParse(let abbrevRef, let revision): + params = [Command.revParse.rawValue] + if abbrevRef { + params.append("--abbrev-ref") + } + params.append(revision) case .lsRemote(url: let url, limitToHeads: let limitToHeads): params = [Command.lsRemote.rawValue] if limitToHeads {