Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions macgit/Services/AdvancedMaintenanceService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
55 changes: 46 additions & 9 deletions macgit/Services/GitFileUndoSnapshotStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
}
66 changes: 66 additions & 0 deletions macgitTests/GitFileUndoSnapshotStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading