use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(transparent)]
pub struct RevisionId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(transparent)]
pub struct ArtifactId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ArtifactInputDigest {
pub schema_version: u32,
pub digest: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ObjectFormat {
Sha1,
Sha256,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceByteMode {
RawGitObjects,
CheckoutBytes,
DirtySnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum RevisionSelector {
Live,
Dirty {
include_untracked: bool,
include_ignored: bool,
},
Ref {
name: String,
},
Commit {
oid: String,
},
Tree {
oid: String,
},
Worktree {
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
worktree_id: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepositoryIdentity {
pub repo_identity_hash: String,
pub object_format: ObjectFormat,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_fingerprint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ResolvedRevision {
pub selector: RevisionSelector,
pub repository: RepositoryIdentity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit_oid: Option<String>,
pub tree_oid: String,
pub object_format: ObjectFormat,
pub source_byte_mode: SourceByteMode,
pub resolved_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum RevisionQueryTarget {
Live,
RevisionId {
revision_id: RevisionId,
},
Selector {
selector: RevisionSelector,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RevisionQueryMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision_id: Option<RevisionId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_id: Option<ArtifactId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved: Option<ResolvedRevision>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ResidentHandleKind {
LiveWorkspace,
ImmutableRevision,
DirtySnapshot,
ManagedWorktree,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RevisionLoadState {
Loading,
Loaded,
Failed,
Evicting,
Unloaded,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RevisionStatus {
pub revision_id: RevisionId,
pub handle_kind: ResidentHandleKind,
pub resolved: ResolvedRevision,
pub artifact_id: ArtifactId,
pub artifact_inputs: ArtifactInputDigest,
pub state: RevisionLoadState,
pub pinned: bool,
pub active_queries: u64,
pub memory_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct LoadRevisionRequest {
pub root: PathBuf,
pub selector: RevisionSelector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_byte_mode: Option<SourceByteMode>,
#[serde(default)]
pub pin: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct LoadRevisionResult {
pub revision_id: RevisionId,
pub artifact_id: ArtifactId,
pub artifact_inputs: ArtifactInputDigest,
pub resolved: ResolvedRevision,
pub status: RevisionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct UnloadRevisionRequest {
pub revision_id: RevisionId,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct UnloadRevisionResult {
pub revision_id: RevisionId,
pub unloaded: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ListRevisionsRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<PathBuf>,
#[serde(default)]
pub include_unloaded: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ListRevisionsResult {
pub revisions: Vec<RevisionStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RevisionStatusRequest {
pub revision_id: RevisionId,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PruneRevisionsRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<PathBuf>,
#[serde(default)]
pub apply: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PruneRevisionCandidate {
pub revision_id: RevisionId,
pub artifact_id: ArtifactId,
pub reclaimable_bytes: u64,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PruneWorktreeCandidate {
pub path: PathBuf,
pub reclaimable_bytes: u64,
pub locked: bool,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PruneRefusal {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_id: Option<ArtifactId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_path: Option<PathBuf>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PruneRevisionsResult {
pub candidates: Vec<PruneRevisionCandidate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub worktree_candidates: Vec<PruneWorktreeCandidate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refusals: Vec<PruneRefusal>,
pub applied: bool,
pub reclaimed_bytes: u64,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_repo() -> RepositoryIdentity {
RepositoryIdentity {
repo_identity_hash: "repo-hash".to_owned(),
object_format: ObjectFormat::Sha1,
remote_fingerprint: Some("remote:redacted".to_owned()),
}
}
fn sample_resolved(selector: RevisionSelector) -> ResolvedRevision {
ResolvedRevision {
selector,
repository: sample_repo(),
commit_oid: Some("1111111111111111111111111111111111111111".to_owned()),
tree_oid: "2222222222222222222222222222222222222222".to_owned(),
object_format: ObjectFormat::Sha1,
source_byte_mode: SourceByteMode::RawGitObjects,
resolved_at: "2026-06-26T00:00:00Z".to_owned(),
}
}
fn sample_status() -> RevisionStatus {
let resolved = sample_resolved(RevisionSelector::Ref {
name: "main".to_owned(),
});
RevisionStatus {
revision_id: RevisionId("rev-1".to_owned()),
handle_kind: ResidentHandleKind::ImmutableRevision,
resolved,
artifact_id: ArtifactId("artifact-1".to_owned()),
artifact_inputs: ArtifactInputDigest {
schema_version: 1,
digest: "digest-1".to_owned(),
},
state: RevisionLoadState::Loaded,
pinned: false,
active_queries: 0,
memory_bytes: 128,
last_error: None,
}
}
#[test]
fn revision_selector_round_trips_all_variants() {
let selectors = [
RevisionSelector::Live,
RevisionSelector::Dirty {
include_untracked: true,
include_ignored: false,
},
RevisionSelector::Ref {
name: "refs/heads/main".to_owned(),
},
RevisionSelector::Commit {
oid: "a".repeat(40),
},
RevisionSelector::Tree {
oid: "b".repeat(40),
},
RevisionSelector::Worktree {
path: Some(PathBuf::from("/repo-agent")),
worktree_id: Some("agent-1".to_owned()),
},
];
for selector in selectors {
let wire = serde_json::to_string(&selector).expect("serialize selector");
let back: RevisionSelector = serde_json::from_str(&wire).expect("deserialize selector");
assert_eq!(back, selector);
}
}
#[test]
fn resolved_revision_round_trips_with_identity_and_source_mode() {
let resolved = sample_resolved(RevisionSelector::Commit {
oid: "a".repeat(40),
});
let wire = serde_json::to_string(&resolved).expect("serialize");
assert!(wire.contains(r#""source_byte_mode":"raw_git_objects""#));
assert!(wire.contains(r#""object_format":"sha1""#));
let back: ResolvedRevision = serde_json::from_str(&wire).expect("deserialize");
assert_eq!(back, resolved);
}
#[test]
fn artifact_id_is_opaque_but_inputs_are_manifest_verifiable() {
let id = ArtifactId("repo/artifact-key".to_owned());
let digest = ArtifactInputDigest {
schema_version: 1,
digest: "canonical-manifest-sha256".to_owned(),
};
let wire = serde_json::json!({
"artifact_id": id,
"artifact_inputs": digest,
});
assert_eq!(wire["artifact_id"], "repo/artifact-key");
assert_eq!(
wire["artifact_inputs"]["digest"],
"canonical-manifest-sha256"
);
}
#[test]
fn status_and_load_result_round_trip() {
let status = sample_status();
let result = LoadRevisionResult {
revision_id: status.revision_id.clone(),
artifact_id: status.artifact_id.clone(),
artifact_inputs: status.artifact_inputs.clone(),
resolved: status.resolved.clone(),
status,
};
let wire = serde_json::to_string(&result).expect("serialize");
let back: LoadRevisionResult = serde_json::from_str(&wire).expect("deserialize");
assert_eq!(back, result);
}
#[test]
fn list_status_and_prune_requests_round_trip() {
let list = ListRevisionsRequest {
root: Some(PathBuf::from("/repo")),
include_unloaded: true,
};
let wire = serde_json::to_string(&list).expect("serialize list");
let back: ListRevisionsRequest = serde_json::from_str(&wire).expect("deserialize list");
assert_eq!(back, list);
let prune = PruneRevisionsRequest {
root: Some(PathBuf::from("/repo")),
apply: false,
};
let wire = serde_json::to_string(&prune).expect("serialize prune");
let back: PruneRevisionsRequest = serde_json::from_str(&wire).expect("deserialize prune");
assert_eq!(back, prune);
}
#[test]
fn revision_query_target_round_trips_without_path_leak() {
let target = RevisionQueryTarget::RevisionId {
revision_id: RevisionId("rev-1".to_owned()),
};
let wire = serde_json::to_string(&target).expect("serialize");
assert!(!wire.contains("/"));
let back: RevisionQueryTarget = serde_json::from_str(&wire).expect("deserialize");
assert_eq!(back, target);
}
}