Simple App to help @jaspermayone make it through COMP1050 with a professor who won't use version control.

v2.0.0 - Refactor to use git merge workflow

- Replace manual file comparison with git-based workflow
- Extract zip to temporary branch and initiate merge
- User can now use git add -p for selective hunk staging
- Simplify UI to show merge instructions
- Include hidden files (important for Eclipse projects)
- Skip .git, .DS_Store, and __MACOSX directories
- Update app version to 2.0.0

+241 -379
+1
.gitignore
··· 1 + build/
+2 -2
ZipMerge.xcodeproj/project.pbxproj
··· 290 290 "$(inherited)", 291 291 "@executable_path/../Frameworks", 292 292 ); 293 - MARKETING_VERSION = 1.0; 293 + MARKETING_VERSION = 2.0; 294 294 PRODUCT_BUNDLE_IDENTIFIER = com.singlefeather.ZipMerge; 295 295 PRODUCT_NAME = "$(TARGET_NAME)"; 296 296 SWIFT_EMIT_LOC_STRINGS = YES; ··· 319 319 "$(inherited)", 320 320 "@executable_path/../Frameworks", 321 321 ); 322 - MARKETING_VERSION = 1.0; 322 + MARKETING_VERSION = 2.0; 323 323 PRODUCT_BUNDLE_IDENTIFIER = com.singlefeather.ZipMerge; 324 324 PRODUCT_NAME = "$(TARGET_NAME)"; 325 325 SWIFT_EMIT_LOC_STRINGS = YES;
+116 -221
ZipMerge/ContentView.swift
··· 13 13 struct ContentView: View { 14 14 @State private var yourDirectory: URL? 15 15 @State private var zipFile: URL? 16 - @State private var comparison: ComparisonResult? 17 - @State private var selectedFile: ComparedFile? 16 + @State private var mergeResult: FileComparer.GitMergeResult? 18 17 @State private var isProcessing = false 19 18 @State private var errorMessage: String? 20 - @State private var showingSuccess = false 21 - @State private var tempDirectory: URL? 22 - @State private var showingGitCommit = false 23 - @State private var commitMessage = "" 19 + @State private var showingInstructions = false 24 20 25 21 var body: some View { 26 - HSplitView { 27 - // Left panel - file list 28 - VStack(spacing: 0) { 29 - setupArea 30 - 31 - if let comparison = comparison { 32 - fileListView(comparison) 33 - } else { 34 - emptyStateView 35 - } 36 - } 37 - .frame(minWidth: 300, idealWidth: 350) 38 - 39 - // Right panel - diff view 40 - if let file = selectedFile, file.changeType == .modified || file.changeType == .added || file.changeType == .deleted { 41 - DiffView(file: file) 22 + VStack(spacing: 20) { 23 + setupArea 24 + 25 + if let merge = mergeResult { 26 + mergeInstructionsView(merge) 42 27 } else { 43 - VStack { 44 - Image(systemName: "doc.text.magnifyingglass") 45 - .font(.system(size: 48)) 46 - .foregroundColor(.secondary) 47 - Text("Select a file to view changes") 48 - .foregroundColor(.secondary) 49 - } 50 - .frame(maxWidth: .infinity, maxHeight: .infinity) 28 + emptyStateView 51 29 } 52 30 } 31 + .padding() 32 + .frame(minWidth: 600, minHeight: 400) 53 33 .alert("Error", isPresented: .init( 54 34 get: { errorMessage != nil }, 55 35 set: { if !$0 { errorMessage = nil } } ··· 58 38 } message: { 59 39 Text(errorMessage ?? "") 60 40 } 61 - .alert("Success", isPresented: $showingSuccess) { 62 - Button("OK") { 63 - if isGitRepository() { 64 - showingSuccess = false 65 - showingGitCommit = true 66 - } else { 67 - cleanupAfterMerge() 68 - } 69 - } 70 - } message: { 71 - Text("Changes applied successfully!") 72 - } 73 - .alert("Create Git Commit", isPresented: $showingGitCommit) { 74 - TextField("Commit message", text: $commitMessage) 75 - Button("Commit") { 76 - createGitCommit() 77 - cleanupAfterMerge() 78 - } 79 - Button("Skip") { 80 - cleanupAfterMerge() 81 - } 82 - } message: { 83 - Text("Would you like to create a git commit for these changes?") 84 - } 85 41 } 86 42 87 43 private var setupArea: some View { ··· 91 47 Image(systemName: "folder.fill") 92 48 .foregroundColor(.blue) 93 49 VStack(alignment: .leading) { 94 - Text("Your Project") 50 + Text("Your Git Project") 95 51 .font(.headline) 96 52 Text(yourDirectory?.lastPathComponent ?? "Not selected") 97 53 .font(.caption) ··· 105 61 .padding(10) 106 62 .background(Color(NSColor.controlBackgroundColor)) 107 63 .cornerRadius(8) 108 - 64 + 65 + if !isGitRepository() && yourDirectory != nil { 66 + HStack { 67 + Image(systemName: "exclamationmark.triangle.fill") 68 + .foregroundColor(.orange) 69 + Text("Selected directory is not a git repository") 70 + .font(.caption) 71 + .foregroundColor(.secondary) 72 + } 73 + } 74 + 109 75 // Zip drop zone 110 76 ZipDropZone(zipFile: $zipFile) { 111 77 processZip() 112 78 } 113 - 79 + 114 80 if isProcessing { 115 81 ProgressView("Processing...") 116 82 } 117 - 118 - if let comparison = comparison, comparison.changedFiles.count > 0 { 119 - HStack { 120 - Text("\(comparison.pendingCount) pending") 121 - .foregroundColor(.orange) 122 - Spacer() 123 - Button("Apply Changes") { 124 - applyChanges() 125 - } 126 - .disabled(comparison.pendingCount > 0) 127 - .buttonStyle(.borderedProminent) 128 - } 129 - } 130 83 } 131 - .padding() 132 84 } 133 85 134 86 private var emptyStateView: some View { ··· 137 89 Image(systemName: "arrow.down.doc.fill") 138 90 .font(.system(size: 48)) 139 91 .foregroundColor(.secondary) 140 - Text("Drop a zip file to compare") 92 + Text("Drop a zip file to merge") 141 93 .font(.title3) 142 94 .foregroundColor(.secondary) 143 - Text("First, choose your project folder above") 95 + Text("First, choose your git project folder above") 144 96 .font(.caption) 145 97 .foregroundColor(.secondary) 146 98 Spacer() 147 99 } 148 100 .frame(maxWidth: .infinity) 149 101 } 150 - 151 - private func fileListView(_ comparison: ComparisonResult) -> some View { 152 - VStack(alignment: .leading, spacing: 0) { 153 - Text("Changed Files") 154 - .font(.headline) 155 - .padding(.horizontal) 156 - .padding(.vertical, 8) 157 - 158 - Divider() 159 - 160 - if comparison.changedFiles.isEmpty { 161 - VStack { 162 - Spacer() 163 - Image(systemName: "checkmark.circle.fill") 164 - .font(.system(size: 48)) 165 - .foregroundColor(.green) 166 - Text("No changes detected") 102 + 103 + private func mergeInstructionsView(_ merge: FileComparer.GitMergeResult) -> some View { 104 + VStack(alignment: .leading, spacing: 20) { 105 + HStack { 106 + Image(systemName: "checkmark.circle.fill") 107 + .foregroundColor(.green) 108 + .font(.title) 109 + VStack(alignment: .leading) { 110 + Text("Merge Initiated!") 111 + .font(.title2) 112 + .bold() 113 + Text("Branch: \(merge.branchName)") 114 + .font(.caption) 115 + .foregroundColor(.secondary) 116 + } 117 + Spacer() 118 + } 119 + 120 + if merge.hasConflicts { 121 + HStack { 122 + Image(systemName: "exclamationmark.triangle.fill") 123 + .foregroundColor(.orange) 124 + Text("Merge has conflicts - resolve them in your terminal") 167 125 .foregroundColor(.secondary) 168 - Spacer() 169 126 } 170 - .frame(maxWidth: .infinity) 171 - } else { 172 - List(comparison.changedFiles, selection: $selectedFile) { file in 173 - FileRowView(file: binding(for: file)) 174 - .tag(file) 127 + } 128 + 129 + VStack(alignment: .leading, spacing: 12) { 130 + Text("Next Steps:") 131 + .font(.headline) 132 + 133 + Text("Open your terminal in the project directory and use git to selectively merge changes:") 134 + .foregroundColor(.secondary) 135 + 136 + codeBlock("cd \(yourDirectory?.path ?? "")") 137 + 138 + Text("1. Review staged changes:") 139 + .font(.subheadline) 140 + codeBlock("git status") 141 + 142 + Text("2. Selectively stage hunks (optional):") 143 + .font(.subheadline) 144 + codeBlock("git add -p") 145 + 146 + Text("3. Commit the merge:") 147 + .font(.subheadline) 148 + codeBlock("git commit") 149 + 150 + Text("4. After committing, come back here and click 'Cleanup' to remove the temporary branch and zip file.") 151 + .font(.caption) 152 + .foregroundColor(.secondary) 153 + .padding(.top, 8) 154 + } 155 + .padding() 156 + .background(Color(NSColor.controlBackgroundColor)) 157 + .cornerRadius(8) 158 + 159 + HStack { 160 + Spacer() 161 + Button("Cleanup") { 162 + cleanupAfterMerge() 175 163 } 176 - .listStyle(.inset) 164 + .buttonStyle(.borderedProminent) 177 165 } 166 + 167 + Spacer() 178 168 } 179 169 } 180 - 181 - private func binding(for file: ComparedFile) -> Binding<ComparedFile> { 182 - Binding( 183 - get: { 184 - comparison?.files.first { $0.id == file.id } ?? file 185 - }, 186 - set: { newValue in 187 - if let index = comparison?.files.firstIndex(where: { $0.id == file.id }) { 188 - comparison?.files[index] = newValue 189 - } 190 - } 191 - ) 170 + 171 + private func codeBlock(_ text: String) -> some View { 172 + Text(text) 173 + .font(.system(.body, design: .monospaced)) 174 + .padding(8) 175 + .frame(maxWidth: .infinity, alignment: .leading) 176 + .background(Color(NSColor.textBackgroundColor)) 177 + .cornerRadius(4) 178 + .textSelection(.enabled) 192 179 } 193 180 194 181 private func chooseYourDirectory() { ··· 209 196 return 210 197 } 211 198 199 + guard isGitRepository() else { 200 + errorMessage = "Selected directory is not a git repository. ZipMerge requires git to work." 201 + return 202 + } 203 + 212 204 isProcessing = true 213 205 214 206 DispatchQueue.global(qos: .userInitiated).async { 215 207 do { 216 - // Create temp directory for extraction 217 - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) 218 - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 219 - 220 - // Extract zip 221 - try FileComparer.extractZip(at: zip, to: tempDir) 222 - 223 - // Find the actual root (in case zip has a wrapper folder) 224 - let theirRoot = FileComparer.findRootDirectory(in: tempDir) 225 - 226 - // Compare 227 - let result = try FileComparer.compare(yourDirectory: yours, theirDirectory: theirRoot) 208 + let result = try FileComparer.importZipToGitBranch(zipURL: zip, projectDirectory: yours) 228 209 229 210 DispatchQueue.main.async { 230 - self.tempDirectory = tempDir 231 - self.comparison = result 211 + self.mergeResult = result 232 212 self.isProcessing = false 233 213 } 234 214 } catch { ··· 240 220 } 241 221 } 242 222 243 - private func applyChanges() { 244 - guard let comparison = comparison else { return } 223 + private func cleanupAfterMerge() { 224 + guard let merge = mergeResult, let directory = yourDirectory else { return } 245 225 246 226 do { 247 - try FileComparer.applyChanges(comparison) 248 - showingSuccess = true 249 - } catch { 250 - errorMessage = error.localizedDescription 251 - } 252 - } 227 + // Delete the temporary git branch 228 + try FileComparer.cleanupGitMerge(branchName: merge.branchName, projectDirectory: directory) 253 229 254 - private func cleanupAfterMerge() { 255 - // Clean up temp directory 256 - if let tempDir = tempDirectory { 257 - try? FileManager.default.removeItem(at: tempDir) 258 - tempDirectory = nil 259 - } 230 + // Delete the zip file 231 + if let zip = zipFile { 232 + try? FileManager.default.removeItem(at: zip) 233 + } 260 234 261 - // Delete the zip file 262 - if let zip = zipFile { 263 - try? FileManager.default.removeItem(at: zip) 235 + // Reset state 236 + mergeResult = nil 237 + zipFile = nil 238 + } catch { 239 + errorMessage = "Failed to cleanup: \(error.localizedDescription)" 264 240 } 265 - 266 - // Reset state 267 - comparison = nil 268 - zipFile = nil 269 - commitMessage = "" 270 241 } 271 242 272 243 private func isGitRepository() -> Bool { ··· 275 246 let gitDir = directory.appendingPathComponent(".git") 276 247 var isDirectory: ObjCBool = false 277 248 return FileManager.default.fileExists(atPath: gitDir.path, isDirectory: &isDirectory) && isDirectory.boolValue 278 - } 279 - 280 - private func createGitCommit() { 281 - guard let directory = yourDirectory else { return } 282 - 283 - let process = Process() 284 - process.executableURL = URL(fileURLWithPath: "/usr/bin/git") 285 - process.currentDirectoryURL = directory 286 - process.arguments = ["commit", "-am", commitMessage.isEmpty ? "[ZipMerge Auto Merge]" : commitMessage] 287 - 288 - do { 289 - try process.run() 290 - process.waitUntilExit() 291 - } catch { 292 - errorMessage = "Failed to create git commit: \(error.localizedDescription)" 293 - } 294 - } 295 - } 296 - 297 - struct FileRowView: View { 298 - @Binding var file: ComparedFile 299 - 300 - var body: some View { 301 - HStack { 302 - Image(systemName: file.icon) 303 - .foregroundColor(colorForType(file.changeType)) 304 - 305 - VStack(alignment: .leading) { 306 - Text(file.fileName) 307 - .lineLimit(1) 308 - Text(file.relativePath) 309 - .font(.caption) 310 - .foregroundColor(.secondary) 311 - .lineLimit(1) 312 - } 313 - 314 - Spacer() 315 - 316 - if file.changeType != .unchanged { 317 - decisionButtons 318 - } 319 - } 320 - .padding(.vertical, 4) 321 - } 322 - 323 - private var decisionButtons: some View { 324 - HStack(spacing: 4) { 325 - Button { 326 - file.decision = .keepMine 327 - } label: { 328 - Image(systemName: "person.fill") 329 - .foregroundColor(file.decision == .keepMine ? .white : .blue) 330 - } 331 - .buttonStyle(.bordered) 332 - .tint(file.decision == .keepMine ? .blue : nil) 333 - .help("Keep your version") 334 - 335 - Button { 336 - file.decision = .takeTheirs 337 - } label: { 338 - Image(systemName: "graduationcap.fill") 339 - .foregroundColor(file.decision == .takeTheirs ? .white : .green) 340 - } 341 - .buttonStyle(.bordered) 342 - .tint(file.decision == .takeTheirs ? .green : nil) 343 - .help("Take teacher's version") 344 - } 345 - } 346 - 347 - private func colorForType(_ type: FileChangeType) -> Color { 348 - switch type { 349 - case .added: return .green 350 - case .modified: return .orange 351 - case .deleted: return .red 352 - case .unchanged: return .gray 353 - } 354 249 } 355 250 } 356 251
+122 -156
ZipMerge/FileComparer.swift
··· 11 11 import Compression 12 12 13 13 class FileComparer { 14 - 14 + 15 + struct GitMergeResult { 16 + let branchName: String 17 + let originalBranch: String 18 + let hasConflicts: Bool 19 + } 20 + 21 + static func importZipToGitBranch(zipURL: URL, projectDirectory: URL) throws -> GitMergeResult { 22 + let timestamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") 23 + let branchName = "zip-import-\(timestamp)" 24 + 25 + // Get current branch name 26 + let originalBranch = try runGitCommand(["branch", "--show-current"], at: projectDirectory).trimmingCharacters(in: .whitespacesAndNewlines) 27 + 28 + // Create temp directory for extraction 29 + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) 30 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 31 + 32 + // Ensure temp directory is cleaned up 33 + defer { 34 + try? FileManager.default.removeItem(at: tempDir) 35 + } 36 + 37 + do { 38 + // Extract zip to temp directory 39 + try extractZip(at: zipURL, to: tempDir) 40 + 41 + // Find the actual root (in case zip has a wrapper folder) 42 + let extractedRoot = findRootDirectory(in: tempDir) 43 + 44 + // Create and checkout new branch 45 + try runGitCommand(["checkout", "-b", branchName], at: projectDirectory) 46 + 47 + // Copy extracted files to project directory 48 + try copyContents(from: extractedRoot, to: projectDirectory) 49 + 50 + // Stage all changes 51 + try runGitCommand(["add", "-A"], at: projectDirectory) 52 + 53 + // Commit the changes 54 + let commitMessage = "Import from zip: \(zipURL.lastPathComponent)" 55 + try runGitCommand(["commit", "-m", commitMessage], at: projectDirectory) 56 + 57 + // Switch back to original branch 58 + try runGitCommand(["checkout", originalBranch], at: projectDirectory) 59 + 60 + // Initiate merge without committing (allows selective staging) 61 + let mergeOutput = try? runGitCommand(["merge", "--no-commit", "--no-ff", branchName], at: projectDirectory) 62 + let hasConflicts = mergeOutput?.contains("CONFLICT") ?? false 63 + 64 + return GitMergeResult( 65 + branchName: branchName, 66 + originalBranch: originalBranch, 67 + hasConflicts: hasConflicts 68 + ) 69 + } catch { 70 + // Cleanup: try to switch back to original branch if something went wrong 71 + try? runGitCommand(["checkout", originalBranch], at: projectDirectory) 72 + try? runGitCommand(["branch", "-D", branchName], at: projectDirectory) 73 + throw error 74 + } 75 + } 76 + 77 + static func cleanupGitMerge(branchName: String, projectDirectory: URL) throws { 78 + // Delete the temporary branch 79 + try runGitCommand(["branch", "-D", branchName], at: projectDirectory) 80 + } 81 + 82 + private static func runGitCommand(_ arguments: [String], at directory: URL) throws -> String { 83 + let process = Process() 84 + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") 85 + process.currentDirectoryURL = directory 86 + process.arguments = arguments 87 + 88 + let outputPipe = Pipe() 89 + let errorPipe = Pipe() 90 + process.standardOutput = outputPipe 91 + process.standardError = errorPipe 92 + 93 + try process.run() 94 + process.waitUntilExit() 95 + 96 + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() 97 + let output = String(data: outputData, encoding: .utf8) ?? "" 98 + 99 + if process.terminationStatus != 0 { 100 + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() 101 + let errorOutput = String(data: errorData, encoding: .utf8) ?? "Unknown error" 102 + throw NSError(domain: "ZipMerge", code: Int(process.terminationStatus), 103 + userInfo: [NSLocalizedDescriptionKey: "Git command failed: \(errorOutput)"]) 104 + } 105 + 106 + return output 107 + } 108 + 109 + private static func copyContents(from source: URL, to destination: URL) throws { 110 + let fm = FileManager.default 111 + let contents = try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) 112 + 113 + for item in contents { 114 + let itemName = item.lastPathComponent 115 + 116 + // Skip .git directory, .DS_Store, and __MACOSX 117 + if itemName == ".git" || itemName == ".DS_Store" || itemName == "__MACOSX" { 118 + continue 119 + } 120 + 121 + let destPath = destination.appendingPathComponent(itemName) 122 + 123 + // Remove existing item if it exists 124 + if fm.fileExists(atPath: destPath.path) { 125 + try fm.removeItem(at: destPath) 126 + } 127 + 128 + // Copy the item (including hidden files for Eclipse projects) 129 + try fm.copyItem(at: item, to: destPath) 130 + } 131 + } 132 + 15 133 static func extractZip(at zipURL: URL, to destination: URL) throws { 16 134 let process = Process() 17 135 process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") 18 136 process.arguments = ["-o", zipURL.path, "-d", destination.path] 19 - 137 + 20 138 let pipe = Pipe() 21 139 process.standardOutput = pipe 22 140 process.standardError = pipe 23 - 141 + 24 142 try process.run() 25 143 process.waitUntilExit() 26 - 144 + 27 145 if process.terminationStatus != 0 { 28 146 let data = pipe.fileHandleForReading.readDataToEndOfFile() 29 147 let output = String(data: data, encoding: .utf8) ?? "Unknown error" ··· 47 165 } 48 166 49 167 return directory 50 - } 51 - 52 - static func compare(yourDirectory: URL, theirDirectory: URL) throws -> ComparisonResult { 53 - let fm = FileManager.default 54 - var files: [ComparedFile] = [] 55 - 56 - // Get all files from both directories 57 - let yourFiles = getAllFiles(in: yourDirectory, relativeTo: yourDirectory) 58 - let theirFiles = getAllFiles(in: theirDirectory, relativeTo: theirDirectory) 59 - 60 - let yourPaths = Set(yourFiles.keys) 61 - let theirPaths = Set(theirFiles.keys) 62 - 63 - // Files only in theirs (added) 64 - for path in theirPaths.subtracting(yourPaths) { 65 - let content = try? String(contentsOf: theirFiles[path]!, encoding: .utf8) 66 - files.append(ComparedFile( 67 - relativePath: path, 68 - changeType: .added, 69 - yourContent: nil, 70 - theirContent: content 71 - )) 72 - } 73 - 74 - // Files only in yours (deleted from teacher's version) 75 - for path in yourPaths.subtracting(theirPaths) { 76 - let content = try? String(contentsOf: yourFiles[path]!, encoding: .utf8) 77 - files.append(ComparedFile( 78 - relativePath: path, 79 - changeType: .deleted, 80 - yourContent: content, 81 - theirContent: nil 82 - )) 83 - } 84 - 85 - // Files in both - check if modified 86 - for path in yourPaths.intersection(theirPaths) { 87 - let yourURL = yourFiles[path]! 88 - let theirURL = theirFiles[path]! 89 - 90 - let yourData = try? Data(contentsOf: yourURL) 91 - let theirData = try? Data(contentsOf: theirURL) 92 - 93 - if yourData == theirData { 94 - files.append(ComparedFile( 95 - relativePath: path, 96 - changeType: .unchanged 97 - )) 98 - } else { 99 - let yourContent = try? String(contentsOf: yourURL, encoding: .utf8) 100 - let theirContent = try? String(contentsOf: theirURL, encoding: .utf8) 101 - 102 - // Compute hunks for modified files 103 - let hunks = computeHunks(yourContent: yourContent ?? "", theirContent: theirContent ?? "") 104 - 105 - files.append(ComparedFile( 106 - relativePath: path, 107 - changeType: .modified, 108 - yourContent: yourContent, 109 - theirContent: theirContent, 110 - hunks: hunks 111 - )) 112 - } 113 - } 114 - 115 - // Sort by path 116 - files.sort { $0.relativePath < $1.relativePath } 117 - 118 - return ComparisonResult( 119 - files: files, 120 - yourDirectory: yourDirectory, 121 - theirDirectory: theirDirectory 122 - ) 123 - } 124 - 125 - private static func getAllFiles(in directory: URL, relativeTo base: URL) -> [String: URL] { 126 - let fm = FileManager.default 127 - var result: [String: URL] = [:] 128 - 129 - guard let enumerator = fm.enumerator( 130 - at: directory, 131 - includingPropertiesForKeys: [.isRegularFileKey], 132 - options: [.skipsHiddenFiles] 133 - ) else { 134 - return result 135 - } 136 - 137 - for case let fileURL as URL in enumerator { 138 - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), 139 - resourceValues.isRegularFile == true else { 140 - continue 141 - } 142 - 143 - let relativePath = fileURL.path.replacingOccurrences(of: base.path + "/", with: "") 144 - result[relativePath] = fileURL 145 - } 146 - 147 - return result 148 - } 149 - 150 - static func applyChanges(_ comparison: ComparisonResult) throws { 151 - let fm = FileManager.default 152 - 153 - for file in comparison.files { 154 - guard file.decision != .pending else { continue } 155 - 156 - let yourFile = comparison.yourDirectory.appendingPathComponent(file.relativePath) 157 - let theirFile = comparison.theirDirectory.appendingPathComponent(file.relativePath) 158 - 159 - switch (file.changeType, file.decision) { 160 - case (.added, .takeTheirs): 161 - // Copy new file from theirs to yours 162 - let parentDir = yourFile.deletingLastPathComponent() 163 - try fm.createDirectory(at: parentDir, withIntermediateDirectories: true) 164 - try fm.copyItem(at: theirFile, to: yourFile) 165 - 166 - case (.modified, .takeTheirs): 167 - // Check if hunks are used 168 - if !file.hunks.isEmpty { 169 - // Apply selected hunks only 170 - try applySelectedHunks(file: file, yourFile: yourFile) 171 - } else { 172 - // Replace entire file with theirs 173 - try fm.removeItem(at: yourFile) 174 - try fm.copyItem(at: theirFile, to: yourFile) 175 - } 176 - 177 - case (.deleted, .takeTheirs): 178 - // Delete your file (it's not in teacher's version) 179 - try fm.removeItem(at: yourFile) 180 - 181 - case (_, .keepMine): 182 - // Do nothing, keep your version 183 - break 184 - 185 - default: 186 - break 187 - } 188 - } 189 - } 190 - 191 - private static func computeHunks(yourContent: String, theirContent: String) -> [DiffHunk] { 192 - // For now, return empty array - hunk selection can be added later 193 - // This feature requires a robust diff algorithm which is complex to implement 194 - return [] 195 - } 196 - 197 - private static func applySelectedHunks(file: ComparedFile, yourFile: URL) throws { 198 - // This will be implemented when hunk selection UI is ready 199 - // For now, just replace the entire file 200 - guard let theirContent = file.theirContent else { return } 201 - try theirContent.write(to: yourFile, atomically: true, encoding: .utf8) 202 168 } 203 169 }