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:
+
+ - Open your sync folder (default:
~/Library/Application Support/oAI/sync)
+ - Delete the specific file(s) named in the error message
+ - Open Terminal,
cd into that folder, and run git pull --ff-only once
+ - Confab will regenerate the file correctly on its next sync
+
+
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)
+ }
}