NSFileVersion on macOS: Conflict Selection, Safe Replacement, and Version Cleanup
Resolve macOS document version conflicts by separating selection, coordinated replacement, validation, resolution flags, and safe version-store cleanup.
NSFileVersion represents a revision of a file, including metadata and a URL for its associated content. On macOS, the document architecture can create local versions at meaningful lifecycle points. Cloud-backed files can also acquire conflicting versions when different machines save competing changes. These two uses share an API, but a local history entry and an unresolved cloud conflict are not the same state.
Conflict resolution requires more than choosing the latest timestamp. The app must decide which information survives, replace or merge content safely, confirm that the result is usable, mark the appropriate conflict objects resolved, and remove revisions that no longer need retention. Removing history before validating the result can turn a recoverable conflict into data loss.
Inventory the current and conflicting revisions
Use currentVersionOfItem(at:) to inspect the current version, otherVersionsOfItem(at:) for other revisions, and unresolvedConflictVersionsOfItem(at:) for the conflict set. These calls answer different questions. A version existing in history does not imply that it conflicts with the current file.
Version metadata includes a modification date, user-presentable names, and a persistent identifier. The identifier can be stored and used to retrieve the corresponding version later. Use it to associate a selection with a specific revision rather than relying on an array index that may change after synchronization.
import Foundation
func conflictDescriptions(for documentURL: URL) -> [String] {
let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(
at: documentURL
) ?? []
return conflicts.map { version in
let date = version.modificationDate?.description ?? "Unknown date"
let computer = version.localizedNameOfSavingComputer ?? "Unknown computer"
return "\(computer): \(date)"
}
}
This is a metadata inspection example, not a merge algorithm. Display localized dates and labels appropriately in a real interface. A device name or timestamp helps explain provenance but does not establish correctness. Clock differences, offline edits, and legitimate independent changes make “newest wins” an application policy with potential losses, not a universal conflict-resolution rule.
Decide what the user is choosing
A resolution interface should distinguish keeping the current document, replacing it with a selected revision, and preserving both as separate documents. A domain-aware merge is a fourth option when the format supports it. Two text files may be diffable; two project packages may contain incompatible object graphs despite having similarly named files.
Preview the actual content where practical. A record-count summary, format version, or application-specific change description can be more informative than a timestamp. If a version is not locally available, wait for a supported retrieval path and handle that failure instead of presenting it as an empty file.
Before a destructive selection, preserve unsaved in-memory edits. An NSDocument subclass may have state that is newer than the current on-disk version. Coordinate with the document’s save and reload lifecycle rather than replacing the file while the editor continues to display stale data. Otherwise, a later autosave can overwrite the chosen revision and undo the user’s decision.
Coordinate mutations and capture both error paths
Version removal belongs inside a coordinated write operation. Content replacement also needs coordination appropriate to the document so other presenters can relinquish access and observe the mutation. Avoid blocking the presenter queue waiting for a coordinator that is itself waiting for that presenter.
The following Swift helper demonstrates replacement with one selected version. It must run on an appropriate document work queue, after the app has confirmed the selection and preserved anything needed for rollback. It intentionally does not mark conflicts resolved or delete history, because validation and policy decisions still follow.
enum VersionReplacementError: Error {
case accessorNotRun
}
func replaceDocument(
at documentURL: URL,
with chosen: NSFileVersion
) throws -> URL {
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinationError: NSError?
var operationResult: Result<URL, Error>?
coordinator.coordinate(
writingItemAt: documentURL,
options: .forReplacing,
error: &coordinationError
) { coordinatedURL in
operationResult = Result {
try chosen.replaceItem(at: coordinatedURL, options: [])
}
}
if let coordinationError { throw coordinationError }
guard let operationResult else {
throw VersionReplacementError.accessorNotRun
}
return try operationResult.get()
}
The coordination error and error from the version operation are separate. Ignoring either can falsely report success. Use the returned URL: Apple documents that it may differ from the supplied URL, including when the version content has a different file type and the extension changes. Update the document’s own location/state through its supported lifecycle rather than assuming its previous path remains authoritative.
Validate before resolving and removing
After replacing or committing merged content, reopen or validate it using the same parser and invariants as an ordinary document load. Confirm package completeness, schema compatibility, and any application-level relationships. A filesystem write succeeding does not prove that the editor can use the selected document.
Only then mark the conflicts covered by the decision with isResolved = true. Apple explicitly forbids setting that property to false. Marking resolved removes a revision from conflict reports; it does not itself combine bytes or delete its stored content.
Removal is another operation, with its own errors. Apple notes that unused versions can continue consuming iCloud quota and synchronizing until removed. Remove only the versions that the application has decided are no longer useful, within a coordinated write. Never call remove() on the current version. removeOtherVersionsOfItem(at:) is a broad operation that removes all noncurrent versions, so it should not be used merely because one conflict was resolved.
Retain an explicit cleanup checkpoint if the process stops between validation, marking resolved, and deletion. On relaunch, inspect the actual version store again. Do not assume an interrupted sequence left every flag or stored revision in the expected state. An idempotent cleanup policy should tolerate already-removed items while preserving unrelated history.
Handle synchronization changes during selection
A conflict can change while a user is examining it. Immediately before committing a choice, reconcile the current set and the selected persistent identifier. If the chosen revision is no longer available or the current document changed, show the refreshed decision context instead of committing against a stale UI snapshot.
Avoid broad cleanup based on an old list if newly arriving versions have not been reviewed. Separate the revisions the user approved from the revisions now present. Record the chosen identifier and outcome using a representation suitable for the identifier’s archiving contract; do not invent meaning from its opaque contents.
Failure diagnosis and acceptance testing
Test two independently edited copies, keeping current, choosing another revision, preserving both, and a format-aware merge where implemented. Include unsaved editor changes, an unavailable cloud revision, a replacement error, a validation failure, a changed file extension, cleanup failure, and a crash between each transition.
Log coordination stage, document ID, selection identity, validation result, and cleanup outcome without dumping private content. A failed replacement should leave conflict state available for retry. A failed cleanup after successful validation should be reported as retained history, rather than pretending the content choice failed or blindly repeating replacement.
The API provides revisions and mutation tools. The app remains responsible for explaining competing edits, preserving a recoverable state, and ensuring that the document presented after resolution is exactly the result the user selected.
Related:
- CloudKit Zone Change Sync: Durable Tokens, Tombstones, and Local Transactions
- macOS File Coordination: NSFileCoordinator and NSFilePresenter Without Deadlocks
Sources: