2.5.1 #11

Merged
rune merged 10 commits from 2.5.1 into main 2026-08-04 14:12:17 +02:00
10 changed files with 556 additions and 14 deletions
Showing only changes of commit e2284aba2b - Show all commits
@@ -716,6 +716,10 @@ The weather is sunny today!
<div class="example">
<pre><code>~/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!
<li>Check footer for detailed error message</li>
</ul>
<h4 id="git-sync-troubleshooting-untracked">"Untracked Working Tree Files Would Be Overwritten"</h4>
<p>This can happen the first time a machine syncs after Confab adds a new file to the sync repository (like <code>folders.json</code> or <code>notes.json</code>) — 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.</p>
<p>When Confab detects this specific error, it offers to fix it automatically — a dialog appears with a <strong>"Fix It For Me"</strong> button that removes the leftover local copy and completes the sync, or a <strong>"Fix It Myself"</strong> button if you'd rather do it by hand:</p>
<ol>
<li>Open your sync folder (default: <code>~/Library/Application Support/oAI/sync</code>)</li>
<li>Delete the specific file(s) named in the error message</li>
<li>Open Terminal, <code>cd</code> into that folder, and run <code>git pull --ff-only</code> once</li>
<li>Confab will regenerate the file correctly on its next sync</li>
</ol>
<h4>Merge Conflicts</h4>
<ul>
<li>Stop auto-sync on all but one machine</li>
+124 -1
View File
@@ -33,6 +33,17 @@ class GitSyncService {
// Debounce tracking
private var pendingSyncTask: Task<Void, Never>?
/// 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<file>\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()
+21
View File
@@ -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() }
)
}
}
}
+3 -2
View File
@@ -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)
+3 -2
View File
@@ -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)
@@ -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
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
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)
}
}
}
}
@@ -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
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
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)
}
}
}
+27
View File
@@ -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],
+13 -9
View File
@@ -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
}
+67
View File
@@ -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)
}
}