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.
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user