From e2284aba2b25943fbb024acc3ee55bb480c52e5e Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 4 Aug 2026 12:21:15 +0200 Subject: [PATCH] Show manual sync-conflict fix instructions in-app instead of deep-linking to Help NSWorkspace.shared.open() silently drops #fragment anchors on file:// URLs, so "Fix It Myself" always landed on the Help Book index instead of the relevant section. Replaced with GitSyncManualFixSheet, an in-app sheet showing the real conflicting filenames and sync path. Also indent conversation rows one level deeper than their containing folder in the sidebar and conversation list, so nesting is visible on the conversations themselves and not just the folder headers. --- .../Contents/Resources/en.lproj/index.html | 14 ++ oAI/Services/GitSyncService.swift | 125 ++++++++++++- oAI/Views/Main/ChatView.swift | 21 +++ oAI/Views/Main/SidebarView.swift | 5 +- oAI/Views/Screens/ConversationListView.swift | 5 +- oAI/Views/Screens/GitSyncConflictSheet.swift | 176 ++++++++++++++++++ oAI/Views/Screens/GitSyncManualFixSheet.swift | 108 +++++++++++ oAI/Views/Screens/SettingsView.swift | 27 +++ oAI/oAIApp.swift | 22 ++- oAITests/GitSyncServiceTests.swift | 67 +++++++ 10 files changed, 556 insertions(+), 14 deletions(-) create mode 100644 oAI/Views/Screens/GitSyncConflictSheet.swift create mode 100644 oAI/Views/Screens/GitSyncManualFixSheet.swift 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) + } }