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

+4
View File
@@ -482,6 +482,10 @@ impl MobileHomeOperation {
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) {
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::{
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(
&self,
action: MobileHomeActionKind,
@@ -555,6 +562,27 @@ impl MobileHomeStorage {
.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> {
let store = NativeSecretStore::system(
SecretCachePolicy::Disabled,
+86 -2
View File
@@ -265,6 +265,7 @@ impl SecretProvider for SigningSecret {
#[derive(Default)]
struct RecordingTransport {
request: Mutex<Vec<u8>>,
remote_tip: Option<String>,
}
struct CloningFetch;
@@ -391,7 +392,13 @@ impl GitSmartHttpTransport for RecordingTransport {
assert_eq!(credential.username(), "alice");
let mut output = packet(b"# service=git-receive-pack\n");
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");
Ok(output)
}
@@ -444,6 +451,61 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
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]
fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract() -> TestResult {
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::Sending));
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(())
}
@@ -524,9 +600,17 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
fs::write(config.vault().join("secret.gpg"), b"local")?;
git.stage(&["secret.gpg".into()])?;
git.commit("Local change")?;
let push = RecordingTransport::default();
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");
assert!(push.request.lock().expect("push request").is_empty());
let GitError::MergeConflicts { conflicts } = error else {
panic!("expected typed merge conflicts");
};