diff --git a/macgit/Services/AdvancedMaintenanceService.swift b/macgit/Services/AdvancedMaintenanceService.swift index ef6940e..3772cbc 100644 --- a/macgit/Services/AdvancedMaintenanceService.swift +++ b/macgit/Services/AdvancedMaintenanceService.swift @@ -42,12 +42,12 @@ actor AdvancedMaintenanceService { func orphanedUndoBackups(in repositories: [URL]) -> OrphanedUndoBackupReport { var directories: [URL] = [] var byteCount: Int64 = 0 + let snapshotStore = GitFileUndoSnapshotStore() for repositoryURL in Set(repositories.map(\.standardizedFileURL)) { - let root = repositoryURL.appendingPathComponent( - ".git/macgit/undo", - isDirectory: true - ) + guard let root = try? snapshotStore.undoRoot(in: repositoryURL) else { + continue + } guard let children = try? fileManager.contentsOfDirectory( at: root, includingPropertiesForKeys: [.isDirectoryKey], diff --git a/macgit/Services/GitFileUndoSnapshotStore.swift b/macgit/Services/GitFileUndoSnapshotStore.swift index b14d9c2..cb51df5 100644 --- a/macgit/Services/GitFileUndoSnapshotStore.swift +++ b/macgit/Services/GitFileUndoSnapshotStore.swift @@ -27,7 +27,7 @@ struct GitFileUndoSnapshotStore { func capture(paths: [String], in repositoryURL: URL) throws -> GitFileUndoSnapshot { let snapshotID = UUID() - let directory = snapshotDirectory(snapshotID, in: repositoryURL) + let directory = try snapshotDirectory(snapshotID, in: repositoryURL) try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) let items = try paths.map { path in @@ -56,11 +56,12 @@ struct GitFileUndoSnapshotStore { func restore(snapshotID: UUID, in repositoryURL: URL) throws { let data = try Data(contentsOf: manifestURL(snapshotID, in: repositoryURL)) let snapshot = try JSONDecoder().decode(GitFileUndoSnapshot.self, from: data) + let snapshotDirectory = try snapshotDirectory(snapshotID, in: repositoryURL) for item in snapshot.items { let destination = repositoryURL.appendingPathComponent(item.path) if item.existed, let backupRelativePath = item.backupRelativePath { - let backup = snapshotDirectory(snapshotID, in: repositoryURL).appendingPathComponent(backupRelativePath) + let backup = snapshotDirectory.appendingPathComponent(backupRelativePath) try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) if fileManager.fileExists(atPath: destination.path) { try fileManager.removeItem(at: destination) @@ -73,22 +74,58 @@ struct GitFileUndoSnapshotStore { } func delete(snapshotID: UUID, in repositoryURL: URL) throws { - let directory = snapshotDirectory(snapshotID, in: repositoryURL) + let directory = try snapshotDirectory(snapshotID, in: repositoryURL) if fileManager.fileExists(atPath: directory.path) { try fileManager.removeItem(at: directory) } GitUndoSnapshotRegistry.shared.unregister(snapshotID) } - private func undoRoot(in repositoryURL: URL) -> URL { - repositoryURL.appendingPathComponent(".git/macgit/undo", isDirectory: true) + func undoRoot(in repositoryURL: URL) throws -> URL { + try gitDirectory(in: repositoryURL) + .appendingPathComponent("macgit/undo", isDirectory: true) } - private func snapshotDirectory(_ id: UUID, in repositoryURL: URL) -> URL { - undoRoot(in: repositoryURL).appendingPathComponent(id.uuidString, isDirectory: true) + private func gitDirectory(in repositoryURL: URL) throws -> URL { + let dotGitURL = repositoryURL.appendingPathComponent(".git") + var isDirectory: ObjCBool = false + + if fileManager.fileExists(atPath: dotGitURL.path, isDirectory: &isDirectory), + isDirectory.boolValue { + return dotGitURL.standardizedFileURL + } + + let contents = try String(contentsOf: dotGitURL, encoding: .utf8) + let firstLine = contents.split(whereSeparator: \.isNewline).first.map(String.init) ?? "" + let prefix = "gitdir:" + guard firstLine.hasPrefix(prefix) else { + throw GitError.commandFailed("The repository's .git file does not contain a valid gitdir pointer.") + } + + let path = firstLine.dropFirst(prefix.count) + .trimmingCharacters(in: .whitespaces) + guard !path.isEmpty else { + throw GitError.commandFailed("The repository's .git file contains an empty gitdir pointer.") + } + + let gitDirectory = URL( + fileURLWithPath: path, + isDirectory: true, + relativeTo: dotGitURL.deletingLastPathComponent() + ).standardizedFileURL + var resolvedIsDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: gitDirectory.path, isDirectory: &resolvedIsDirectory), + resolvedIsDirectory.boolValue else { + throw GitError.commandFailed("The repository's git directory could not be found.") + } + return gitDirectory + } + + private func snapshotDirectory(_ id: UUID, in repositoryURL: URL) throws -> URL { + try undoRoot(in: repositoryURL).appendingPathComponent(id.uuidString, isDirectory: true) } - private func manifestURL(_ id: UUID, in repositoryURL: URL) -> URL { - snapshotDirectory(id, in: repositoryURL).appendingPathComponent("manifest.json") + private func manifestURL(_ id: UUID, in repositoryURL: URL) throws -> URL { + try snapshotDirectory(id, in: repositoryURL).appendingPathComponent("manifest.json") } } diff --git a/macgitTests/GitFileUndoSnapshotStoreTests.swift b/macgitTests/GitFileUndoSnapshotStoreTests.swift index 149efd4..ee59839 100644 --- a/macgitTests/GitFileUndoSnapshotStoreTests.swift +++ b/macgitTests/GitFileUndoSnapshotStoreTests.swift @@ -48,10 +48,76 @@ final class GitFileUndoSnapshotStoreTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: repoURL.appendingPathComponent("Missing.txt").path)) } + func testCaptureRestoreAndDeleteInLinkedWorktree() throws { + let repositoryURL = try makeGitRepository() + let worktreeURL = repositoryURL.deletingLastPathComponent() + .appendingPathComponent("linked-worktree-\(UUID().uuidString)", isDirectory: true) + try runGit(["worktree", "add", "-b", "linked-worktree", worktreeURL.path], in: repositoryURL) + addTeardownBlock { + try? self.runGit(["worktree", "remove", "--force", worktreeURL.path], in: repositoryURL) + try? FileManager.default.removeItem(at: repositoryURL) + } + + let gitMarkerURL = worktreeURL.appendingPathComponent(".git") + var isDirectory: ObjCBool = false + XCTAssertTrue(FileManager.default.fileExists(atPath: gitMarkerURL.path, isDirectory: &isDirectory)) + XCTAssertFalse(isDirectory.boolValue) + + let fileURL = worktreeURL.appendingPathComponent("Notes.md") + try "before\n".write(to: fileURL, atomically: true, encoding: .utf8) + let store = GitFileUndoSnapshotStore() + let snapshot = try store.capture(paths: ["Notes.md"], in: worktreeURL) + + try "after\n".write(to: fileURL, atomically: true, encoding: .utf8) + try store.restore(snapshotID: snapshot.id, in: worktreeURL) + XCTAssertEqual(try String(contentsOf: fileURL, encoding: .utf8), "before\n") + + let snapshotDirectory = try store.undoRoot(in: worktreeURL) + .appendingPathComponent(snapshot.id.uuidString, isDirectory: true) + XCTAssertTrue(FileManager.default.fileExists(atPath: snapshotDirectory.path)) + try store.delete(snapshotID: snapshot.id, in: worktreeURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: snapshotDirectory.path)) + } + private func makeRepoDirectory() throws -> URL { let repoURL = FileManager.default.temporaryDirectory .appendingPathComponent("macgit-file-snapshot-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: repoURL.appendingPathComponent(".git"), withIntermediateDirectories: true) return repoURL } + + private func makeGitRepository() throws -> URL { + let repositoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("macgit-file-snapshot-repository-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: repositoryURL, withIntermediateDirectories: true) + try runGit(["init", "-b", "main"], in: repositoryURL) + try runGit(["config", "user.name", "Mac Git Tests"], in: repositoryURL) + try runGit(["config", "user.email", "tests@example.com"], in: repositoryURL) + try "base\n".write( + to: repositoryURL.appendingPathComponent("README.md"), + atomically: true, + encoding: .utf8 + ) + try runGit(["add", "README.md"], in: repositoryURL) + try runGit(["commit", "-m", "initial"], in: repositoryURL) + return repositoryURL + } + + private func runGit(_ arguments: [String], in repositoryURL: URL) throws { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/git") + task.arguments = arguments + task.currentDirectoryURL = repositoryURL + let stderr = Pipe() + task.standardError = stderr + try task.run() + task.waitUntilExit() + if task.terminationStatus != 0 { + let output = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "git failed" + throw GitError.commandFailed(output) + } + } }