Sync Home with pull then push and prepare 1.0.1 build 8 (#124)
Dependency security audit / rustsec (push) Successful in 20s
Tagged release / prepare-release (push) Successful in 4s
Tagged release / build-unix (push) Successful in 1h15m17s
Tagged release / build-windows (push) Successful in 19m26s
Tagged release / publish-release (push) Successful in 3s

This commit is contained in:
hugo committed 2026-09-12 12:53:30 +02:00
1 parent 3c3574b449
commit aec3f237a9
12 files changed
+1174 -50

No files matched your search

Generated
+8 -8
View File
@@ -5388,7 +5388,7 @@ checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
[[package]] [[package]]
name = "ironstorage" name = "ironstorage"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"apple-native-keyring-store", "apple-native-keyring-store",
"arboard", "arboard",
@@ -5435,7 +5435,7 @@ dependencies = [
[[package]] [[package]]
name = "ironstorage-apple" name = "ironstorage-apple"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"ironstorage", "ironstorage",
"uniffi", "uniffi",
@@ -5443,7 +5443,7 @@ dependencies = [
[[package]] [[package]]
name = "ironstorage-apple-release" name = "ironstorage-apple-release"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -5453,7 +5453,7 @@ dependencies = [
[[package]] [[package]]
name = "ironstorage-cli" name = "ironstorage-cli"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"ctrlc", "ctrlc",
"ironstorage", "ironstorage",
@@ -5463,7 +5463,7 @@ dependencies = [
[[package]] [[package]]
name = "ironstorage-desktop" name = "ironstorage-desktop"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"iced", "iced",
@@ -5479,7 +5479,7 @@ dependencies = [
[[package]] [[package]]
name = "ironstorage-macos-packager" name = "ironstorage-macos-packager"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"apple-bundles", "apple-bundles",
"apple-codesign", "apple-codesign",
@@ -5490,7 +5490,7 @@ dependencies = [
[[package]] [[package]]
name = "ironstorage-tui" name = "ironstorage-tui"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"ironstorage", "ironstorage",
@@ -5501,7 +5501,7 @@ dependencies = [
[[package]] [[package]]
name = "ironstorage-watch-apple" name = "ironstorage-watch-apple"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"ironstorage", "ironstorage",
"uniffi", "uniffi",
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
resolver = "3" resolver = "3"
[workspace.package] [workspace.package]
version = "1.0.0" version = "1.0.1"
edition = "2024" edition = "2024"
rust-version = "1.92" rust-version = "1.92"
license = "MIT" license = "MIT"
+3 -3
View File
@@ -15,11 +15,11 @@ copyright = "2026 rfc1437"
review_notes = "IronStorage works entirely on-device without an account or remote service. On first launch, generate a local GPG key and choose a passphrase. You can then create folders without unlocking, and create encrypted password or TOTP entries after entering that passphrase. Git history is initialized locally; HTTPS synchronization is optional and is not needed for review. Entries appear on Apple Watch only after selecting them for Watch sharing on iPhone." review_notes = "IronStorage works entirely on-device without an account or remote service. On first launch, generate a local GPG key and choose a passphrase. You can then create folders without unlocking, and create encrypted password or TOTP entries after entering that passphrase. Git history is initialized locally; HTTPS synchronization is optional and is not needed for review. Entries appear on Apple Watch only after selecting them for Watch sharing on iPhone."
[release] [release]
version = "1.0" version = "1.0.1"
build = "7" build = "8"
minimum_ios = "17.0" minimum_ios = "17.0"
minimum_watchos = "26.0" minimum_watchos = "26.0"
notes = "Initial iPhone and Apple Watch release with local GPG key generation, pass-compatible local Git history, optional HTTPS synchronization, password folders, and explicitly shared TOTP codes on Apple Watch." notes = "Pull down on Home to pull remote changes and then push local commits. Pull failures stop synchronization and are shown immediately. Includes OTP QR import improvements and the Apple Watch companion."
[[screenshots]] [[screenshots]]
file = "apple/AppStore/screenshots/iphone-6.5/home.png" file = "apple/AppStore/screenshots/iphone-6.5/home.png"
+14
View File
@@ -1254,6 +1254,8 @@ public protocol MobileHomeOperationProtocol: AnyObject, Sendable {
func refreshIfStale() throws -> MobileHomePage func refreshIfStale() throws -> MobileHomePage
func sync() throws -> MobileHomePage
} }
open class MobileHomeOperation: MobileHomeOperationProtocol, @unchecked Sendable { open class MobileHomeOperation: MobileHomeOperationProtocol, @unchecked Sendable {
fileprivate let handle: UInt64 fileprivate let handle: UInt64
@@ -1380,6 +1382,15 @@ open func refreshIfStale()throws -> MobileHomePage {
}) })
} }
open func sync()throws -> MobileHomePage {
return try FfiConverterTypeMobileHomePage_lift(try rustCallWithError(FfiConverterTypeMobileHomeFfiError_lift) {
uniffiCallStatus in
uniffi_ironstorage_apple_fn_method_mobilehomeoperation_sync(
self.uniffiCloneHandle(),uniffiCallStatus
)
})
}
} }
@@ -8120,6 +8131,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale() != 27818) { if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale() != 27818) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
if (uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_sync() != 48214) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_ironstorage_apple_checksum_method_mobilekeytransfer_export() != 50467) { if (uniffi_ironstorage_apple_checksum_method_mobilekeytransfer_export() != 50467) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
+11
View File
@@ -518,6 +518,11 @@ RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_push(uint64_t
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh_if_stale(uint64_t ptr, RustCallStatus *_Nonnull out_status RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_refresh_if_stale(uint64_t ptr, RustCallStatus *_Nonnull out_status
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_SYNC
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_METHOD_MOBILEHOMEOPERATION_SYNC
RustBuffer uniffi_ironstorage_apple_fn_method_mobilehomeoperation_sync(uint64_t ptr, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_FN_CLONE_MOBILEKEYTRANSFER
uint64_t uniffi_ironstorage_apple_fn_clone_mobilekeytransfer(uint64_t handle, RustCallStatus *_Nonnull out_status uint64_t uniffi_ironstorage_apple_fn_clone_mobilekeytransfer(uint64_t handle, RustCallStatus *_Nonnull out_status
@@ -1363,6 +1368,12 @@ uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_push(void
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE #define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_REFRESH_IF_STALE
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale(void uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_refresh_if_stale(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_SYNC
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEHOMEOPERATION_SYNC
uint16_t uniffi_ironstorage_apple_checksum_method_mobilehomeoperation_sync(void
); );
#endif #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_EXPORT #ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_APPLE_CHECKSUM_METHOD_MOBILEKEYTRANSFER_EXPORT
File diff suppressed because it is too large. Load diff
+11 -3
View File
@@ -388,9 +388,11 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
case fetch case fetch
case pull case pull
case push case push
case sync
var changesLocalStore: Bool { var changesLocalStore: Bool {
if case .pull = self { return true } if case .pull = self { return true }
if case .sync = self { return true }
return false return false
} }
} }
@@ -577,7 +579,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
refreshControl?.endRefreshing() refreshControl?.endRefreshing()
return return
} }
runHome(.pull) runHome(.sync)
} else { } else {
reloadShell() reloadShell()
} }
@@ -750,7 +752,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
let refreshed = page.refreshedAt.map(formattedDate) let refreshed = page.refreshedAt.map(formattedDate)
switch page.freshness { switch page.freshness {
case .neverRefreshed: case .neverRefreshed:
return "Not refreshed. Pull down to update the local clone or tap Refresh Status." return "Not refreshed. Pull down to pull, then push your changes, or tap Refresh Status."
case .cached: case .cached:
return "Cached · Last refreshed \(refreshed ?? "at an unknown time"). This is not live status." return "Cached · Last refreshed \(refreshed ?? "at an unknown time"). This is not live status."
case .current: case .current:
@@ -822,6 +824,7 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
case .fetch: try operation.fetch() case .fetch: try operation.fetch()
case .pull: try operation.pull() case .pull: try operation.pull()
case .push: try operation.push() case .push: try operation.push()
case .sync: try operation.sync()
} }
return Result<MobileHomePage, HomeFailure>.success(page) return Result<MobileHomePage, HomeFailure>.success(page)
} catch let error as MobileHomeFfiError { } catch let error as MobileHomeFfiError {
@@ -865,11 +868,16 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
UIAccessibility.post(notification: .announcement, argument: notice.title) UIAccessibility.post(notification: .announcement, argument: notice.title)
} }
case let .failure(failure): case let .failure(failure):
if request == .sync {
// Pull may have updated entries before a later push failed.
NotificationCenter.default.post(name: .ironStorageLocalStoreDidChange, object: nil)
NotificationCenter.default.post(name: .ironStorageWatchSnapshotDidChange, object: nil)
}
tableView.reloadData() tableView.reloadData()
if homePage == nil { if homePage == nil {
showHomeFailure(failure) showHomeFailure(failure)
presentHomeFailure(failure, retry: request ?? .fetch) presentHomeFailure(failure, retry: request ?? .fetch)
} else if failure.kind == .interrupted { } else if failure.kind == .interrupted, request != .sync {
UIAccessibility.post(notification: .announcement, argument: failure.title) UIAccessibility.post(notification: .announcement, argument: failure.title)
} else { } else {
presentHomeFailure(failure, retry: request ?? .fetch) presentHomeFailure(failure, retry: request ?? .fetch)
+7 -1
View File
@@ -9,7 +9,7 @@ actually exercised.
| Area | Implemented boundary | Automated evidence | Manual release evidence | | Area | Implemented boundary | Automated evidence | Manual release evidence |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Home tab, local history, and optional remote activity | UIKit renders Rust `MobileHomePage` and typed Commit/Fetch/Pull/Push actions | `mobile_home` unit tests, `git_embedded`, and `crates/apple` bridge tests | Verify a clean local store first, then attach a disposable HTTPS remote and exercise refresh and all four actions, including expired application-token recovery | | Home tab, local history, and optional remote activity | UIKit renders Rust `MobileHomePage`; pulling down calls Rust sync (pull, then push only on success). Explicit Commit/Fetch/Pull/Push actions remain available. | `mobile_home` unit tests, `git_embedded` move/back and failed-pull regressions, and `crates/apple` bridge tests | Verify refresh and all four actions, including expired application-token recovery. Pull failures must show an error and prevent push. |
| Passwords tab, navigation, view, and edit | Rust supplies typed paths, fields, drafts, revisions, and mutations; UIKit presents them | `mobile_passwords`, `entry_documents`, `mobile_mutation`, and `mobile_authentication` tests | Open nested entries, edit/save/cancel, and confirm stale-edit recovery | | Passwords tab, navigation, view, and edit | Rust supplies typed paths, fields, drafts, revisions, and mutations; UIKit presents them | `mobile_passwords`, `entry_documents`, `mobile_mutation`, and `mobile_authentication` tests | Open nested entries, edit/save/cancel, and confirm stale-edit recovery |
| Swipe move/copy/delete | Rust plans destinations, collisions, dirty-editor handling, and commits; UIKit also exposes accessibility actions | `plans_destinations_collisions_hidden_paths_and_stale_revisions` and authentication serialization tests | Exercise swipe and VoiceOver alternatives; confirm destructive prompts | | Swipe move/copy/delete | Rust plans destinations, collisions, dirty-editor handling, and commits; UIKit also exposes accessibility actions | `plans_destinations_collisions_hidden_paths_and_stale_revisions` and authentication serialization tests | Exercise swipe and VoiceOver alternatives; confirm destructive prompts |
| TOTP tab and sharing | Rust discovers, caches, parses, selects, and generates OTP data | `mobile_totp`, `otp`, and `mobile_watch` integration tests | Discover entries, read a code, change Watch selection, and verify countdown | | TOTP tab and sharing | Rust discovers, caches, parses, selects, and generates OTP data | `mobile_totp`, `otp`, and `mobile_watch` integration tests | Discover entries, read a code, change Watch selection, and verify countdown |
@@ -88,6 +88,12 @@ the tester is ready to enter the passphrase again.
## Manual evidence still required ## Manual evidence still required
Issue #124 / version 1.0.1 build 8: iPhone and Watch simulator builds passed;
the existing iPhone simulator installation was updated in place and Home opened.
Automated Git tests cover move-and-move-back commits, network/authentication
failures, and merge conflicts without a subsequent push. Live account sync is
deferred to the user's TestFlight validation; no physical iPhone was updated.
Issue #120 was exercised on 2026-09-12 using the existing iPhone 17 Pro / iOS Issue #120 was exercised on 2026-09-12 using the existing iPhone 17 Pro / iOS
26.5 simulator (`FB5C6929-C10D-40C8-9A94-5CC592037FDB`) and its simulated store. 26.5 simulator (`FB5C6929-C10D-40C8-9A94-5CC592037FDB`) and its simulated store.
TOTP Add imported a synthetic QR into `otp/Simulator Panel OTP`; the normal TOTP Add imported a synthetic QR into `otp/Simulator Panel OTP`; the normal
+2 -2
View File
@@ -6,8 +6,8 @@ settings:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: MU22FMRGK8 DEVELOPMENT_TEAM: MU22FMRGK8
MARKETING_VERSION: "1.0" MARKETING_VERSION: "1.0.1"
CURRENT_PROJECT_VERSION: "7" CURRENT_PROJECT_VERSION: "8"
targets: targets:
IronStorage: IronStorage:
type: application type: application
+4
View File
@@ -482,6 +482,10 @@ impl MobileHomeOperation {
self.operation.push().map(Into::into).map_err(Into::into) self.operation.push().map(Into::into).map_err(Into::into)
} }
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeFfiError> {
self.operation.sync().map(Into::into).map_err(Into::into)
}
pub fn cancel(&self) { pub fn cancel(&self) {
self.operation.cancel(); self.operation.cancel();
} }
+29 -1
View File
@@ -1,4 +1,4 @@
//! Storage-owned remote activity and pull-to-refresh models for iPhone Home. //! Storage-owned remote activity and pull-then-push refresh for iPhone Home.
use std::{ use std::{
collections::BTreeSet, collections::BTreeSet,
@@ -412,6 +412,13 @@ impl MobileHomeOperation {
}) })
} }
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeError> {
// Hold the pull reservation across both steps: sync can replace local entries.
self.run(MobileHomeActionKind::Pull, || {
MobileHomeStorage::load()?.sync(unix_seconds()?, &self.control)
})
}
fn run( fn run(
&self, &self,
action: MobileHomeActionKind, action: MobileHomeActionKind,
@@ -555,6 +562,27 @@ impl MobileHomeStorage {
.map_err(MobileHomeError::after_push) .map_err(MobileHomeError::after_push)
} }
fn sync(
&self,
now: i64,
control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> {
let remote = self.remote()?;
let store = self.credentials()?;
let synced = self.git.sync_controlled(remote, &store, control);
let locked = store.lock();
let (_, pushed) = synced.map_err(MobileHomeError::from_git)?;
locked.map_err(MobileHomeError::partial_secret)?;
control
.report(GitProgressPhase::Refreshing)
.map_err(MobileHomeError::from_git)?;
let mut notice = push_notice(&pushed);
notice.title = "Sync Completed".to_owned();
notice.detail = format!("Pull completed successfully. {}", notice.detail);
self.current_page(now, Some(notice))
.map_err(MobileHomeError::after_push)
}
fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> { fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
let store = NativeSecretStore::system( let store = NativeSecretStore::system(
SecretCachePolicy::Disabled, SecretCachePolicy::Disabled,
+86 -2
View File
@@ -265,6 +265,7 @@ impl SecretProvider for SigningSecret {
#[derive(Default)] #[derive(Default)]
struct RecordingTransport { struct RecordingTransport {
request: Mutex<Vec<u8>>, request: Mutex<Vec<u8>>,
remote_tip: Option<String>,
} }
struct CloningFetch; struct CloningFetch;
@@ -391,7 +392,13 @@ impl GitSmartHttpTransport for RecordingTransport {
assert_eq!(credential.username(), "alice"); assert_eq!(credential.username(), "alice");
let mut output = packet(b"# service=git-receive-pack\n"); let mut output = packet(b"# service=git-receive-pack\n");
output.extend_from_slice(b"0000"); output.extend_from_slice(b"0000");
output.extend_from_slice(&packet(b"0000000000000000000000000000000000000000 capabilities^{}\0report-status delete-refs\n")); let reference = self.remote_tip.as_ref().map_or_else(
|| "0000000000000000000000000000000000000000 capabilities^{}".to_owned(),
|tip| format!("{tip} refs/heads/main"),
);
output.extend_from_slice(&packet(
format!("{reference}\0report-status delete-refs\n").as_bytes(),
));
output.extend_from_slice(b"0000"); output.extend_from_slice(b"0000");
Ok(output) Ok(output)
} }
@@ -444,6 +451,61 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
Ok(()) Ok(())
} }
#[test]
fn sync_pushes_move_and_move_back_even_when_the_final_tree_is_unchanged() -> TestResult {
let temporary = tempfile::tempdir()?;
let config = remote_config(&temporary)?;
let remote = &config.git_remotes()[0];
let store = Repository::open(config.vault())?;
let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", remote.url())?;
fs::write(config.vault().join("secret.gpg"), b"ciphertext")?;
let base = git.commit_all("Add secret")?;
set_remote_tracking(config.vault(), &base)?;
fs::rename(
config.vault().join("secret.gpg"),
config.vault().join("moved.gpg"),
)?;
git.commit_all("Move secret")?;
fs::rename(
config.vault().join("moved.gpg"),
config.vault().join("secret.gpg"),
)?;
let head = git.commit_all("Move secret back")?;
assert!(git.status()?.is_clean());
assert_eq!(git.divergence(remote, 10)?.remote().ahead(), 2);
let transport = RecordingTransport {
remote_tip: Some(base.clone()),
..Default::default()
};
let (pulled, pushed) = git.sync_with_transports_controlled(
remote,
&Credentials,
&NoopFetch,
&transport,
&GitOperationControl::default(),
)?;
assert_eq!(pulled, PullOutcome::UpToDate);
assert_eq!(pushed.new_id(), head);
let request = transport.request.lock().expect("request lock");
assert!(
request
.windows(base.len())
.any(|bytes| bytes == base.as_bytes())
);
let offset = request
.windows(4)
.position(|bytes| bytes == b"PACK")
.expect("pack");
// Two commits and the intermediate tree; the remote already has the final tree and blob.
assert_eq!(
u32::from_be_bytes(request[offset + 8..offset + 12].try_into()?),
3
);
assert_eq!(git.divergence(remote, 10)?.remote().ahead(), 0);
Ok(())
}
#[test] #[test]
fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract() -> TestResult { fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract() -> TestResult {
let temporary = tempfile::tempdir()?; let temporary = tempfile::tempdir()?;
@@ -469,6 +531,20 @@ fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract
assert!(phases.contains(&GitProgressPhase::Integrating)); assert!(phases.contains(&GitProgressPhase::Integrating));
assert!(phases.contains(&GitProgressPhase::Sending)); assert!(phases.contains(&GitProgressPhase::Sending));
assert!(!push.request.lock().expect("push request").is_empty()); assert!(!push.request.lock().expect("push request").is_empty());
for error in [GitError::NetworkUnavailable, GitError::AuthenticationFailed] {
let push = RecordingTransport::default();
assert_eq!(
git.sync_with_transports_controlled(
remote,
&Credentials,
&FailedFetch(error.clone()),
&push,
&GitOperationControl::default(),
),
Err(error),
);
assert!(push.request.lock().expect("push request").is_empty());
}
Ok(()) Ok(())
} }
@@ -524,9 +600,17 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
fs::write(config.vault().join("secret.gpg"), b"local")?; fs::write(config.vault().join("secret.gpg"), b"local")?;
git.stage(&["secret.gpg".into()])?; git.stage(&["secret.gpg".into()])?;
git.commit("Local change")?; git.commit("Local change")?;
let push = RecordingTransport::default();
let error = git let error = git
.pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch) .sync_with_transports_controlled(
remote,
&Credentials,
&NoopFetch,
&push,
&GitOperationControl::default(),
)
.expect_err("conflicting histories"); .expect_err("conflicting histories");
assert!(push.request.lock().expect("push request").is_empty());
let GitError::MergeConflicts { conflicts } = error else { let GitError::MergeConflicts { conflicts } = error else {
panic!("expected typed merge conflicts"); panic!("expected typed merge conflicts");
}; };