diff --git a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html index b26dd5b..2a9ca30 100644 --- a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html +++ b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html @@ -716,6 +716,10 @@ The weather is sunny today!
~/Library/Application Support/oAI/sync/
 ├── README.md                    # Warning about manual edits
+├── folders.json                 # Your folder structure (auto-managed, don't edit)
+├── notes.json                   # Per-conversation notes index (auto-managed, don't edit)
+├── notes/                       # Per-conversation notes files
+│   └── ...
 └── conversations/
     ├── my-first-chat.md
     ├── python-help.md
@@ -773,6 +777,16 @@ The weather is sunny today!
                     
  • Check footer for detailed error message
  • +

    "Untracked Working Tree Files Would Be Overwritten"

    +

    This can happen the first time a machine syncs after Confab adds a new file to the sync repository (like folders.json or notes.json) — if that file gets written locally before this machine has ever pulled it from the remote, git sees it as a leftover, unrelated file blocking the merge.

    +

    When Confab detects this specific error, it offers to fix it automatically — a dialog appears with a "Fix It For Me" button that removes the leftover local copy and completes the sync, or a "Fix It Myself" button if you'd rather do it by hand:

    +
      +
    1. Open your sync folder (default: ~/Library/Application Support/oAI/sync)
    2. +
    3. Delete the specific file(s) named in the error message
    4. +
    5. Open Terminal, cd into that folder, and run git pull --ff-only once
    6. +
    7. Confab will regenerate the file correctly on its next sync
    8. +
    +

    Merge Conflicts

    • Stop auto-sync on all but one machine
    • diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index 3f9075b..a183c1d 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -33,6 +33,17 @@ class GitSyncService { // Debounce tracking private var pendingSyncTask: Task? + /// A pull failed because a new sync-repo file (folders.json, notes.json, ...) collided with an + /// untracked local copy — see parseUntrackedFileConflict(from:). Surfaced to the user via + /// GitSyncConflictSheet (wired in ChatView.swift), offering an automatic or manual fix. + struct PendingGitConflict: Identifiable { + let id = UUID() + let files: [String] + let rawError: String + var canAutoFix: Bool { files.allSatisfy(GitSyncService.isFileSafeToAutoDelete) } + } + private(set) var pendingGitConflict: PendingGitConflict? = nil + private init() { // Check if repository is cloned at initialization (synchronous check) let localPath = expandPath(settings.syncLocalPath) @@ -83,12 +94,84 @@ class GitSyncService { let localPath = expandPath(settings.syncLocalPath) log.info("Pulling changes from remote") - _ = try await runGit(["pull", "--ff-only"], cwd: localPath) + do { + _ = try await runGit(["pull", "--ff-only"], cwd: localPath) + } catch { + // Surface an "untracked working tree files" collision as a recoverable conflict the + // user can act on, without changing this function's throw contract — existing callers + // (syncOnStartup's non-fatal log, syncNow's error display) are unaffected. Guarded on + // pendingGitConflict already being nil so a second pull failure while the sheet is + // still showing doesn't replace its content out from under the user. + if pendingGitConflict == nil, + let files = Self.parseUntrackedFileConflict(from: error.localizedDescription) { + pendingGitConflict = PendingGitConflict(files: files, rawError: error.localizedDescription) + } + throw error + } syncStatus.lastSyncTime = Date() await updateStatus() } + /// Re-verifies each file is still genuinely untracked (not just trusting the parsed error text) + /// immediately before deleting, deletes them, retries pull(), and on success imports so the + /// previously-blocked content actually lands. Returns nil on success, an error description on + /// failure. Deliberately does not touch pendingGitConflict itself — dismissPendingGitConflict() + /// is the sheet's explicit "I'm done looking at this" signal. SwiftUI's .sheet(item:) dismisses + /// the instant pendingGitConflict goes nil, so clearing it here would yank the sheet away before + /// the user ever sees whether the fix actually worked. + func autoResolveUntrackedConflict(_ conflict: PendingGitConflict) async -> String? { + guard conflict.canAutoFix else { + return "Some of these files can't be safely removed automatically." + } + + let localPath = expandPath(settings.syncLocalPath) + + for file in conflict.files { + guard let status = try? await runGit(["status", "--porcelain", "--", file], cwd: localPath), + status.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("??") + else { + return "\(file) is no longer untracked — leaving it in place rather than risk deleting something else. Try syncing again." + } + try? FileManager.default.removeItem(at: URL(fileURLWithPath: localPath).appendingPathComponent(file)) + } + + do { + try await pull() + _ = try await importAllConversations() + return nil + } catch { + return error.localizedDescription + } + } + + /// Explicit dismiss for GitSyncConflictSheet — see autoResolveUntrackedConflict's note on why + /// the recovery method itself never clears this. + func dismissPendingGitConflict() { + pendingGitConflict = nil + } + + /// Shown by GitSyncManualFixSheet when the user picks "Fix It Myself" on GitSyncConflictSheet. + /// Deliberately in-app text rather than a deep link into the Help Book: NSWorkspace.shared.open() + /// silently drops the #fragment for file:// URLs before handing off to the default browser (the + /// anchor never survives — confirmed by inspecting location.hash in the opened page, it comes + /// back empty), so an anchored Help Book link always lands on the index instead of the relevant + /// section. Carrying the conflict's own file list and the real sync path into this sheet is also + /// just more useful than generic help-page prose pointing at "the file(s) named in the error". + private(set) var pendingManualFixInstructions: PendingGitConflict? = nil + + /// Swaps GitSyncConflictSheet for GitSyncManualFixSheet — clearing pendingGitConflict here (rather + /// than relying on the sheet's own onDismiss) dismisses the first sheet via its .sheet(item:) + /// binding while pendingManualFixInstructions immediately presents the second. + func showManualFixInstructions(for conflict: PendingGitConflict) { + pendingGitConflict = nil + pendingManualFixInstructions = conflict + } + + func dismissManualFixInstructions() { + pendingManualFixInstructions = nil + } + /// Push local changes to remote func push(message: String = "Sync from Confab") async throws { try ensureCloned() @@ -299,6 +382,46 @@ class GitSyncService { return localFolderIds.filter { !manifestFolderIds.contains($0) } } + // MARK: - Untracked File Conflict Recovery + + /// Parses git's "untracked working tree files would be overwritten by merge" pull failure into + /// the list of colliding relative paths. Returns nil for any other error (auth, network, a real + /// merge conflict) — those aren't what this recovery flow is for. Exact git format: + /// "error: The following untracked working tree files would be overwritten by merge:\n\t\n...\nPlease move or remove them before you merge.\nAborting" + nonisolated static func parseUntrackedFileConflict(from message: String) -> [String]? { + let marker = "untracked working tree files would be overwritten by merge:" + guard let markerRange = message.range(of: marker) else { return nil } + + let lines = message[markerRange.upperBound...].components(separatedBy: "\n") + var files: [String] = [] + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + // The file list ends at the first line that isn't an indented filename (git's own + // trailing "Please move or remove them..."/"Aborting" lines aren't tab-indented). + guard line.hasPrefix("\t") || line.hasPrefix(" ") else { break } + files.append(trimmed) + } + return files.isEmpty ? nil : files + } + + /// Defense in depth for the "Fix It For Me" auto-recovery path: only files this app itself is + /// known to write into the sync repo are ever eligible for automatic deletion. Rejects path + /// traversal, absolute paths, and anything outside the known shape — an unrecognized file falls + /// back to manual recovery only (see PendingGitConflict.canAutoFix). + nonisolated static func isFileSafeToAutoDelete(_ relativePath: String) -> Bool { + if relativePath == "folders.json" || relativePath == "notes.json" { + return true + } + for prefix in ["conversations/", "notes/"] { + guard relativePath.hasPrefix(prefix) else { continue } + let rest = relativePath.dropFirst(prefix.count) + // Exactly one path segment (no further "/"), and a .md file. + return !rest.isEmpty && !rest.contains("/") && rest.hasSuffix(".md") + } + return false + } + /// Import conversations from markdown files func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) { try ensureCloned() diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index 8a67c20..c993c92 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -131,6 +131,27 @@ struct ChatView: View { onDeny: { MCPService.shared.denyPendingPersonalDataAction() } ) } + .sheet(item: Binding( + get: { GitSyncService.shared.pendingGitConflict }, + set: { _ in } + )) { pending in + GitSyncConflictSheet( + pending: pending, + onFixForMe: { await GitSyncService.shared.autoResolveUntrackedConflict(pending) }, + onFixMyself: { GitSyncService.shared.showManualFixInstructions(for: pending) }, + onDismiss: { GitSyncService.shared.dismissPendingGitConflict() } + ) + } + .sheet(item: Binding( + get: { GitSyncService.shared.pendingManualFixInstructions }, + set: { _ in } + )) { pending in + GitSyncManualFixSheet( + files: pending.files, + syncPath: SettingsService.shared.syncLocalPath, + onDone: { GitSyncService.shared.dismissManualFixInstructions() } + ) + } } } diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift index fa16e59..659e2ad 100644 --- a/oAI/Views/Main/SidebarView.swift +++ b/oAI/Views/Main/SidebarView.swift @@ -192,7 +192,7 @@ struct SidebarView: View { Section { if !collapsedFolders.contains(entry.folder.id) { ForEach(folderConversations) { conversation in - conversationRow(conversation) + conversationRow(conversation, depth: entry.depth + 1) } } } header: { @@ -320,8 +320,9 @@ struct SidebarView: View { } @ViewBuilder - private func conversationRow(_ conversation: Conversation) -> some View { + private func conversationRow(_ conversation: Conversation, depth: Int = 0) -> some View { SidebarConversationRow(conversation: conversation) + .padding(.leading, CGFloat(depth) * 14) .contentShape(Rectangle()) .onTapGesture(count: 2) { chatViewModel.loadConversation(conversation) diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 8f61b24..17f2656 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -266,7 +266,7 @@ struct ConversationListView: View { Section { if !collapsedFolders.contains(entry.folder.id) { ForEach(folderConversations) { conversation in - conversationRow(conversation) + conversationRow(conversation, depth: entry.depth + 1) } } } header: { @@ -337,7 +337,7 @@ struct ConversationListView: View { } @ViewBuilder - private func conversationRow(_ conversation: Conversation) -> some View { + private func conversationRow(_ conversation: Conversation, depth: Int = 0) -> some View { let index = filteredConversations.firstIndex(where: { $0.id == conversation.id }) ?? 0 HStack(spacing: 12) { if isSelecting { @@ -382,6 +382,7 @@ struct ConversationListView: View { .help("Delete conversation") } } + .padding(.leading, CGFloat(depth) * 14) .listRowBackground( !isSelecting && index == selectedIndex ? Color.confabAccent.opacity(0.15) diff --git a/oAI/Views/Screens/GitSyncConflictSheet.swift b/oAI/Views/Screens/GitSyncConflictSheet.swift new file mode 100644 index 0000000..cb10975 --- /dev/null +++ b/oAI/Views/Screens/GitSyncConflictSheet.swift @@ -0,0 +1,176 @@ +// +// GitSyncConflictSheet.swift +// Confab +// +// Recovery UI for Git Sync's "untracked working tree files" pull failure +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen +// +// This file is part of Confab. +// +// Confab is licensed under the PolyForm Noncommercial License 1.0.0. +// You may use, study, modify, and share it for any noncommercial +// purpose. Commercial use — including selling Confab or any part of +// it, standalone or bundled into another product or service — +// requires a separate commercial license from the copyright holder. +// +// See the LICENSE file or +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +import SwiftUI + +struct GitSyncConflictSheet: View { + let pending: GitSyncService.PendingGitConflict + /// Performs the automatic fix; nil return means success. + let onFixForMe: () async -> String? + /// Swaps this sheet for GitSyncManualFixSheet's in-app step-by-step instructions. + let onFixMyself: () -> Void + let onDismiss: () -> Void + + private enum RecoveryState: Equatable { + case idle + case fixing + case succeeded + case failed(String) + } + + @State private var recoveryState: RecoveryState = .idle + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + // Header + HStack(spacing: 12) { + Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") + .font(.title2) + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text("Sync Ran Into a Conflict") + .font(.system(size: 17, weight: .semibold)) + Text("Confab syncs in the background automatically, and just hit a file that collided with a leftover local copy of itself") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + + // Conflicting files + VStack(alignment: .leading, spacing: 6) { + Text("AFFECTED FILES") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + ForEach(pending.files, id: \.self) { file in + Text(file) + .font(.system(size: 13, design: .monospaced)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(12) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + } + + Text("These are Confab's own sync bookkeeping files, not your conversations — nothing you've written is at risk either way.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + statusBanner + + if !pending.canAutoFix { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.system(size: 13)) + .padding(.top, 1) + Text("One or more of these files aren't ones Confab recognizes as safe to remove automatically — please fix this one yourself.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(10) + .background(Color.orange.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // Buttons + HStack(spacing: 8) { + Button("Fix It Myself") { + onFixMyself() + } + .buttonStyle(.bordered) + .keyboardShortcut(.escape, modifiers: []) + + Spacer() + + if recoveryState == .succeeded { + Button("Done") { + onDismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: []) + } else { + Button("Fix It For Me") { + recoveryState = .fixing + Task { + if let errorMessage = await onFixForMe() { + recoveryState = .failed(errorMessage) + } else { + recoveryState = .succeeded + } + } + } + .buttonStyle(.borderedProminent) + .disabled(!pending.canAutoFix || recoveryState == .fixing) + .keyboardShortcut(.return, modifiers: []) + } + } + } + .padding(24) + .frame(width: 480) + } + + @ViewBuilder + private var statusBanner: some View { + switch recoveryState { + case .idle: + EmptyView() + case .fixing: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Fixing...") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + case .succeeded: + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Fixed — your conversations are back in sync.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + case .failed(let message): + HStack(alignment: .top, spacing: 8) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .font(.system(size: 13)) + .padding(.top, 1) + Text("Still couldn't sync: \(message)") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/oAI/Views/Screens/GitSyncManualFixSheet.swift b/oAI/Views/Screens/GitSyncManualFixSheet.swift new file mode 100644 index 0000000..bdc207f --- /dev/null +++ b/oAI/Views/Screens/GitSyncManualFixSheet.swift @@ -0,0 +1,108 @@ +// +// GitSyncManualFixSheet.swift +// Confab +// +// Manual-fix instructions for Git Sync's "untracked working tree files" pull failure — +// shown in-app instead of deep-linking to the Help Book (NSWorkspace.shared.open() drops +// #fragment anchors on file:// URLs, so an anchored help link always landed on the index). +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen +// +// This file is part of Confab. +// +// Confab is licensed under the PolyForm Noncommercial License 1.0.0. +// You may use, study, modify, and share it for any noncommercial +// purpose. Commercial use — including selling Confab or any part of +// it, standalone or bundled into another product or service — +// requires a separate commercial license from the copyright holder. +// +// See the LICENSE file or +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +import SwiftUI + +struct GitSyncManualFixSheet: View { + let files: [String] + let syncPath: String + let onDone: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 12) { + Image(systemName: "wrench.and.screwdriver") + .font(.title2) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text("Fix It Yourself") + .font(.system(size: 17, weight: .semibold)) + Text("Four steps, then Confab takes over again") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + Spacer() + } + + VStack(alignment: .leading, spacing: 14) { + step(1, "Open your sync folder:") + Text(syncPath) + .font(.system(size: 13, design: .monospaced)) + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + step(2, "Delete the file\(files.count == 1 ? "" : "s") named in the error:") + VStack(alignment: .leading, spacing: 4) { + ForEach(files, id: \.self) { file in + Text(file) + .font(.system(size: 13, design: .monospaced)) + } + } + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + step(3, "Open Terminal, cd into that folder, and run this once:") + Text("git pull --ff-only") + .font(.system(size: 13, design: .monospaced)) + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + step(4, "That's it — Confab will regenerate the file correctly on its next sync.") + } + + HStack { + Spacer() + Button("Done") { + onDone() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: []) + } + } + .padding(24) + .frame(width: 480) + } + + @ViewBuilder + private func step(_ number: Int, _ text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(number).") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + Text(text) + .font(.system(size: 13)) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 55dcda0..d0cc9c1 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -237,6 +237,33 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .sheet(isPresented: $showEmailLog) { EmailLogView() } + // Duplicated (not moved) from ChatView.swift: SwiftUI won't stack a new sheet on top of + // this already-presented Settings sheet if the .sheet(item:) only lives on ChatView, which + // sits underneath/behind Settings once it's open — a conflict triggered by the "Sync Now" + // button in here would be silently dropped with no visible modal. Attaching the same + // binding here too lets it present correctly regardless of which one is on top; only the + // currently-frontmost host actually shows it, so there's no double-presentation risk. + .sheet(item: Binding( + get: { gitSync.pendingGitConflict }, + set: { _ in } + )) { pending in + GitSyncConflictSheet( + pending: pending, + onFixForMe: { await gitSync.autoResolveUntrackedConflict(pending) }, + onFixMyself: { gitSync.showManualFixInstructions(for: pending) }, + onDismiss: { gitSync.dismissPendingGitConflict() } + ) + } + .sheet(item: Binding( + get: { gitSync.pendingManualFixInstructions }, + set: { _ in } + )) { pending in + GitSyncManualFixSheet( + files: pending.files, + syncPath: SettingsService.shared.syncLocalPath, + onDone: { gitSync.dismissManualFixInstructions() } + ) + } .fileImporter( isPresented: $showRestoreFilePicker, allowedContentTypes: [.json], diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index d93c0a3..f74a147 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -206,7 +206,7 @@ struct oAIApp: App { // ── Help menu ───────────────────────────────────────────────── CommandGroup(replacing: .help) { - Button("Confab Help") { openHelp() } + Button("Confab Help") { Self.openHelpBook() } .keyboardShortcut("?", modifiers: .command) Divider() Button("Read Release Notes") { @@ -223,14 +223,18 @@ struct oAIApp: App { } #if os(macOS) - private func openHelp() { - // Opens the Help Book's index.html directly in the default browser rather than - // through NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why - // (Apple's Tips.app replacement for Help Viewer can't resolve anchors on this beta). - // Revisit once macOS 27 reaches RC. - if let helpBookURL = Bundle.main.url(forResource: "Confab.help", withExtension: nil) { - NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html")) - } + /// Opens the Help Book's index.html directly in the default browser rather than through + /// NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why (Apple's Tips.app + /// replacement for Help Viewer can't resolve anchors on this beta). Revisit once macOS 27 + /// reaches RC. No anchor/fragment support: NSWorkspace.shared.open() silently drops #fragments + /// for file:// URLs before handing off to the browser (confirmed via location.hash coming back + /// empty in the opened page) — deep links into a specific section aren't reliable through this + /// API, so callers needing to point at specific content should show it in-app instead (see + /// GitSyncManualFixSheet for an example) rather than trying to anchor into this Help Book. + nonisolated static func openHelpBook() { + guard let helpBookURL = Bundle.main.url(forResource: "Confab.help", withExtension: nil) else { return } + let url = helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html") + NSWorkspace.shared.open(url) } #endif } diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift index 9c2f880..deff04c 100644 --- a/oAITests/GitSyncServiceTests.swift +++ b/oAITests/GitSyncServiceTests.swift @@ -285,4 +285,71 @@ struct GitSyncServiceTests { #expect(decoded.notes[conversationId]?.filename == "Chat-a3f2.md") #expect(decoded.notes[conversationId]?.enabled == true) } + + // MARK: - parseUntrackedFileConflict + + @Test("Parses a single colliding file out of git's untracked-files error") + func parseUntrackedFileConflictSingleFile() { + let message = "error: The following untracked working tree files would be overwritten by merge:\n\tfolders.json\nPlease move or remove them before you merge.\nAborting" + #expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["folders.json"]) + } + + @Test("Parses multiple colliding files out of git's untracked-files error") + func parseUntrackedFileConflictMultipleFiles() { + let message = "error: The following untracked working tree files would be overwritten by merge:\n\tfolders.json\n\tnotes.json\nPlease move or remove them before you merge.\nAborting" + #expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["folders.json", "notes.json"]) + } + + @Test("Wrapped SyncError.gitFailed description still parses correctly") + func parseUntrackedFileConflictWrappedMessage() { + let message = "Git command failed: error: The following untracked working tree files would be overwritten by merge:\n\tnotes/Chat-a3f2.md\nPlease move or remove them before you merge.\nAborting" + #expect(GitSyncService.parseUntrackedFileConflict(from: message) == ["notes/Chat-a3f2.md"]) + } + + @Test("Unrelated git errors return nil, not an empty or bogus file list") + func parseUntrackedFileConflictUnrelatedErrors() { + #expect(GitSyncService.parseUntrackedFileConflict(from: "fatal: Authentication failed for 'https://gitlab.pm/rune/oai-swift.git/'") == nil) + #expect(GitSyncService.parseUntrackedFileConflict(from: "fatal: unable to access: Could not resolve host") == nil) + #expect(GitSyncService.parseUntrackedFileConflict(from: "error: Your local changes to the following files would be overwritten by merge:\n\tconversations/x.md") == nil) + } + + // MARK: - isFileSafeToAutoDelete + + @Test("Known sync-manifest files are safe to auto-delete") + func isFileSafeToAutoDeleteAllowsKnownFiles() { + #expect(GitSyncService.isFileSafeToAutoDelete("folders.json")) + #expect(GitSyncService.isFileSafeToAutoDelete("notes.json")) + #expect(GitSyncService.isFileSafeToAutoDelete("conversations/my-chat.md")) + #expect(GitSyncService.isFileSafeToAutoDelete("notes/Chat-a3f2.md")) + } + + @Test("Path traversal and absolute paths are never safe to auto-delete") + func isFileSafeToAutoDeleteRejectsTraversal() { + #expect(!GitSyncService.isFileSafeToAutoDelete("../etc/passwd")) + #expect(!GitSyncService.isFileSafeToAutoDelete("/etc/passwd")) + #expect(!GitSyncService.isFileSafeToAutoDelete("conversations/../../../etc/passwd")) + } + + @Test("Files outside the known shape are never safe to auto-delete") + func isFileSafeToAutoDeleteRejectsUnknownFiles() { + #expect(!GitSyncService.isFileSafeToAutoDelete("README.md")) + #expect(!GitSyncService.isFileSafeToAutoDelete("conversations/sub/x.md")) + #expect(!GitSyncService.isFileSafeToAutoDelete("notes/sub/x.md")) + #expect(!GitSyncService.isFileSafeToAutoDelete("")) + #expect(!GitSyncService.isFileSafeToAutoDelete("conversations/")) + } + + // MARK: - PendingGitConflict.canAutoFix + + @Test("canAutoFix is true when every file is safe to auto-delete") + func pendingGitConflictCanAutoFixAllSafe() { + let conflict = GitSyncService.PendingGitConflict(files: ["folders.json", "notes.json"], rawError: "") + #expect(conflict.canAutoFix) + } + + @Test("canAutoFix is false when any file isn't safe to auto-delete") + func pendingGitConflictCanAutoFixOneUnsafe() { + let conflict = GitSyncService.PendingGitConflict(files: ["folders.json", "README.md"], rawError: "") + #expect(!conflict.canAutoFix) + } }