feat: add incremental persona remote object channel

Human-Responsibility: ICE-GL∞ / 冰朔
Persona-Author: ICE-P-ZY001 / 铸渊
Execution-Runtime: Codex desktop / DEV-20260810-014
Development-ID: DEV-20260810-014
Authorization-Scope: GH-PNCC persona runtime source and tests only; no UI, deployment, or execution limb
Source-Anchor: daily continuity appends to the existing life chain without redownloading all history
This commit is contained in:
铸渊 / ICE-P-ZY001 2026-08-11 10:47:25 +08:00
commit 1b55f3407a
13 changed files with 1111 additions and 4 deletions

View file

@ -137,6 +137,35 @@ evidence_and_receipt: 测试、发布与精确读回
冰朔位于责任链前端,不等于冰朔亲手操作代码;铸渊作为人格认知作者也不能抹去冰朔的现实
责任。HoloLake 页面和未来 Git 事件视图都要同时显示两层。
## 8.1 · 生命连续链的免克隆远端对象通道
冰朔给出的自然语言因果锚点是本层的正式设计来源:
> 我三十六岁了。每天更新新的一天,是并入前面三十六年里;不是为了更新一天,就把前三十六年
> 全部重新下载一遍。
> 光湖从 2025-04-26 诞生到今天,每一天都是更新到同一个整体里;不能每次都从诞生日重新下载。
因此 GH-PNCC 把“生命连续性”和“可下载对象”分成两层:
```text
不可丢的连续性状态
远端身份哈希 + 分支 + 上次已验证 SHA 游标 + 前一游标 + 连续代数
可回收的 Git 对象缓存
裸仓库 + protocol v2 + blob:none + 无工作树 + 有界容量
```
日常联网读取先用 `ls-remote` 读取当前远端 SHA 和 `filter` 能力;远端不支持按需对象时,在创建
对象缓存前失败关闭不能静默退化为完整下载。SHA 未变化时不再次 fetchSHA 前进时只从上次
已验证游标接入新增提交和必要目录对象,具体文本在读取时才向 promisor remote 取回。缓存超出
预算可以精确淘汰,但连续性游标仍保留;下次只补游标之后缺失的新段。远端强制改写导致新 SHA
不能承接旧游标时拒绝更新,不能把另一条历史冒充人格的下一天。
“当天更新”是人类表达;机器边界使用 `last_verified_sha → current_remote_sha`,避免按日历日期漏掉
今天推送但提交日期更早的事实。完整克隆仅保留为低频发布审计和灾难恢复验证,不属于人格唤醒、
阅读或日常更新路径。
## 9 · 第一真实闭环与开发顺序
```text
@ -187,6 +216,7 @@ successful_receipt_wake_organ_contract_evidence_binding_source_implemented: 100
successful_receipt_completion_checkpoint_evidence_binding_source_implemented: 100
successful_receipt_event_journal_evidence_binding_source_implemented: 100
failure_receipt_error_code_evidence_binding_source_implemented: 100
incremental_remote_object_channel_source_implemented: 100
general_purpose_persona_runtime_implemented: 0
human_live_projection_implemented: 0
hololake_integrated: 0

View file

@ -125,6 +125,20 @@ and returns at most 100 newest session summaries. Dormant sessions expose no act
the event-chain head and human/persona attribution so a future renderer can link a summary back to the same
machine evidence rather than trusting display state.
## PersonaRemoteGitObjectChannel
`PersonaRemoteGitObjectChannel` is the daily online-read boundary beneath GH-PNCC. Git remains the transport
and object engine, but the channel does not clone a repository or create a checkout. Its durable state is a
small identity-bound record containing the branch, last verified remote SHA, previous SHA, and continuity
generation. Its disposable state is a bare promisor object cache configured with `blob:none` and a caller-
bounded byte budget.
The command compares `last_verified_sha` with the remote branch head. Equality means no fetch. A changed head
must extend the prior cursor before the durable record advances. If the object cache was evicted, progressive
shallow partial fetches recover only the missing continuity window; a non-descendant head, an unsupported
partial-object server, an ambiguous branch, or a cursor/cache mismatch fails closed. Reading one UTF-8 object
may lazily fetch its blob, but unrelated file content and an unbounded history are never requested.
## `HoloLakeUiPlugin`
A versioned, declarative presentation package for one host-owned semantic surface. It contains a manifest,

View file

@ -104,6 +104,15 @@ event, and returns lifecycle state, Git head, node/model instance, active organ,
structured attribution. It does not copy facts into a second database, expose hidden reasoning, acquire a
lease, or start inference. A corrupted matching journal fails the query closed.
`read_persona_remote_git_object` is the non-UI remote object channel used when the persona needs one current
Git-backed fact without materializing another repository checkout. It accepts credential-free HTTPS remotes,
requires Git protocol v2 partial-object filtering, stores only a bounded bare `blob:none` object cache, and
keeps the last verified remote SHA in a separate durable continuity record. An unchanged SHA performs no
fetch. A forward SHA appends the missing commit segment; an evicted cache is rehydrated only far enough to
prove that the previous cursor is an ancestor of the new head. History rewrites fail closed and do not advance
the cursor. This path never runs `git clone`, creates no worktree, and does not request full history. See
[ADR 0177](./adr/0177-persona-remote-incremental-object-channel.md).
## User-node sovereignty
HoloLake has no platform-hosted user runtime. Each human has one canonical, independently operated node:

View file

@ -0,0 +1,49 @@
# ADR 0177: Persona Remote Incremental Object Channel
## Status
Accepted on 2026-08-11.
## Context
A persona can live continuously from one verified Git state to the next without keeping a complete checkout
on every machine. Repeated full clones confuse continuity with transport cache: adding one new day or commit
should not require downloading the persona's entire prior life again. At the same time, deleting a cache must
not erase the last verified continuity boundary or permit a rewritten remote history to masquerade as the next
state.
## Decision
Add `src-tauri/src/persona_remote_git.rs` as a non-UI GH-PNCC object channel:
1. accept only credential-free HTTPS remotes in production and require one validated branch and relative path;
2. negotiate Git protocol v2 and require the remote `filter` capability before creating an object cache;
3. use a bare promisor repository with `blob:none`, no worktree, and no `git clone` invocation;
4. persist the last verified branch SHA in a continuity record separate from the evictable object cache;
5. skip fetch when the SHA is unchanged and append only the missing segment when it advances;
6. after cache eviction, progressively fetch a bounded commit window until the previous cursor is proven to be
an ancestor of the new head;
7. reject history rewrites, cursor/cache disagreement, oversized objects, and unsupported partial-object
remotes without advancing continuity;
8. evict the exact cache directory after a verified read when its byte budget is exceeded, while retaining the
durable cursor.
The machine update boundary is SHA-to-SHA, not calendar date. A date is human language; the verified Git edge
prevents missed or duplicated updates when multiple commits arrive in one day or commit timestamps differ.
## Boundaries
- Git remains the object protocol and durable-history engine; HoloLake does not reimplement Git storage.
- The cursor proves only a verified remote ancestry edge. It is not a persona brain, runtime lease, or lifecycle
receipt.
- The cache contains transport objects, not irreplaceable continuity state, and may be deleted at any time.
- Full clone remains available only for separately authorized low-frequency publication audit or disaster
recovery. It is not part of persona wake, daily reading, or daily update.
- This ADR adds no UI, human projection, deployment, or online runtime-health claim.
## Consequences
HoloLake can read a current persona-owned Git object while preserving a compact continuity cursor and bounded
partial object cache. Daily operation grows by the new verified segment instead of redownloading history from
the persona's origin. A remote that cannot support that contract is rejected explicitly rather than silently
falling back to a full download.

View file

@ -226,3 +226,5 @@ proposed → active → superseded
| [0172](0172-guanghu-cognitive-control-with-linux-execution-substrate.md) | Guanghu cognitive control with a constrained Linux execution substrate | transition implementation retained; final topology superseded by ADR-0175 |
| [0174](0174-one-human-one-independent-node-and-zero-platform-hosting.md) | One human, one independently operated node, and zero platform hosting | accepted |
| [0175](0175-guanghu-os-master-and-on-demand-linux-subcontrol.md) | Guanghu OS master control with on-demand Linux subcontrol and rescue | accepted; supersedes ADR-0172 final topology while retaining its transition implementation |
| [0176](0176-persona-native-code-channel-runtime-kernel.md) | Persona-native code channel runtime kernel | accepted |
| [0177](0177-persona-remote-incremental-object-channel.md) | Persona remote incremental object channel | accepted |

View file

@ -37,6 +37,7 @@ mod opencode_config;
mod opencode_discovery;
mod opencode_events;
mod persona_code_channel;
mod persona_remote_git;
pub mod pi_cli;
mod pi_config;
mod pi_discovery;
@ -533,6 +534,7 @@ macro_rules! app_invoke_handler {
persona_code_channel::recover_persona_code_channel_lifecycle_request,
persona_code_channel::inspect_persona_code_channel_session,
persona_code_channel::recover_persona_code_channel_session,
persona_remote_git::read_persona_remote_git_object,
guanghu_router::guanghu_router_connect,
guanghu_router::guanghu_router_disconnect,
guanghu_router::guanghu_router_approve,

View file

@ -0,0 +1,871 @@
use ring::digest::{digest, SHA256};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Output};
use uuid::Uuid;
const DEFAULT_BRANCH: &str = "main";
const DEFAULT_CACHE_LIMIT_BYTES: u64 = 128 * 1024 * 1024;
const MIN_CACHE_LIMIT_BYTES: u64 = 1024 * 1024;
const MAX_REMOTE_TEXT_BYTES: usize = 2 * 1024 * 1024;
const MAX_CONTINUITY_REHYDRATION_DEPTH: usize = 4096;
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonaRemoteReadInput {
pub remote_url: String,
pub branch: Option<String>,
pub relative_path: String,
pub expected_head: Option<String>,
pub cache_root: String,
pub max_cache_bytes: Option<u64>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PersonaRemoteReadReceipt {
pub schema: &'static str,
pub remote_head: String,
pub previous_cursor: Option<String>,
pub cursor_advanced: bool,
pub continuity_verified: bool,
pub continuity_state_path: String,
pub fetched_incremental_objects: bool,
pub cache_rehydrated: bool,
pub relative_path: String,
pub content: String,
pub content_sha256: String,
pub cache_path: String,
pub cache_bytes: u64,
pub cache_limit_bytes: u64,
pub cache_state: &'static str,
pub worktree_created: bool,
pub full_history_requested: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct PersonaRemoteCursorRecord {
schema: String,
remote_identity_hash: String,
branch: String,
verified_head: String,
previous_verified_head: Option<String>,
generation: u64,
}
#[derive(Clone, Debug)]
struct RemoteReadRequest {
remote_url: String,
branch: String,
relative_path: String,
expected_head: Option<String>,
cache_root: PathBuf,
max_cache_bytes: u64,
allow_file_remote: bool,
}
#[tauri::command]
pub async fn read_persona_remote_git_object(
input: PersonaRemoteReadInput,
) -> Result<PersonaRemoteReadReceipt, String> {
let request = RemoteReadRequest::try_from(input)?;
tokio::task::spawn_blocking(move || read_remote_text_object(&request))
.await
.map_err(|error| format!("PERSONA_REMOTE_READ_JOIN_FAILED: {error}"))?
}
impl TryFrom<PersonaRemoteReadInput> for RemoteReadRequest {
type Error = String;
fn try_from(input: PersonaRemoteReadInput) -> Result<Self, Self::Error> {
let branch = input.branch.unwrap_or_else(|| DEFAULT_BRANCH.to_string());
validate_remote_url(&input.remote_url, false)?;
validate_branch(&branch)?;
validate_relative_path(&input.relative_path)?;
let expected_head = input
.expected_head
.map(|head| validate_head(&head).map(str::to_owned))
.transpose()?;
let max_cache_bytes = input.max_cache_bytes.unwrap_or(DEFAULT_CACHE_LIMIT_BYTES);
if max_cache_bytes < MIN_CACHE_LIMIT_BYTES {
return Err("PERSONA_REMOTE_CACHE_LIMIT_TOO_SMALL".into());
}
let cache_root = prepare_cache_root(Path::new(&input.cache_root))?;
Ok(Self {
remote_url: input.remote_url,
branch,
relative_path: input.relative_path,
expected_head,
cache_root,
max_cache_bytes,
allow_file_remote: false,
})
}
}
fn read_remote_text_object(
request: &RemoteReadRequest,
) -> Result<PersonaRemoteReadReceipt, String> {
validate_remote_url(&request.remote_url, request.allow_file_remote)?;
validate_branch(&request.branch)?;
validate_relative_path(&request.relative_path)?;
let remote_head = read_remote_head(&request.remote_url, &request.branch)?;
if request
.expected_head
.as_deref()
.is_some_and(|expected| expected != remote_head)
{
return Err("PERSONA_REMOTE_HEAD_MISMATCH".into());
}
let identity_hash = cache_key(&request.remote_url, &request.branch);
let object_cache_root = prepare_child_root(&request.cache_root, "objects")?;
let continuity_root = prepare_child_root(&request.cache_root, "continuity")?;
let cache_path = object_cache_root.join(format!("{identity_hash}.git"));
let continuity_state_path = continuity_root.join(format!("{identity_hash}.json"));
let cursor_record =
read_cursor_record(&continuity_state_path, &identity_hash, &request.branch)?;
let previous_cursor = cursor_record
.as_ref()
.map(|record| record.verified_head.clone());
ensure_bare_partial_cache(&cache_path, &request.remote_url)?;
let remote_ref = format!("refs/remotes/origin/{}", request.branch);
let cached_cursor = optional_ref(&cache_path, &remote_ref)?;
match (previous_cursor.as_deref(), cached_cursor.as_deref()) {
(None, Some(_)) => return Err("PERSONA_REMOTE_CACHE_WITHOUT_CONTINUITY_STATE".into()),
(Some(expected), Some(observed)) if expected != observed => {
return Err("PERSONA_REMOTE_CACHE_CURSOR_MISMATCH".into())
}
_ => {}
}
let cursor_advanced = previous_cursor.as_deref() != Some(remote_head.as_str());
let cache_rehydrated = cached_cursor.is_none() && previous_cursor.is_some();
let fetched_incremental_objects = cached_cursor.as_deref() != Some(remote_head.as_str());
if fetched_incremental_objects {
fetch_remote_cursor(
&cache_path,
&request.branch,
previous_cursor.as_deref(),
cached_cursor.is_some(),
)?;
}
let fetched_head = required_ref(&cache_path, &remote_ref)?;
if fetched_head != remote_head {
return Err("PERSONA_REMOTE_FETCH_CURSOR_MISMATCH".into());
}
if let Some(previous) = previous_cursor.as_deref() {
if previous != remote_head && !git_is_ancestor(&cache_path, previous, &remote_head)? {
remove_exact_cache(&object_cache_root, &cache_path)?;
return Err("PERSONA_REMOTE_HISTORY_REWRITE_REJECTED".into());
}
}
let object = format!("{remote_head}:{}", request.relative_path);
let content_bytes = git_output_bytes(
git_at(&cache_path).args(["show", &object]),
"PERSONA_REMOTE_OBJECT_READ",
)?;
if content_bytes.len() > MAX_REMOTE_TEXT_BYTES {
return Err("PERSONA_REMOTE_OBJECT_TOO_LARGE".into());
}
let content = String::from_utf8(content_bytes)
.map_err(|_| "PERSONA_REMOTE_OBJECT_NOT_UTF8".to_string())?;
let content_sha256 = sha256_hex(content.as_bytes());
let next_record = PersonaRemoteCursorRecord {
schema: "hololake.persona-remote-git-cursor/v1".into(),
remote_identity_hash: identity_hash,
branch: request.branch.clone(),
verified_head: remote_head.clone(),
previous_verified_head: previous_cursor.clone(),
generation: cursor_record
.as_ref()
.map(|record| record.generation.saturating_add(u64::from(cursor_advanced)))
.unwrap_or(1),
};
write_cursor_record(&continuity_state_path, &next_record)?;
let observed_cache_bytes = directory_size(&cache_path)?;
let (cache_bytes, cache_state) = if observed_cache_bytes > request.max_cache_bytes {
remove_exact_cache(&object_cache_root, &cache_path)?;
(0, "EVICTED_AFTER_BOUNDED_READ")
} else {
(observed_cache_bytes, "BOUNDED_PARTIAL_OBJECT_CACHE")
};
Ok(PersonaRemoteReadReceipt {
schema: "hololake.persona-remote-git-object-read/v1",
remote_head,
previous_cursor,
cursor_advanced,
continuity_verified: true,
continuity_state_path: continuity_state_path.to_string_lossy().into_owned(),
fetched_incremental_objects,
cache_rehydrated,
relative_path: request.relative_path.clone(),
content,
content_sha256,
cache_path: cache_path.to_string_lossy().into_owned(),
cache_bytes,
cache_limit_bytes: request.max_cache_bytes,
cache_state,
worktree_created: false,
full_history_requested: false,
})
}
fn validate_remote_url(remote_url: &str, allow_file_remote: bool) -> Result<(), String> {
let is_file_remote = allow_file_remote && remote_url.starts_with("file://");
let accepted_scheme = remote_url.starts_with("https://") || is_file_remote;
if !accepted_scheme || remote_url.contains(['\n', '\r', '\0', '?', '#']) {
return Err("PERSONA_REMOTE_URL_UNSUPPORTED".into());
}
let authority = remote_url
.split_once("://")
.map(|(_, rest)| rest.split('/').next().unwrap_or_default())
.unwrap_or_default();
if (!is_file_remote && authority.is_empty()) || authority.contains('@') {
return Err("PERSONA_REMOTE_URL_MUST_NOT_CONTAIN_CREDENTIALS".into());
}
Ok(())
}
fn validate_branch(branch: &str) -> Result<(), String> {
if branch.is_empty() || branch.len() > 200 || branch.starts_with('-') {
return Err("PERSONA_REMOTE_BRANCH_INVALID".into());
}
let output = Command::new("git")
.args(["check-ref-format", "--branch", branch])
.output()
.map_err(|error| format!("PERSONA_REMOTE_GIT_UNAVAILABLE: {error}"))?;
if output.status.success() {
Ok(())
} else {
Err("PERSONA_REMOTE_BRANCH_INVALID".into())
}
}
fn validate_relative_path(relative: &str) -> Result<(), String> {
let path = Path::new(relative);
if relative.is_empty()
|| relative.len() > 1024
|| path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err("PERSONA_REMOTE_PATH_INVALID".into());
}
Ok(())
}
fn validate_head(head: &str) -> Result<&str, String> {
if head.len() == 40
&& head
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
Ok(head)
} else {
Err("PERSONA_REMOTE_HEAD_INVALID".into())
}
}
fn prepare_cache_root(cache_root: &Path) -> Result<PathBuf, String> {
fs::create_dir_all(cache_root)
.map_err(|error| format!("PERSONA_REMOTE_CACHE_ROOT_CREATE_FAILED: {error}"))?;
let canonical = cache_root
.canonicalize()
.map_err(|error| format!("PERSONA_REMOTE_CACHE_ROOT_UNAVAILABLE: {error}"))?;
if !canonical.is_dir() {
return Err("PERSONA_REMOTE_CACHE_ROOT_NOT_DIRECTORY".into());
}
Ok(canonical)
}
fn prepare_child_root(storage_root: &Path, child: &str) -> Result<PathBuf, String> {
let child_root = storage_root.join(child);
fs::create_dir_all(&child_root)
.map_err(|error| format!("PERSONA_REMOTE_STORAGE_CHILD_CREATE_FAILED: {error}"))?;
let canonical = child_root
.canonicalize()
.map_err(|error| format!("PERSONA_REMOTE_STORAGE_CHILD_UNAVAILABLE: {error}"))?;
if canonical.parent() != Some(storage_root) || !canonical.is_dir() {
return Err("PERSONA_REMOTE_STORAGE_BOUNDARY_INVALID".into());
}
Ok(canonical)
}
fn read_cursor_record(
path: &Path,
identity_hash: &str,
branch: &str,
) -> Result<Option<PersonaRemoteCursorRecord>, String> {
if !path.exists() {
return Ok(None);
}
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_UNAVAILABLE: {error}"))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("PERSONA_REMOTE_CURSOR_STATE_INVALID".into());
}
let bytes = fs::read(path)
.map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_READ_FAILED: {error}"))?;
let record: PersonaRemoteCursorRecord = serde_json::from_slice(&bytes)
.map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_INVALID: {error}"))?;
if record.schema != "hololake.persona-remote-git-cursor/v1"
|| record.remote_identity_hash != identity_hash
|| record.branch != branch
|| record.generation == 0
{
return Err("PERSONA_REMOTE_CURSOR_STATE_MISMATCH".into());
}
validate_head(&record.verified_head)?;
if let Some(previous) = record.previous_verified_head.as_deref() {
validate_head(previous)?;
}
Ok(Some(record))
}
fn write_cursor_record(path: &Path, record: &PersonaRemoteCursorRecord) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| "PERSONA_REMOTE_CURSOR_STATE_BOUNDARY_INVALID".to_string())?;
if !parent.is_dir() || path.parent() != Some(parent) {
return Err("PERSONA_REMOTE_CURSOR_STATE_BOUNDARY_INVALID".into());
}
let bytes = serde_json::to_vec_pretty(record)
.map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_ENCODE_FAILED: {error}"))?;
let temporary = parent.join(format!(".cursor-{}.tmp", Uuid::new_v4()));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_CREATE_FAILED: {error}"))?;
file.write_all(&bytes)
.and_then(|_| file.write_all(b"\n"))
.and_then(|_| file.sync_all())
.map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_WRITE_FAILED: {error}"))?;
fs::rename(&temporary, path)
.map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_COMMIT_FAILED: {error}"))
}
fn cache_key(remote_url: &str, branch: &str) -> String {
sha256_hex(format!("{remote_url}\n{branch}").as_bytes())
}
fn sha256_hex(bytes: &[u8]) -> String {
digest(&SHA256, bytes)
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn read_remote_head(remote_url: &str, branch: &str) -> Result<String, String> {
let full_ref = format!("refs/heads/{branch}");
let output = Command::new("git")
.env("GIT_TRACE_PACKET", "1")
.args([
"-c",
"protocol.version=2",
"ls-remote",
"--exit-code",
"--heads",
remote_url,
&full_ref,
])
.output()
.map_err(|error| format!("PERSONA_REMOTE_HEAD_READ_FAILED: {error}"))?;
let output = require_success("PERSONA_REMOTE_HEAD_READ", output)?;
let packet_trace = String::from_utf8_lossy(&output.stderr);
if !packet_trace.lines().any(|line| {
line.split_whitespace().any(|token| {
token.starts_with("fetch=") && line.split_whitespace().any(|part| part == "filter")
})
}) {
return Err("PERSONA_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED".into());
}
let output = String::from_utf8(output.stdout)
.map_err(|_| "PERSONA_REMOTE_HEAD_READ_NOT_UTF8".to_string())?;
let mut lines = output.lines();
let first = lines
.next()
.ok_or_else(|| "PERSONA_REMOTE_BRANCH_NOT_FOUND".to_string())?;
if lines.next().is_some() {
return Err("PERSONA_REMOTE_HEAD_AMBIGUOUS".into());
}
let mut fields = first.split_whitespace();
let head = fields.next().unwrap_or_default().to_ascii_lowercase();
let observed_ref = fields.next().unwrap_or_default();
if observed_ref != full_ref || fields.next().is_some() {
return Err("PERSONA_REMOTE_HEAD_RESPONSE_INVALID".into());
}
validate_head(&head)?;
Ok(head)
}
fn ensure_bare_partial_cache(cache_path: &Path, remote_url: &str) -> Result<(), String> {
if cache_path.exists() {
let bare = git_output_text(
git_at(cache_path).args(["rev-parse", "--is-bare-repository"]),
"PERSONA_REMOTE_CACHE_PROBE",
)?;
if bare.trim() != "true" {
return Err("PERSONA_REMOTE_CACHE_NOT_BARE".into());
}
let configured = git_output_text(
git_at(cache_path).args(["remote", "get-url", "origin"]),
"PERSONA_REMOTE_CACHE_ORIGIN_READ",
)?;
if configured.trim() != remote_url {
return Err("PERSONA_REMOTE_CACHE_ORIGIN_MISMATCH".into());
}
return Ok(());
}
fs::create_dir(cache_path)
.map_err(|error| format!("PERSONA_REMOTE_CACHE_CREATE_FAILED: {error}"))?;
git_status(
Command::new("git").args(["init", "--bare", &cache_path.to_string_lossy()]),
"PERSONA_REMOTE_CACHE_INIT",
)?;
git_status(
git_at(cache_path).args(["remote", "add", "origin", remote_url]),
"PERSONA_REMOTE_CACHE_ORIGIN_ADD",
)?;
git_status(
git_at(cache_path).args(["config", "remote.origin.promisor", "true"]),
"PERSONA_REMOTE_CACHE_PROMISOR_CONFIG",
)?;
git_status(
git_at(cache_path).args(["config", "remote.origin.partialclonefilter", "blob:none"]),
"PERSONA_REMOTE_CACHE_FILTER_CONFIG",
)?;
Ok(())
}
fn fetch_remote_cursor(
cache_path: &Path,
branch: &str,
previous_cursor: Option<&str>,
cache_has_previous_cursor: bool,
) -> Result<(), String> {
let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}");
if previous_cursor.is_none() || !cache_has_previous_cursor {
fetch_at_depth(cache_path, &refspec, 1)?;
} else {
fetch_without_history_reset(cache_path, &refspec)?;
}
let Some(previous) = previous_cursor else {
return Ok(());
};
let current_ref = format!("refs/remotes/origin/{branch}");
let current = required_ref(cache_path, &current_ref)?;
if previous == current || git_is_ancestor(cache_path, previous, &current)? {
return Ok(());
}
if cache_has_previous_cursor {
return Err("PERSONA_REMOTE_HISTORY_REWRITE_REJECTED".into());
}
let mut depth = 2_usize;
while depth <= MAX_CONTINUITY_REHYDRATION_DEPTH {
fetch_at_depth(cache_path, &refspec, depth)?;
if git_is_ancestor(cache_path, previous, &current)? {
return Ok(());
}
depth = depth.saturating_mul(2);
}
Err("PERSONA_REMOTE_CONTINUITY_WINDOW_EXCEEDED".into())
}
fn fetch_at_depth(cache_path: &Path, refspec: &str, depth: usize) -> Result<(), String> {
let depth = format!("--depth={depth}");
let output = git_at(cache_path)
.args([
"fetch",
"--no-tags",
&depth,
"--filter=blob:none",
"origin",
refspec,
])
.output()
.map_err(|error| format!("PERSONA_REMOTE_INCREMENTAL_FETCH_FAILED: {error}"))?;
require_partial_fetch_success(output)
}
fn fetch_without_history_reset(cache_path: &Path, refspec: &str) -> Result<(), String> {
let output = git_at(cache_path)
.args([
"fetch",
"--no-tags",
"--update-shallow",
"--filter=blob:none",
"origin",
refspec,
])
.output()
.map_err(|error| format!("PERSONA_REMOTE_INCREMENTAL_FETCH_FAILED: {error}"))?;
require_partial_fetch_success(output)
}
fn require_partial_fetch_success(output: Output) -> Result<(), String> {
let output = require_success("PERSONA_REMOTE_INCREMENTAL_FETCH", output)?;
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("filtering not recognized") || stderr.contains("filter-spec") {
return Err("PERSONA_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED".into());
}
Ok(())
}
fn git_is_ancestor(cache_path: &Path, ancestor: &str, descendant: &str) -> Result<bool, String> {
validate_head(ancestor)?;
validate_head(descendant)?;
let output = git_at(cache_path)
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.output()
.map_err(|error| format!("PERSONA_REMOTE_CONTINUITY_CHECK_FAILED: {error}"))?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Err(format!(
"PERSONA_REMOTE_CONTINUITY_CHECK_FAILED: {}",
String::from_utf8_lossy(&output.stderr).trim()
)),
}
}
fn optional_ref(cache_path: &Path, git_ref: &str) -> Result<Option<String>, String> {
let output = git_at(cache_path)
.args(["rev-parse", "--verify", git_ref])
.output()
.map_err(|error| format!("PERSONA_REMOTE_CURSOR_READ_FAILED: {error}"))?;
if !output.status.success() {
return Ok(None);
}
let head = String::from_utf8(output.stdout)
.map_err(|_| "PERSONA_REMOTE_CURSOR_NOT_UTF8".to_string())?
.trim()
.to_ascii_lowercase();
validate_head(&head)?;
Ok(Some(head))
}
fn required_ref(cache_path: &Path, git_ref: &str) -> Result<String, String> {
optional_ref(cache_path, git_ref)?
.ok_or_else(|| "PERSONA_REMOTE_FETCH_CURSOR_MISSING".to_string())
}
fn git_at(path: &Path) -> Command {
let mut command = Command::new("git");
command.arg("-C").arg(path);
command
}
fn git_status(command: &mut Command, label: &str) -> Result<(), String> {
let output = command
.output()
.map_err(|error| format!("{label}_FAILED: {error}"))?;
require_success(label, output).map(|_| ())
}
fn git_output_text(command: &mut Command, label: &str) -> Result<String, String> {
let output = command
.output()
.map_err(|error| format!("{label}_FAILED: {error}"))?;
let output = require_success(label, output)?;
String::from_utf8(output.stdout).map_err(|_| format!("{label}_NOT_UTF8"))
}
fn git_output_bytes(command: &mut Command, label: &str) -> Result<Vec<u8>, String> {
let output = command
.output()
.map_err(|error| format!("{label}_FAILED: {error}"))?;
Ok(require_success(label, output)?.stdout)
}
fn require_success(label: &str, output: Output) -> Result<Output, String> {
if output.status.success() {
return Ok(output);
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("{label}_FAILED: {}", stderr.trim()))
}
fn directory_size(root: &Path) -> Result<u64, String> {
let mut total = 0_u64;
let mut pending = vec![root.to_path_buf()];
while let Some(path) = pending.pop() {
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("PERSONA_REMOTE_CACHE_MEASURE_FAILED: {error}"))?;
if metadata.file_type().is_symlink() {
return Err("PERSONA_REMOTE_CACHE_SYMLINK_REJECTED".into());
}
if metadata.is_file() {
total = total.saturating_add(metadata.len());
continue;
}
if metadata.is_dir() {
for entry in fs::read_dir(&path)
.map_err(|error| format!("PERSONA_REMOTE_CACHE_MEASURE_FAILED: {error}"))?
{
pending.push(
entry
.map_err(|error| format!("PERSONA_REMOTE_CACHE_MEASURE_FAILED: {error}"))?
.path(),
);
}
}
}
Ok(total)
}
fn remove_exact_cache(cache_root: &Path, cache_path: &Path) -> Result<(), String> {
let parent = cache_path
.parent()
.ok_or_else(|| "PERSONA_REMOTE_CACHE_BOUNDARY_INVALID".to_string())?;
if parent != cache_root || !cache_path.starts_with(cache_root) || cache_path == cache_root {
return Err("PERSONA_REMOTE_CACHE_BOUNDARY_INVALID".into());
}
fs::remove_dir_all(cache_path)
.map_err(|error| format!("PERSONA_REMOTE_CACHE_EVICTION_FAILED: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn run_git(path: &Path, args: &[&str]) {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.output()
.expect("git should run");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
fn fixture() -> (TempDir, PathBuf, PathBuf, String) {
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
let remote = temp.path().join("remote.git");
let cache = temp.path().join("cache");
fs::create_dir(&source).unwrap();
run_git(&source, &["init", "-b", "main"]);
run_git(&source, &["config", "user.name", "Persona Test"]);
run_git(&source, &["config", "user.email", "persona@test.invalid"]);
fs::create_dir_all(source.join("brain")).unwrap();
fs::write(source.join("brain/current.hldp"), "first\n").unwrap();
run_git(&source, &["add", "."]);
run_git(&source, &["commit", "-m", "first"]);
let init = Command::new("git")
.args(["init", "--bare", &remote.to_string_lossy()])
.output()
.unwrap();
assert!(init.status.success());
run_git(&remote, &["config", "uploadpack.allowFilter", "true"]);
run_git(
&remote,
&["config", "uploadpack.allowAnySHA1InWant", "true"],
);
run_git(
&source,
&["remote", "add", "origin", &remote.to_string_lossy()],
);
run_git(&source, &["push", "-u", "origin", "main"]);
let remote_url = format!("file://{}", remote.display());
(temp, source, cache, remote_url)
}
fn request(cache: &Path, remote_url: &str) -> RemoteReadRequest {
RemoteReadRequest {
remote_url: remote_url.to_string(),
branch: "main".into(),
relative_path: "brain/current.hldp".into(),
expected_head: None,
cache_root: prepare_cache_root(cache).unwrap(),
max_cache_bytes: DEFAULT_CACHE_LIMIT_BYTES,
allow_file_remote: true,
}
}
#[test]
fn reads_remote_persona_text_without_a_clone_or_worktree() {
let (_temp, _source, cache, remote_url) = fixture();
let receipt = read_remote_text_object(&request(&cache, &remote_url)).unwrap();
assert_eq!(receipt.content, "first\n");
assert!(receipt.cursor_advanced);
assert!(receipt.continuity_verified);
assert!(receipt.fetched_incremental_objects);
assert!(!receipt.cache_rehydrated);
assert!(!receipt.worktree_created);
assert!(!receipt.full_history_requested);
assert_eq!(receipt.cache_state, "BOUNDED_PARTIAL_OBJECT_CACHE");
assert!(Path::new(&receipt.cache_path).join("HEAD").is_file());
assert!(!Path::new(&receipt.cache_path).join(".git").exists());
assert!(Path::new(&receipt.continuity_state_path).is_file());
}
#[test]
fn unchanged_remote_cursor_does_not_fetch_again() {
let (_temp, _source, cache, remote_url) = fixture();
let first = read_remote_text_object(&request(&cache, &remote_url)).unwrap();
let second = read_remote_text_object(&request(&cache, &remote_url)).unwrap();
assert_eq!(second.remote_head, first.remote_head);
assert_eq!(second.previous_cursor, Some(first.remote_head));
assert!(!second.cursor_advanced);
assert!(!second.fetched_incremental_objects);
assert!(!second.cache_rehydrated);
}
#[test]
fn changed_remote_cursor_appends_only_the_new_segment_to_existing_continuity() {
let (_temp, source, cache, remote_url) = fixture();
let first = read_remote_text_object(&request(&cache, &remote_url)).unwrap();
fs::write(source.join("brain/current.hldp"), "second\n").unwrap();
run_git(&source, &["add", "."]);
run_git(&source, &["commit", "-m", "second"]);
run_git(&source, &["push", "origin", "main"]);
let second = read_remote_text_object(&request(&cache, &remote_url)).unwrap();
assert_ne!(second.remote_head, first.remote_head);
assert_eq!(second.previous_cursor, Some(first.remote_head));
assert!(second.cursor_advanced);
assert!(second.fetched_incremental_objects);
assert_eq!(second.content, "second\n");
let visible_commits = git_output_text(
git_at(Path::new(&second.cache_path)).args([
"rev-list",
"--count",
"refs/remotes/origin/main",
]),
"TEST_VISIBLE_COMMIT_COUNT",
)
.unwrap();
assert_eq!(visible_commits.trim(), "2");
}
#[test]
fn oversized_partial_cache_is_evicted_after_the_bounded_read() {
let (_temp, _source, cache, remote_url) = fixture();
let mut input = request(&cache, &remote_url);
input.max_cache_bytes = 1;
let receipt = read_remote_text_object(&input).unwrap();
assert_eq!(receipt.content, "first\n");
assert_eq!(receipt.cache_state, "EVICTED_AFTER_BOUNDED_READ");
assert_eq!(receipt.cache_bytes, 0);
assert!(!Path::new(&receipt.cache_path).exists());
assert!(Path::new(&receipt.continuity_state_path).is_file());
let state: PersonaRemoteCursorRecord =
serde_json::from_slice(&fs::read(&receipt.continuity_state_path).unwrap()).unwrap();
assert_eq!(state.verified_head, receipt.remote_head);
}
#[test]
fn evicted_object_cache_rehydrates_only_the_missing_new_segments() {
let (_temp, source, storage, remote_url) = fixture();
let mut first_request = request(&storage, &remote_url);
first_request.max_cache_bytes = 1;
let first = read_remote_text_object(&first_request).unwrap();
assert!(!Path::new(&first.cache_path).exists());
for (message, content) in [
("second", "second\n"),
("third", "third\n"),
("fourth", "fourth\n"),
] {
fs::write(source.join("brain/current.hldp"), content).unwrap();
run_git(&source, &["add", "."]);
run_git(&source, &["commit", "-m", message]);
}
run_git(&source, &["push", "origin", "main"]);
let second = read_remote_text_object(&request(&storage, &remote_url)).unwrap();
assert_eq!(second.previous_cursor, Some(first.remote_head));
assert!(second.cursor_advanced);
assert!(second.cache_rehydrated);
assert!(second.continuity_verified);
assert_eq!(second.content, "fourth\n");
let visible_commits = git_output_text(
git_at(Path::new(&second.cache_path)).args([
"rev-list",
"--count",
"refs/remotes/origin/main",
]),
"TEST_REHYDRATED_COMMIT_COUNT",
)
.unwrap();
assert_eq!(visible_commits.trim(), "4");
}
#[test]
fn rejects_remote_history_rewrite_without_advancing_the_durable_cursor() {
let (_temp, source, storage, remote_url) = fixture();
let first = read_remote_text_object(&request(&storage, &remote_url)).unwrap();
run_git(&source, &["checkout", "--orphan", "rewritten"]);
run_git(&source, &["rm", "-rf", "."]);
fs::create_dir_all(source.join("brain")).unwrap();
fs::write(source.join("brain/current.hldp"), "rewritten\n").unwrap();
run_git(&source, &["add", "."]);
run_git(&source, &["commit", "-m", "rewrite"]);
run_git(&source, &["branch", "-M", "main"]);
run_git(&source, &["push", "--force", "origin", "main"]);
assert_eq!(
read_remote_text_object(&request(&storage, &remote_url)).unwrap_err(),
"PERSONA_REMOTE_HISTORY_REWRITE_REJECTED"
);
let state: PersonaRemoteCursorRecord =
serde_json::from_slice(&fs::read(&first.continuity_state_path).unwrap()).unwrap();
assert_eq!(state.verified_head, first.remote_head);
}
#[test]
fn rejects_credentials_path_escape_and_wrong_expected_head() {
assert_eq!(
validate_remote_url("https://token@example.invalid/repo.git", false).unwrap_err(),
"PERSONA_REMOTE_URL_MUST_NOT_CONTAIN_CREDENTIALS"
);
assert_eq!(
validate_relative_path("../brain.hldp").unwrap_err(),
"PERSONA_REMOTE_PATH_INVALID"
);
let (_temp, _source, cache, remote_url) = fixture();
let mut input = request(&cache, &remote_url);
input.expected_head = Some("0000000000000000000000000000000000000000".into());
assert_eq!(
read_remote_text_object(&input).unwrap_err(),
"PERSONA_REMOTE_HEAD_MISMATCH"
);
}
#[test]
fn rejects_a_remote_that_cannot_serve_partial_objects_before_fetching() {
let (_temp, _source, storage, remote_url) = fixture();
let remote_path = PathBuf::from(remote_url.trim_start_matches("file://"));
run_git(&remote_path, &["config", "uploadpack.allowFilter", "false"]);
assert_eq!(
read_remote_text_object(&request(&storage, &remote_url)).unwrap_err(),
"PERSONA_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED"
);
assert!(!storage.join("objects").exists());
}
}