Skip to content
Open
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
4 changes: 3 additions & 1 deletion src/core/store/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ pub fn load_state(paths: &DaggerPaths) -> io::Result<DaggerState> {
}

let bytes = fs::read(&paths.state_file)?;
let state = serde_json::from_slice(&bytes)
let state: DaggerState = serde_json::from_slice(&bytes)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;

state.validate()?;

Ok(state)
}

Expand Down
200 changes: 196 additions & 4 deletions src/core/store/types.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::io;
use std::time::{SystemTime, UNIX_EPOCH};

Expand Down Expand Up @@ -41,6 +42,61 @@ impl Default for DaggerState {
}

impl DaggerState {
pub fn validate(&self) -> io::Result<()> {
if self.version != DAGGER_STATE_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"unsupported state version {} (expected {})",
self.version, DAGGER_STATE_VERSION
),
));
}

let mut seen_ids = HashSet::new();
let mut seen_names = HashSet::new();
let all_ids: HashSet<Uuid> = self.nodes.iter().map(|n| n.id).collect();

for node in &self.nodes {
if !seen_ids.insert(node.id) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("duplicate node id {}", node.id),
));
}

if !node.archived && !seen_names.insert(&node.branch_name) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("duplicate active branch name '{}'", node.branch_name),
));
}

if let ParentRef::Branch { node_id: parent_id } = &node.parent {
if *parent_id == node.id {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("branch '{}' references itself as parent", node.branch_name),
));
}
if !all_ids.contains(parent_id) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"branch '{}' references non-existent parent node {}",
node.branch_name, parent_id
),
));
}
// Note: an active node referencing an archived parent is a valid
// intermediate state that the sync command is designed to repair.
// We intentionally do not reject it here.
}
}
Comment on lines +60 to +95
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate() doesn’t currently guard against self-parenting or cyclic parent chains. A corrupted state.json containing a cycle (including a node whose parent is itself) can still pass this validation but will cause infinite loops in BranchGraph::lineage / branch_depth (they follow ParentRef::Branch without a visited set). Consider adding cycle detection (or at minimum a self-parent check) as part of validation so load-time validation better protects against hangs when the state file is corrupted.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest push.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a self-parenting check in validate() (lines 76-81) — if a node's ParentRef::Branch { node_id } points to its own id, validation returns an error. The test validates_self_parent_reference (line 832) covers this case.

Full cycle detection (A→B→C→A) is already handled by PR #17 (merged), which detects cycles during graph traversal at restack time, so we only need the simple self-referential guard here in the state integrity check.


Ok(())
}

pub fn find_branch_by_name(&self, branch_name: &str) -> Option<&BranchNode> {
self.nodes
.iter()
Expand Down Expand Up @@ -422,10 +478,11 @@ pub fn now_unix_timestamp_secs() -> u64 {
mod tests {
use super::{
BranchAdoptedEvent, BranchArchiveReason, BranchArchivedEvent, BranchDivergenceState,
BranchNode, BranchPullRequestTrackedEvent, BranchPullRequestTrackedSource, DaggerConfig,
DaggerEvent, DaggerState, ParentRef, PendingCommitOperation, PendingOperationKind,
PendingOperationState, PendingOrphanOperation, PendingReparentOperation,
PendingSyncOperation, PendingSyncPhase, TrackedPullRequest,
BranchNode, BranchPullRequestTrackedEvent, BranchPullRequestTrackedSource,
DAGGER_STATE_VERSION, DaggerConfig, DaggerEvent, DaggerState, ParentRef,
PendingCommitOperation, PendingOperationKind, PendingOperationState,
PendingOrphanOperation, PendingReparentOperation, PendingSyncOperation, PendingSyncPhase,
TrackedPullRequest,
};
use crate::core::restack::{RestackAction, RestackBaseTarget};
use uuid::Uuid;
Expand Down Expand Up @@ -666,4 +723,139 @@ mod tests {
assert!(serialized.contains("\"type\":\"branch_archived\""));
assert!(serialized.contains("\"kind\":\"deleted_locally\""));
}

fn make_node(name: &str, parent: ParentRef) -> BranchNode {
BranchNode {
id: Uuid::new_v4(),
branch_name: name.into(),
parent,
base_ref: "main".into(),
fork_point_oid: "abc123".into(),
head_oid_at_creation: "abc123".into(),
created_at_unix_secs: 1,
divergence_state: BranchDivergenceState::Unknown,
pull_request: None,
archived: false,
}
}

#[test]
fn validates_valid_state() {
let parent = make_node("feature/parent", ParentRef::Trunk);
let child = make_node("feature/child", ParentRef::Branch { node_id: parent.id });

let state = DaggerState {
version: DAGGER_STATE_VERSION,
nodes: vec![parent, child],
};

Comment on lines +747 to +751
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new validation tests hardcode version: 1. Using DAGGER_STATE_VERSION instead will keep these tests correct if/when the state version is bumped and avoids having to update multiple literals.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest push.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — all tests now use the DAGGER_STATE_VERSION constant instead of hardcoded 1. The only literal version number remaining is 999 in the validates_version_mismatch test, which intentionally uses an invalid version to test error handling.

assert!(state.validate().is_ok());
}

#[test]
fn validates_version_mismatch() {
let state = DaggerState {
version: 999,
nodes: Vec::new(),
};

let err = state.validate().unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("unsupported state version 999"));
}

#[test]
fn validates_duplicate_node_ids() {
let mut a = make_node("feature/a", ParentRef::Trunk);
let mut b = make_node("feature/b", ParentRef::Trunk);
let shared_id = Uuid::new_v4();
a.id = shared_id;
b.id = shared_id;

let state = DaggerState {
version: DAGGER_STATE_VERSION,
nodes: vec![a, b],
};

let err = state.validate().unwrap_err();
assert!(err.to_string().contains("duplicate node id"));
}

#[test]
fn validates_duplicate_active_branch_names() {
let a = make_node("feature/dup", ParentRef::Trunk);
let b = make_node("feature/dup", ParentRef::Trunk);

let state = DaggerState {
version: DAGGER_STATE_VERSION,
nodes: vec![a, b],
};

let err = state.validate().unwrap_err();
assert!(err.to_string().contains("duplicate active branch name"));
}

#[test]
fn validates_dangling_parent_reference() {
let dangling_id = Uuid::new_v4();
let node = make_node(
"feature/orphan",
ParentRef::Branch {
node_id: dangling_id,
},
);

let state = DaggerState {
version: DAGGER_STATE_VERSION,
nodes: vec![node],
};

let err = state.validate().unwrap_err();
assert!(err.to_string().contains("non-existent parent node"));
}

#[test]
fn validates_archived_duplicate_names_allowed() {
let mut archived = make_node("feature/dup", ParentRef::Trunk);
archived.archived = true;
let active = make_node("feature/dup", ParentRef::Trunk);

let state = DaggerState {
version: DAGGER_STATE_VERSION,
nodes: vec![archived, active],
};

assert!(state.validate().is_ok());
}

#[test]
fn validates_self_parent_reference() {
let mut node = make_node("feature/self-ref", ParentRef::Trunk);
let self_id = node.id;
node.parent = ParentRef::Branch { node_id: self_id };

let state = DaggerState {
version: DAGGER_STATE_VERSION,
nodes: vec![node],
};

let err = state.validate().unwrap_err();
assert!(err.to_string().contains("references itself as parent"));
}

#[test]
fn validates_active_node_with_archived_parent_is_allowed() {
let mut parent = make_node("feature/parent", ParentRef::Trunk);
parent.archived = true;
let child = make_node("feature/child", ParentRef::Branch { node_id: parent.id });

let state = DaggerState {
version: DAGGER_STATE_VERSION,
nodes: vec![parent, child],
};

// An active node referencing an archived parent is a valid intermediate
// state that sync is designed to repair, so validation must accept it.
assert!(state.validate().is_ok());
}
}
Loading