Skip to content

Commit 228f1b0

Browse files
ViktorSimkoaciidgh
authored andcommitted
Add isGitIgnored method for checking if a file is ignored by git (#1613)
* Ignore files from .gitignore Add getIgnoredFiles to GitRepository * Use git check-ignore for filtering files This makes an improvement performance-wise, because it can be used to check only the files we are concerned about * Fix checkoutExists and add test for it * Fix isIgnored and add test for it * Modify `isGitIgnored` to be able to process multiple paths in one invocation This way there is no need to create the shell process for every path separately which can improve the performance * Use file input for check-ignore This ensures that we won't surpass ARG_MAX with the arguments to check-ignore, and also that paths now can include spaces * Rename isGitIgnored to areIgnored * Use localFileSystem to write the temporary file with the paths * Add quotes around the path arguments for git in areIgnored * Append paths directly to the output stream * Use shellEscaped on path arguments in areIgnored
1 parent c85fc1b commit 228f1b0

File tree

7 files changed

+102
-0
lines changed

7 files changed

+102
-0
lines changed

Sources/SourceControl/GitRepository.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ public class GitRepositoryProvider: RepositoryProvider {
9292
}
9393
}
9494

95+
public func checkoutExists(at path: AbsolutePath) throws -> Bool {
96+
precondition(exists(path))
97+
98+
let result = try Process.popen(args: Git.tool, "-C", path.asString, "rev-parse", "--is-bare-repository")
99+
return try result.exitStatus == .terminated(code: 0) && result.utf8Output().chomp() == "false"
100+
}
101+
95102
public func openCheckout(at path: AbsolutePath) throws -> WorkingCheckout {
96103
return GitRepository(path: path)
97104
}
@@ -100,6 +107,9 @@ public class GitRepositoryProvider: RepositoryProvider {
100107
enum GitInterfaceError: Swift.Error {
101108
/// This indicates a problem communicating with the `git` tool.
102109
case malformedResponse(String)
110+
111+
/// This indicates that a fatal error was encountered
112+
case fatalError
103113
}
104114

105115
/// A basic `git` repository. This class is thread safe.
@@ -387,6 +397,35 @@ public class GitRepository: Repository, WorkingCheckout {
387397
return localFileSystem.isDirectory(AbsolutePath(firstLine))
388398
}
389399

400+
/// Returns true if the file at `path` is ignored by `git`
401+
public func areIgnored(_ paths: [AbsolutePath]) throws -> [Bool] {
402+
return try queue.sync {
403+
let stringPaths = paths.map({ $0.asString })
404+
405+
let pathsFile = try TemporaryFile()
406+
try localFileSystem.writeFileContents(pathsFile.path) {
407+
for path in paths {
408+
$0 <<< path.asString <<< "\0"
409+
}
410+
}
411+
412+
let args = [Git.tool, "-C", self.path.asString.shellEscaped(), "check-ignore", "-z", "--stdin", "<", pathsFile.path.asString.shellEscaped()]
413+
let argsWithSh = ["sh", "-c", args.joined(separator: " ")]
414+
let result = try Process.popen(arguments: argsWithSh)
415+
let output = try result.output.dematerialize()
416+
417+
let outputs: [String] = output.split(separator: 0).map(Array.init).map({ (bytes: [Int8]) -> String in
418+
return String(cString: bytes + [0])
419+
})
420+
421+
guard result.exitStatus == .terminated(code: 0) || result.exitStatus == .terminated(code: 1) else {
422+
throw GitInterfaceError.fatalError
423+
}
424+
425+
return stringPaths.map(outputs.contains)
426+
}
427+
}
428+
390429
// MARK: Git Operations
391430

392431
/// Resolve a "treeish" to a concrete hash.

Sources/SourceControl/InMemoryGitRepository.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@ extension InMemoryGitRepository: WorkingCheckout {
272272
public func isAlternateObjectStoreValid() -> Bool {
273273
return true
274274
}
275+
276+
public func areIgnored(_ paths: [AbsolutePath]) throws -> [Bool] {
277+
return [false]
278+
}
275279
}
276280

277281
/// This class implement provider for in memeory git repository.
@@ -323,6 +327,10 @@ public final class InMemoryGitRepositoryProvider: RepositoryProvider {
323327
try checkout.installHead()
324328
}
325329

330+
public func checkoutExists(at path: AbsolutePath) throws -> Bool {
331+
return checkoutsMap.keys.contains(path)
332+
}
333+
326334
public func openCheckout(at path: AbsolutePath) throws -> WorkingCheckout {
327335
return checkoutsMap[path]!
328336
}

Sources/SourceControl/Repository.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ public protocol RepositoryProvider {
8888
to destinationPath: AbsolutePath,
8989
editable: Bool) throws
9090

91+
/// Returns true if a working repository exists at `path`
92+
func checkoutExists(at path: AbsolutePath) throws -> Bool
93+
9194
/// Open a working repository copy.
9295
///
9396
/// - Parameters:
@@ -193,6 +196,9 @@ public protocol WorkingCheckout {
193196

194197
/// Returns true if there is an alternative store in the checkout and it is valid.
195198
func isAlternateObjectStoreValid() -> Bool
199+
200+
/// Returns true if the file at `path` is ignored by `git`
201+
func areIgnored(_ paths: [AbsolutePath]) throws -> [Bool]
196202
}
197203

198204
/// A single repository revision.

Tests/PackageGraphTests/RepositoryPackageContainerProviderTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ private class MockRepositories: RepositoryProvider {
9797
// No-op.
9898
assert(repositories.index(forKey: repository.url) != nil)
9999
}
100+
101+
func checkoutExists(at path: AbsolutePath) throws -> Bool {
102+
return false
103+
}
100104

101105
func open(repository: RepositorySpecifier, at path: AbsolutePath) throws -> Repository {
102106
return repositories[repository.url]!

Tests/SourceControlTests/GitRepositoryTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class GitRepositoryTests: XCTestCase {
4040
// Test the provider.
4141
let testCheckoutPath = path.appending(component: "checkout")
4242
let provider = GitRepositoryProvider()
43+
XCTAssertTrue(try provider.checkoutExists(at: testRepoPath))
4344
let repoSpec = RepositorySpecifier(url: testRepoPath.asString)
4445
try! provider.fetch(repository: repoSpec, to: testCheckoutPath)
4546

@@ -604,6 +605,43 @@ class GitRepositoryTests: XCTestCase {
604605
}
605606
}
606607

608+
func testAreIgnored() throws {
609+
mktmpdir { path in
610+
// Create a repo.
611+
let testRepoPath = path.appending(component: "test_repo")
612+
try makeDirectories(testRepoPath)
613+
initGitRepo(testRepoPath)
614+
let repo = GitRepository(path: testRepoPath)
615+
616+
// Add a .gitignore
617+
try localFileSystem.writeFileContents(testRepoPath.appending(component: ".gitignore"), bytes: "ignored_file1\nignored file2")
618+
619+
let ignored = try repo.areIgnored([testRepoPath.appending(component: "ignored_file1"), testRepoPath.appending(component: "ignored file2"), testRepoPath.appending(component: "not ignored")])
620+
XCTAssertTrue(ignored[0])
621+
XCTAssertTrue(ignored[1])
622+
XCTAssertFalse(ignored[2])
623+
624+
let notIgnored = try repo.areIgnored([testRepoPath.appending(component: "not_ignored")])
625+
XCTAssertFalse(notIgnored[0])
626+
}
627+
}
628+
629+
func testAreIgnoredWithSpaceInRepoPath() throws {
630+
mktmpdir { path in
631+
// Create a repo.
632+
let testRepoPath = path.appending(component: "test repo")
633+
try makeDirectories(testRepoPath)
634+
initGitRepo(testRepoPath)
635+
let repo = GitRepository(path: testRepoPath)
636+
637+
// Add a .gitignore
638+
try localFileSystem.writeFileContents(testRepoPath.appending(component: ".gitignore"), bytes: "ignored_file1")
639+
640+
let ignored = try repo.areIgnored([testRepoPath.appending(component: "ignored_file1")])
641+
XCTAssertTrue(ignored[0])
642+
}
643+
}
644+
607645
static var allTests = [
608646
("testBranchOperations", testBranchOperations),
609647
("testCheckoutRevision", testCheckoutRevision),
@@ -620,5 +658,6 @@ class GitRepositoryTests: XCTestCase {
620658
("testSubmodules", testSubmodules),
621659
("testUncommitedChanges", testUncommitedChanges),
622660
("testAlternativeObjectStoreValidation", testAlternativeObjectStoreValidation),
661+
("testGitIgnored", testAreIgnored),
623662
]
624663
}

Tests/SourceControlTests/InMemoryGitRepositoryTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ class InMemoryGitRepositoryTests: XCTestCase {
100100
XCTAssert(fooRepo.exists(revision: try fooRepo.resolveRevision(tag: v1)))
101101

102102
let fooCheckoutPath = AbsolutePath("/fooCheckout")
103+
XCTAssertFalse(try provider.checkoutExists(at: fooCheckoutPath))
103104
try provider.cloneCheckout(repository: specifier, at: fooRepoPath, to: fooCheckoutPath, editable: false)
105+
XCTAssertTrue(try provider.checkoutExists(at: fooCheckoutPath))
104106
let fooCheckout = try provider.openCheckout(at: fooCheckoutPath)
105107

106108
XCTAssertEqual(fooCheckout.tags.sorted(), [v1, v2])

Tests/SourceControlTests/RepositoryManagerTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ private class DummyRepositoryProvider: RepositoryProvider {
9595
try localFileSystem.writeFileContents(destinationPath.appending(component: "README.txt"), bytes: "Hi")
9696
}
9797

98+
func checkoutExists(at path: AbsolutePath) throws -> Bool {
99+
return false
100+
}
101+
98102
func openCheckout(at path: AbsolutePath) throws -> WorkingCheckout {
99103
fatalError("unsupported")
100104
}

0 commit comments

Comments
 (0)