Skip to content

Commit 864ed0f

Browse files
committed
[SR-12581] System-wide cache of SwiftPM dependencies
1 parent c6ec2b4 commit 864ed0f

File tree

2 files changed

+171
-5
lines changed

2 files changed

+171
-5
lines changed

Sources/SourceControl/GitRepository.swift

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,59 @@ public class GitRepositoryProvider: RepositoryProvider {
3333

3434
/// Reference to process set, if installed.
3535
private let processSet: ProcessSet?
36-
37-
public init(processSet: ProcessSet? = nil) {
36+
/// The path to the directory where all cached git repositories are stored.
37+
private let cachePath: AbsolutePath
38+
/// The maximum size of the cache in bytes.
39+
private let maxCacheSize: UInt64
40+
41+
public init(processSet: ProcessSet? = nil,
42+
cachePath: AbsolutePath = AbsolutePath(localFileSystem.homeDirectory, ".swiftpm/cache/repositories"),
43+
maxCacheSize: UInt64 = 21_474_836_480) {
3844
self.processSet = processSet
45+
self.cachePath = cachePath
46+
self.maxCacheSize = maxCacheSize
47+
}
48+
49+
/// Clones the git repository we want to cache into the cache directory if it does not already exist and returns it.
50+
private func setupCacheIfNeeded(for repository: RepositorySpecifier) throws -> GitRepository {
51+
let repositoryPath = cachePath.appending(component: repository.fileSystemIdentifier)
52+
53+
guard !localFileSystem.exists(repositoryPath) else { return GitRepository(path: repositoryPath, isWorkingRepo: false) }
54+
55+
try localFileSystem.createDirectory(repositoryPath, recursive: true)
56+
57+
// We are cloning each repository into its own directory instead of using one large bare repository and adding a remote for each repository.
58+
// This avoids the large overhead that occurs when git tries to determine if it has any revision in common with the remote repository,
59+
// which involves sending a list of all local commits to the server (a potentially huge list depending on cache size
60+
// with most commits unrelated to the repository we actually want to fetch).
61+
try Process.checkNonZeroExit(
62+
args: Git.tool, "clone", "--mirror", repository.url, repositoryPath.pathString)
63+
64+
return GitRepository(path: repositoryPath, isWorkingRepo: false)
65+
}
66+
67+
/// Purges git repositories from the cache directory in order to free some space.
68+
private func purgeCacheIfNeeded() {
69+
do {
70+
let cacheSize = try localFileSystem.getDirectorySize(cachePath)
71+
let desiredCacheSize = maxCacheSize - (maxCacheSize / 8)
72+
73+
guard cacheSize > maxCacheSize else { return }
74+
75+
let repositories = try localFileSystem.getDirectoryContents(cachePath)
76+
.map { GitRepository(path: cachePath.appending(component: $0), isWorkingRepo: false) }
77+
.sorted { (try localFileSystem.getFileInfo($0.path).modTime) < (try localFileSystem.getFileInfo($1.path).modTime) }
78+
79+
// Purges repositories until the desired cache size is reached.
80+
for repository in repositories {
81+
let cacheSize = try localFileSystem.getDirectorySize(cachePath)
82+
guard cacheSize > desiredCacheSize else { break }
83+
try localFileSystem.removeFileTree(repository.path)
84+
}
85+
} catch {
86+
// The cache seems to be broken. Lets remove everything.
87+
try? localFileSystem.removeFileTree(cachePath)
88+
}
3989
}
4090

4191
public func fetch(repository: RepositorySpecifier, to path: AbsolutePath) throws {
@@ -50,9 +100,26 @@ public class GitRepositoryProvider: RepositoryProvider {
50100
// FIXME: We need infrastructure in this subsystem for reporting
51101
// status information.
52102

53-
let process = Process(
54-
args: Git.tool, "clone", "--mirror", repository.url, path.pathString, environment: Git.environment)
55-
// Add to process set.
103+
let process: Process
104+
105+
do {
106+
let cache = try setupCacheIfNeeded(for: repository)
107+
// Fetch repository in cache.
108+
try Process.checkNonZeroExit(
109+
args: Git.tool, "-C", cache.path.pathString, "fetch")
110+
// Clone the repository using the cache as a reference if possible.
111+
// Git objects are not shared (--dissociate) to avoid problems that might occur when the cache is
112+
// deleted or the package is copied somewhere it cannot reach the cache directory.
113+
process = Process(
114+
args: Git.tool, "clone", "--reference-if-able", cache.path.pathString, "--dissociate", "--mirror", repository.url, path.pathString, environment: Git.environment)
115+
116+
purgeCacheIfNeeded()
117+
} catch {
118+
// Fallback to cloning without using the cache.
119+
process = Process(
120+
args: Git.tool, "clone", "--mirror", repository.url, path.pathString, environment: Git.environment)
121+
}
122+
56123
try processSet?.add(process)
57124

58125
try process.launch()
@@ -277,6 +344,29 @@ public class GitRepository: Repository, WorkingCheckout {
277344
}
278345
}
279346

347+
/// Adds a remote to the repository
348+
/// - Parameters:
349+
/// - remote: The name of the remote to add.
350+
/// - url: The url of the remote to add.
351+
public func add(remote: String, url: String) throws {
352+
try queue.sync {
353+
try Process.checkNonZeroExit(
354+
args: Git.tool, "-C", path.pathString, "remote", "add", remote, url)
355+
return
356+
}
357+
}
358+
359+
360+
/// Removes a remote from the repository
361+
/// - Parameter remote: The name of the remote to remove.
362+
public func remove(remote: String) throws {
363+
try queue.sync {
364+
try Process.checkNonZeroExit(
365+
args: Git.tool, "-C", path.pathString, "remote", "remove", remote)
366+
return
367+
}
368+
}
369+
280370
// MARK: Repository Interface
281371

282372
/// Returns the tags present in repository.
@@ -754,3 +844,16 @@ private class GitFileSystemView: FileSystem {
754844
fatalError("will never be supported")
755845
}
756846
}
847+
848+
extension FileSystem {
849+
/// Recursively sums up the file size in bytes of all files inside a directory.
850+
func getDirectorySize(_ path: AbsolutePath) throws -> UInt64 {
851+
if isFile(path) {
852+
return try getFileInfo(path).size
853+
} else if isDirectory(path) {
854+
return try getDirectoryContents(path).reduce(0) { $0 + (try getDirectorySize(path.appending(component: $1))) }
855+
} else {
856+
return 0
857+
}
858+
}
859+
}

Tests/SourceControlTests/GitRepositoryTests.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,69 @@ class GitRepositoryTests: XCTestCase {
328328
}
329329
}
330330

331+
func testCacheFetch() throws {
332+
mktmpdir { path in
333+
// Create a repo.
334+
let testRepoPath = path.appending(component: "test-repo")
335+
try makeDirectories(testRepoPath)
336+
initGitRepo(testRepoPath, tag: "1.2.3")
337+
let repo = GitRepository(path: testRepoPath)
338+
XCTAssertEqual(repo.tags, ["1.2.3"])
339+
340+
// Clone it somewhere.
341+
let testClonePath = path.appending(component: "clone")
342+
let testCachePath = AbsolutePath(path, ".cache/swiftpm/repositories")
343+
let provider = GitRepositoryProvider(cachePath: testCachePath)
344+
let repoSpec = RepositorySpecifier(url: testRepoPath.pathString)
345+
try provider.fetch(repository: repoSpec, to: testClonePath)
346+
347+
XCTAssertDirectoryExists(testCachePath.appending(component: repoSpec.fileSystemIdentifier))
348+
}
349+
}
350+
351+
func testCachePurge() throws {
352+
mktmpdir { path in
353+
// Create a repo.
354+
let testRepoPath = path.appending(component: "test-repo")
355+
try makeDirectories(testRepoPath)
356+
initGitRepo(testRepoPath, tag: "1.2.3")
357+
let repo = GitRepository(path: testRepoPath)
358+
XCTAssertEqual(repo.tags, ["1.2.3"])
359+
360+
// Clone it somewhere.
361+
let testClonePath = path.appending(component: "clone")
362+
let testCachePath = AbsolutePath(path, ".cache/swiftpm/repositories")
363+
let provider = GitRepositoryProvider(cachePath: testCachePath, maxCacheSize: 0)
364+
let repoSpec = RepositorySpecifier(url: testRepoPath.pathString)
365+
try provider.fetch(repository: repoSpec, to: testClonePath)
366+
367+
XCTAssertFalse(localFileSystem.isDirectory(testCachePath.appending(component: repoSpec.fileSystemIdentifier)))
368+
}
369+
}
370+
371+
func testCacheFallback() throws {
372+
mktmpdir { path in
373+
// Create a repo.
374+
let testRepoPath = path.appending(component: "test-repo")
375+
try makeDirectories(testRepoPath)
376+
initGitRepo(testRepoPath, tag: "1.2.3")
377+
let repo = GitRepository(path: testRepoPath)
378+
XCTAssertEqual(repo.tags, ["1.2.3"])
379+
380+
// Clone it somewhere.
381+
let testClonePath = path.appending(component: "clone")
382+
let testCachePath = AbsolutePath(path, ".cache/swiftpm/repositories")
383+
// Make directroy non-writeable to force falling back to a normal clone without using the cache
384+
try localFileSystem.createDirectory(testCachePath, recursive: true)
385+
try localFileSystem.chmod(.userUnWritable, path: testCachePath)
386+
let provider = GitRepositoryProvider(cachePath: testCachePath)
387+
let repoSpec = RepositorySpecifier(url: testRepoPath.pathString)
388+
try provider.fetch(repository: repoSpec, to: testClonePath)
389+
print(testClonePath)
390+
XCTAssertDirectoryExists(testClonePath)
391+
}
392+
}
393+
331394
func testHasUnpushedCommits() throws {
332395
mktmpdir { path in
333396
// Create a repo.

0 commit comments

Comments
 (0)