Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ Navop user-facing release notes. Generate and review each bilingual version entr

<!-- NAVOP_RELEASES -->

## [Unreleased]

#### 修复与优化

- 个人同步:修复引用了钥匙串凭据的连接在多设备间持续产生假冲突的问题。连接载荷里的凭据引用导出时保留了源设备的本地整数 ID,而每台设备的行号各不相同,导致同一条连接跨设备的 checksum 永不相等,被判定为「双方都改过」而反复冲突、来回覆盖。现在导出载荷统一只保留跨设备稳定的 `credential_cloud_id`,整数 ID 置零(下载时仍按云端 ID 映射回本机行号,行为不变)。
- 首页:从标签栏「+」打开的「打开连接」列表里点一次不再新建两个 SSH 会话。此前同一行既挂了行内的打开回调,又有列表内建的点击 → `confirm` 打开路径,一次点击先后走完两条路径(已有 2 个标签时点一次直接变成 4 个);现在点击与键盘回车统一只走 `confirm`,并补源码级回归测试守住「行渲染不得直接打开连接」(#461)。

---

#### Fixes and Improvements

- Personal sync: fixed constantly recurring false conflicts for connections that reference keychain credentials. The exported payload kept the source device's local integer credential ID, which differs on every device, so the same logical connection never produced an equal checksum across devices and was repeatedly flagged as "modified on both sides", overwriting back and forth. Exports now carry only the device-stable `credential_cloud_id` with the integer ID zeroed (downloads still map the cloud ID back to the local row, unchanged behavior).
- Home: clicking an entry in the “Open connection” list opened from the tab bar’s “+” no longer creates two SSH sessions. A row carried both its own open callback and the list’s built-in click → `confirm` open path, so a single click ran through both (going from 2 tabs to 4 in one click); clicks and the Enter key now share the single `confirm` path, guarded by a source-level regression test (#461).

## [v0.21.1] - 2026-10-22

#### 修复与优化
Expand Down
16 changes: 16 additions & 0 deletions crates/core/src/cloud_sync/personal/configured_store_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,20 @@ impl GitRunner for FlushRunner {
*self.checked_clean.lock().expect("checked clean lock") = true;
Ok(false)
}

async fn stash_sync_package(&self, _repo: &Path) -> Result<(), GitRunnerError> {
Ok(())
}

async fn stash_pop(&self, _repo: &Path) -> Result<(), GitRunnerError> {
Ok(())
}

async fn rebase_abort(&self, _repo: &Path) -> Result<(), GitRunnerError> {
Ok(())
}

async fn reset_sync_package(&self, _repo: &Path) -> Result<(), GitRunnerError> {
Ok(())
}
}
25 changes: 16 additions & 9 deletions crates/core/src/cloud_sync/personal/conflict_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,24 +90,31 @@ where
local: Option<&PersonalSyncItemSnapshot>,
) -> Result<(), SyncStoreError> {
let remote = required_remote_snapshot(conflict)?;
// `remote_snapshot` 是检测到冲突那一刻的存档:从挂冲突到用户点「应用」之间,
// 另一台设备可能又推了新版本。若仍拿快照里的旧版本做乐观锁,upsert 会以
// "stale version" 拒绝,冲突永远解不开(issue #457)。所以这里按 record_id
// 现场重读远端记录,拿到当前版本再写;远端记录已不存在时才回退快照版本。
let expected_version = match self
.store
.list_records(Some(&conflict.data_type), None)
.await?
.into_iter()
.find(|record| record.id == conflict.record_id)
{
Some(current) => Some(current.version),
None => Some(remote.version),
};
let Some(local) = local else {
// 本地条目已经不在了 ⇒「使用本地版本」= 保留本地的删除意图:把云端记录也
// 标记为删除。以前这里会直接报 "connection not found",用户点哪个按钮都出不来。
return self
.store
.tombstone_record(
&conflict.data_type,
&conflict.record_id,
Some(remote.version),
)
.tombstone_record(&conflict.data_type, &conflict.record_id, expected_version)
.await;
};
let mut record = self.local.export_item(local).await?;
record.id = conflict.record_id.clone();
let stored = self
.store
.upsert_record(&record, Some(remote.version))
.await?;
let stored = self.store.upsert_record(&record, expected_version).await?;
self.local
.mark_synced(&local.local_id, &stored.id, stored.updated_at / 1000)
.await
Expand Down
42 changes: 41 additions & 1 deletion crates/core/src/cloud_sync/personal/conflict_resolver_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,32 @@ async fn resolver_use_local_writes_expected_remote_version_and_clears_conflict()
assert!(repo.list("personal").expect("conflicts list").is_empty());
}

/// issue #457:从挂冲突到用户点「应用」之间,另一台设备可能又推了新版本。
/// resolver 必须按 record_id 现场重读远端版本,而不是拿冲突快照里的旧版本
/// 做乐观锁(否则 "stale version" 让冲突永远解不开)。
#[tokio::test]
async fn resolver_use_local_uses_current_remote_version_not_stale_snapshot() {
let repo = conflict_repo();
let local_item = local_item("local-1", "cloud-1", "local");
// 冲突快照里的远端版本是 7,但远端现在已经是版本 9(另一台设备推过)。
let stale_remote = test_record("cloud-1", data_type::CONNECTION, 7, "remote");
let current_remote = test_record("cloud-1", data_type::CONNECTION, 9, "remote-newer");
let conflict = stored_conflict(&local_item, &stale_remote);
repo.upsert(&conflict).expect("conflict stored");
let local = FakeLocalSource::with_items(vec![local_item]);
let store = FakeStore::default().with_remote_record(current_remote);
let resolver = PersonalSyncConflictResolver::new(store.clone(), local.clone(), repo.clone());

resolver
.resolve(&conflict, ConflictResolution::UseLocal)
.await
.expect("use local resolves against current remote version");

// 乐观锁用的是现场重读的版本 9,而不是快照里的 7。
assert_eq!(vec![Some(9)], store.expected_versions());
assert!(repo.list("personal").expect("conflicts list").is_empty());
}

#[tokio::test]
async fn resolver_keep_both_is_explicitly_unsupported_and_keeps_conflict() {
let repo = conflict_repo();
Expand Down Expand Up @@ -201,6 +227,8 @@ async fn resolver_use_cloud_only_clears_conflict_when_both_sides_are_gone() {
struct FakeStore {
expected_versions: Arc<Mutex<Vec<Option<u32>>>>,
tombstoned: Arc<Mutex<Vec<(String, String, Option<u32>)>>>,
/// 模拟远端当前记录(另一台设备可能已经推了比冲突快照新的版本)。
remote_records: Arc<Mutex<Vec<CloudSyncData>>>,
}

impl FakeStore {
Expand All @@ -214,6 +242,14 @@ impl FakeStore {
fn tombstoned(&self) -> Vec<(String, String, Option<u32>)> {
self.tombstoned.lock().expect("tombstoned lock").clone()
}

fn with_remote_record(self, record: CloudSyncData) -> Self {
self.remote_records
.lock()
.expect("remote records lock")
.push(record);
self
}
}

#[async_trait]
Expand All @@ -231,7 +267,11 @@ impl PersonalSyncStore for FakeStore {
_data_type: Option<&str>,
_since: Option<i64>,
) -> Result<Vec<CloudSyncData>, SyncStoreError> {
Ok(Vec::new())
Ok(self
.remote_records
.lock()
.expect("remote records lock")
.clone())
}

async fn upsert_record(
Expand Down
118 changes: 114 additions & 4 deletions crates/core/src/cloud_sync/personal/git_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ pub trait GitRunner: Clone + Send + Sync {
async fn remote_url(&self, repo: &Path) -> Result<Option<String>, GitRunnerError>;
async fn is_repo(&self, repo: &Path) -> Result<bool, GitRunnerError>;
async fn is_clean_for_sync(&self, repo: &Path) -> Result<bool, GitRunnerError>;
/// 把同步包目录的未提交改动暂存进 git stash(issue #457 的脏工作区自愈)。
async fn stash_sync_package(&self, repo: &Path) -> Result<(), GitRunnerError>;
/// 恢复最近一次暂存的同步包改动。
async fn stash_pop(&self, repo: &Path) -> Result<(), GitRunnerError>;
/// 中止进行中的 rebase;没有 rebase 在进行时失败是预期内的。
async fn rebase_abort(&self, repo: &Path) -> Result<(), GitRunnerError>;
/// 把同步包目录重置回 HEAD(丢弃未提交改动,含 unmerged 状态)。
async fn reset_sync_package(&self, repo: &Path) -> Result<(), GitRunnerError>;
}

#[derive(Debug, Clone, Default)]
Expand Down Expand Up @@ -79,6 +87,33 @@ impl GitRunner for CommandGitRunner {
let output = run_git(repo, &["status", "--porcelain", "--", ".onetcli-sync"])?;
Ok(output.trim().is_empty())
}

async fn stash_sync_package(&self, repo: &Path) -> Result<(), GitRunnerError> {
run_git(
repo,
&[
"stash",
"push",
"-m",
"onetcli-sync-self-heal",
"--",
".onetcli-sync",
],
)
.map(|_| ())
}

async fn stash_pop(&self, repo: &Path) -> Result<(), GitRunnerError> {
run_git(repo, &["stash", "pop"]).map(|_| ())
}

async fn rebase_abort(&self, repo: &Path) -> Result<(), GitRunnerError> {
run_git(repo, &["rebase", "--abort"]).map(|_| ())
}

async fn reset_sync_package(&self, repo: &Path) -> Result<(), GitRunnerError> {
run_git(repo, &["checkout", "--", ".onetcli-sync"]).map(|_| ())
}
}

#[derive(Clone)]
Expand Down Expand Up @@ -110,15 +145,90 @@ where
}

self.runner.add_sync_package(&self.root).await?;
self.runner
match self
.runner
.commit_sync_package(&self.root, SYNC_COMMIT_MESSAGE)
.await?;
.await
{
Err(GitRunnerError::NothingToCommit) => Ok(()),
result => result,
}?;
if self.options.auto_push {
self.runner.push(&self.root).await?;
self.push_with_self_heal().await?;
}
Ok(())
}

/// 带自愈的 push:被拒(远端有新提交)时先 pull --rebase 再重试一次。
async fn push_with_self_heal(&self) -> Result<(), SyncStoreError> {
match self.runner.push(&self.root).await {
Ok(()) => Ok(()),
Err(GitRunnerError::MergeConflict) | Err(GitRunnerError::CommandFailed(_)) => {
tracing::warn!("git push rejected; pulling with rebase before retrying");
self.pull_with_self_heal().await?;
self.runner
.push(&self.root)
.await
.map_err(SyncStoreError::from)
}
Err(error) => Err(error.into()),
}
}

/// 带自愈的 pull --rebase(issue #457):
/// - 脏工作区(上一轮有冲突、flush 被跳过留下未提交改动)会让 pull 直接拒绝;
/// 先 stash 再 pull,成功后 pop 回来,恢复失败也不阻塞同步。
/// - rebase 冲突会让仓库卡死在 unmerged 状态;先 abort 再重试一次,
/// 仍冲突则如实上抛,交给上层挂冲突。
async fn pull_with_self_heal(&self) -> Result<(), SyncStoreError> {
match self.runner.pull_rebase(&self.root).await {
Ok(()) => return Ok(()),
Err(GitRunnerError::MergeConflict) => {
// 仓库可能卡在上一轮 rebase 冲突:abort 后重试一次。
let _ = self.runner.rebase_abort(&self.root).await;
match self.runner.pull_rebase(&self.root).await {
Ok(()) => Ok(()),
Err(GitRunnerError::MergeConflict) => {
// 重试仍冲突:先退出 rebase 状态避免仓库卡死,再如实上抛。
let _ = self.runner.rebase_abort(&self.root).await;
Err(SyncStoreError::GitMergeConflict)
}
Err(error) => Err(error.into()),
}
}
Err(GitRunnerError::CommandFailed(_)) => {
// 典型场景:上一轮 pass 有冲突提前结束、flush 被跳过,工作区
// 留着未提交的 .onetcli-sync 改动,git 拒绝 pull --rebase。
tracing::warn!(
"git pull --rebase rejected by dirty worktree; stashing sync package"
);
if self.runner.stash_sync_package(&self.root).await.is_err() {
// stash 失败通常意味着 unmerged 残留:把同步包目录重置后再试。
// 本地数据库仍是数据源头,重置后 worker 会重新上传。
let _ = self.runner.reset_sync_package(&self.root).await;
}
match self.runner.pull_rebase(&self.root).await {
Ok(()) => {
if let Err(error) = self.runner.stash_pop(&self.root).await {
// stash pop 失败不阻塞同步:改动还在 stash 里,下一轮
// flush 会把整个同步包重新提交上去。
tracing::warn!(error = ?error, "git stash pop failed after pull");
}
Ok(())
}
Err(error) => {
// pull 本身失败:尝试把 stash 恢复回去,别把用户的改动弄丢。
if let Err(pop_error) = self.runner.stash_pop(&self.root).await {
tracing::warn!(error = ?pop_error, "git stash pop failed after failed pull");
}
Err(error.into())
}
}
}
Err(error) => return Err(error.into()),
}
}

fn mark_dirty(&self) -> Result<(), SyncStoreError> {
*self
.dirty
Expand Down Expand Up @@ -151,7 +261,7 @@ where
if !self.runner.is_repo(&self.root).await? {
return Err(SyncStoreError::NotConfigured);
}
self.runner.pull_rebase(&self.root).await?;
self.pull_with_self_heal().await?;
self.directory.probe().await
}

Expand Down
Loading
Loading