a73x

d4dac620

Overhaul the wire format and order events by Lamport clock

a73x   2026-03-21 13:50

Commit message
Overhaul the wire format and order events by Lamport clock

Cargo.toml
Old New
@@ -23,3 +23,4 @@ clap_mangen = "0.2"
23 23
24 [dev-dependencies] 24 [dev-dependencies]
25 tempfile = "3" 25 tempfile = "3"
26 proptest = "1"
fuzz/Cargo.toml
Old New
@@ -0,0 +1,21 @@
1 [package]
2 name = "git-collab-fuzz"
3 version = "0.0.0"
4 publish = false
5 edition = "2021"
6
7 [package.metadata]
8 cargo-fuzz = true
9
10 [dependencies]
11 libfuzzer-sys = "0.4"
12 git-collab = { path = ".." }
13 serde_json = "1"
14
15 [[bin]]
16 name = "fuzz_event_parse"
17 path = "fuzz_targets/fuzz_event_parse.rs"
18 test = false
19 doc = false
20
21 [workspace]
fuzz/fuzz_targets/fuzz_event_parse.rs
Old New
@@ -0,0 +1,10 @@
1 #![no_main]
2
3 use libfuzzer_sys::fuzz_target;
4 use git_collab::event::Event;
5
6 fuzz_target!(|data: &[u8]| {
7 // Feed arbitrary bytes to the Event JSON parser.
8 // Must not panic — Ok or Err both acceptable.
9 let _result: Result<Event, _> = serde_json::from_slice(data);
10 });
src/dag.rs
Old New
@@ -1,23 +1,79 @@
1 use git2::{Oid, Repository, Sort}; 1 use git2::{ObjectType, Oid, Repository, Sort};
2 2
3 use crate::error::Error; 3 use crate::error::Error;
4 use crate::event::{Action, Event}; 4 use crate::event::{Action, Event};
5 use crate::identity::author_signature; 5 use crate::identity::author_signature;
6 use crate::signing::{sign_event, SignedEvent}; 6 use crate::signing::sign_event;
7
8 /// The manifest blob content included in every event commit tree.
9 const MANIFEST_JSON: &[u8] = br#"{"version":1,"format":"git-collab"}"#;
10
11 /// Maximum allowed size for an event.json blob (1 MB).
12 pub const MAX_EVENT_BLOB_SIZE: usize = 1_048_576;
13
14 /// Walk the entire DAG reachable from `tip` and return the maximum clock value.
15 /// Returns 0 if the DAG is empty or all events have clock 0 (pre-migration).
16 pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> {
17 let mut revwalk = repo.revwalk()?;
18 revwalk.set_sorting(Sort::TOPOLOGICAL)?;
19 revwalk.push(tip)?;
20
21 let mut max = 0u64;
22 for oid_result in revwalk {
23 let oid = oid_result?;
24 let commit = repo.find_commit(oid)?;
25 let tree = commit.tree()?;
26 let entry = tree
27 .get_name("event.json")
28 .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
29
30 if entry.kind() != Some(ObjectType::Blob) {
31 return Err(Error::Git(git2::Error::from_str(
32 "event.json entry is not a blob",
33 )));
34 }
35
36 let blob = repo.find_blob(entry.id())?;
37 let content = blob.content();
38 if content.len() > MAX_EVENT_BLOB_SIZE {
39 return Err(Error::PayloadTooLarge {
40 actual: content.len(),
41 limit: MAX_EVENT_BLOB_SIZE,
42 });
43 }
44
45 let event: Event = serde_json::from_slice(content)?;
46 if event.clock > max {
47 max = event.clock;
48 }
49 }
50 Ok(max)
51 }
7 52
8 /// Create an orphan commit (no parents) with the given event. 53 /// Create an orphan commit (no parents) with the given event.
9 /// Returns the new commit OID which also serves as the entity ID. 54 /// Returns the new commit OID which also serves as the entity ID.
55 /// Clones the event internally and sets clock=1.
10 pub fn create_root_event( 56 pub fn create_root_event(
11 repo: &Repository, 57 repo: &Repository,
12 event: &Event, 58 event: &Event,
13 signing_key: &ed25519_dalek::SigningKey, 59 signing_key: &ed25519_dalek::SigningKey,
14 ) -> Result<Oid, Error> { 60 ) -> Result<Oid, Error> {
15 let signed = sign_event(event, signing_key)?; 61 let mut event = event.clone();
16 let json = serde_json::to_vec_pretty(&signed)?; 62 event.clock = 1;
17 let blob_oid = repo.blob(&json)?; 63
64 let detached = sign_event(&event, signing_key)?;
65 let event_json = serde_json::to_vec_pretty(&event)?;
66
67 let event_blob = repo.blob(&event_json)?;
68 let sig_blob = repo.blob(detached.signature.as_bytes())?;
69 let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
70 let manifest_blob = repo.blob(MANIFEST_JSON)?;
18 71
19 let mut tb = repo.treebuilder(None)?; 72 let mut tb = repo.treebuilder(None)?;
20 tb.insert("event.json", blob_oid, 0o100644)?; 73 tb.insert("event.json", event_blob, 0o100644)?;
74 tb.insert("signature", sig_blob, 0o100644)?;
75 tb.insert("pubkey", pubkey_blob, 0o100644)?;
76 tb.insert("manifest.json", manifest_blob, 0o100644)?;
21 let tree_oid = tb.write()?; 77 let tree_oid = tb.write()?;
22 let tree = repo.find_tree(tree_oid)?; 78 let tree = repo.find_tree(tree_oid)?;
23 79
@@ -29,18 +85,32 @@ pub fn create_root_event(
29 } 85 }
30 86
31 /// Append an event to an existing DAG. The current tip is the parent. 87 /// Append an event to an existing DAG. The current tip is the parent.
88 /// Clones the event internally and sets clock = max_clock(tip) + 1.
32 pub fn append_event( 89 pub fn append_event(
33 repo: &Repository, 90 repo: &Repository,
34 ref_name: &str, 91 ref_name: &str,
35 event: &Event, 92 event: &Event,
36 signing_key: &ed25519_dalek::SigningKey, 93 signing_key: &ed25519_dalek::SigningKey,
37 ) -> Result<Oid, Error> { 94 ) -> Result<Oid, Error> {
38 let signed = sign_event(event, signing_key)?; 95 let tip = repo.refname_to_id(ref_name)?;
39 let json = serde_json::to_vec_pretty(&signed)?; 96 let current_max = max_clock(repo, tip)?;
40 let blob_oid = repo.blob(&json)?; 97
98 let mut event = event.clone();
99 event.clock = current_max + 1;
100
101 let detached = sign_event(&event, signing_key)?;
102 let event_json = serde_json::to_vec_pretty(&event)?;
103
104 let event_blob = repo.blob(&event_json)?;
105 let sig_blob = repo.blob(detached.signature.as_bytes())?;
106 let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
107 let manifest_blob = repo.blob(MANIFEST_JSON)?;
41 108
42 let mut tb = repo.treebuilder(None)?; 109 let mut tb = repo.treebuilder(None)?;
43 tb.insert("event.json", blob_oid, 0o100644)?; 110 tb.insert("event.json", event_blob, 0o100644)?;
111 tb.insert("signature", sig_blob, 0o100644)?;
112 tb.insert("pubkey", pubkey_blob, 0o100644)?;
113 tb.insert("manifest.json", manifest_blob, 0o100644)?;
44 let tree_oid = tb.write()?; 114 let tree_oid = tb.write()?;
45 let tree = repo.find_tree(tree_oid)?; 115 let tree = repo.find_tree(tree_oid)?;
46 116
@@ -70,14 +140,26 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event)
70 let entry = tree 140 let entry = tree
71 .get_name("event.json") 141 .get_name("event.json")
72 .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; 142 .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
143
144 // Verify the entry points to a blob, not a tree or commit
145 if entry.kind() != Some(ObjectType::Blob) {
146 return Err(Error::Git(git2::Error::from_str(
147 "event.json entry is not a blob",
148 )));
149 }
150
73 let blob = repo.find_blob(entry.id())?; 151 let blob = repo.find_blob(entry.id())?;
152
153 // Check blob size before attempting deserialization
74 let content = blob.content(); 154 let content = blob.content();
75 // Try SignedEvent first, fall back to plain Event for backward compat 155 if content.len() > MAX_EVENT_BLOB_SIZE {
76 let event: Event = if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) { 156 return Err(Error::PayloadTooLarge {
77 signed.event 157 actual: content.len(),
78 } else { 158 limit: MAX_EVENT_BLOB_SIZE,
79 serde_json::from_slice(content)? 159 });
80 }; 160 }
161
162 let event: Event = serde_json::from_slice(content)?;
81 events.push((oid, event)); 163 events.push((oid, event));
82 } 164 }
83 Ok(events) 165 Ok(events)
@@ -116,18 +198,30 @@ pub fn reconcile(
116 return Ok(remote_oid); 198 return Ok(remote_oid);
117 } 199 }
118 200
119 // True fork — create merge commit 201 // True fork — create merge commit with clock = max(local, remote) + 1
202 let local_max = max_clock(repo, local_oid)?;
203 let remote_max = max_clock(repo, remote_oid)?;
204 let merge_clock = std::cmp::max(local_max, remote_max) + 1;
205
120 let merge_event = Event { 206 let merge_event = Event {
121 timestamp: chrono::Utc::now().to_rfc3339(), 207 timestamp: chrono::Utc::now().to_rfc3339(),
122 author: merge_author.clone(), 208 author: merge_author.clone(),
123 action: Action::Merge, 209 action: Action::Merge,
210 clock: merge_clock,
124 }; 211 };
125 212
126 let signed = sign_event(&merge_event, signing_key)?; 213 let detached = sign_event(&merge_event, signing_key)?;
127 let json = serde_json::to_vec_pretty(&signed)?; 214 let event_json = serde_json::to_vec_pretty(&merge_event)?;
128 let blob_oid = repo.blob(&json)?; 215 let event_blob = repo.blob(&event_json)?;
216 let sig_blob = repo.blob(detached.signature.as_bytes())?;
217 let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
218 let manifest_blob = repo.blob(MANIFEST_JSON)?;
219
129 let mut tb = repo.treebuilder(None)?; 220 let mut tb = repo.treebuilder(None)?;
130 tb.insert("event.json", blob_oid, 0o100644)?; 221 tb.insert("event.json", event_blob, 0o100644)?;
222 tb.insert("signature", sig_blob, 0o100644)?;
223 tb.insert("pubkey", pubkey_blob, 0o100644)?;
224 tb.insert("manifest.json", manifest_blob, 0o100644)?;
131 let tree_oid = tb.write()?; 225 let tree_oid = tb.write()?;
132 let tree = repo.find_tree(tree_oid)?; 226 let tree = repo.find_tree(tree_oid)?;
133 227
@@ -147,6 +241,72 @@ pub fn reconcile(
147 Ok(oid) 241 Ok(oid)
148 } 242 }
149 243
244 /// Migrate a DAG ref so that every event with clock=0 gets a sequential clock
245 /// assigned in topological order. Events that already have clock>0 are left as-is.
246 /// This rewrites the commit chain (new OIDs) and updates the ref.
247 pub fn migrate_clocks(
248 repo: &Repository,
249 ref_name: &str,
250 signing_key: &ed25519_dalek::SigningKey,
251 ) -> Result<(), Error> {
252 let events = walk_events(repo, ref_name)?;
253
254 // Check if migration is needed: any event with clock=0?
255 let needs_migration = events.iter().any(|(_, e)| e.clock == 0);
256 if !needs_migration {
257 return Ok(());
258 }
259
260 // Rebuild the chain with sequential clocks
261 let mut clock = 0u64;
262 let mut prev_oid: Option<Oid> = None;
263
264 for (_old_oid, event) in &events {
265 clock += 1;
266 let mut migrated = event.clone();
267 if migrated.clock == 0 {
268 migrated.clock = clock;
269 } else {
270 clock = migrated.clock; // respect existing clocks
271 }
272
273 let detached = sign_event(&migrated, signing_key)?;
274 let event_json = serde_json::to_vec_pretty(&migrated)?;
275 let event_blob = repo.blob(&event_json)?;
276 let sig_blob = repo.blob(detached.signature.as_bytes())?;
277 let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
278 let manifest_blob = repo.blob(MANIFEST_JSON)?;
279
280 let mut tb = repo.treebuilder(None)?;
281 tb.insert("event.json", event_blob, 0o100644)?;
282 tb.insert("signature", sig_blob, 0o100644)?;
283 tb.insert("pubkey", pubkey_blob, 0o100644)?;
284 tb.insert("manifest.json", manifest_blob, 0o100644)?;
285 let tree_oid = tb.write()?;
286 let tree = repo.find_tree(tree_oid)?;
287
288 let sig = author_signature(&migrated.author)?;
289 let message = commit_message(&migrated.action);
290
291 let parents: Vec<git2::Commit> = if let Some(pid) = prev_oid {
292 vec![repo.find_commit(pid)?]
293 } else {
294 vec![]
295 };
296 let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
297
298 let new_oid = repo.commit(None, &sig, &sig, &message, &tree, &parent_refs)?;
299 prev_oid = Some(new_oid);
300 }
301
302 // Update the ref to point to the new tip
303 if let Some(new_tip) = prev_oid {
304 repo.reference(ref_name, new_tip, true, "migrate clocks")?;
305 }
306
307 Ok(())
308 }
309
150 fn commit_message(action: &Action) -> String { 310 fn commit_message(action: &Action) -> String {
151 match action { 311 match action {
152 Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title), 312 Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title),
src/error.rs
Old New
@@ -25,4 +25,16 @@ pub enum Error {
25 25
26 #[error("untrusted key: {0}")] 26 #[error("untrusted key: {0}")]
27 UntrustedKey(String), 27 UntrustedKey(String),
28
29 #[error("another sync is in progress (pid {pid}, started {since})")]
30 SyncLocked { pid: u32, since: String },
31
32 #[error("sync partially failed: {succeeded} of {total} refs pushed")]
33 PartialSync { succeeded: usize, total: usize },
34
35 #[error("event.json blob exceeds {limit} byte limit (actual: {actual})")]
36 PayloadTooLarge { actual: usize, limit: usize },
37
38 #[error("invalid ref name: {0}")]
39 InvalidRefName(String),
28 } 40 }
src/event.rs
Old New
@@ -11,38 +11,50 @@ pub struct Event {
11 pub timestamp: String, 11 pub timestamp: String,
12 pub author: Author, 12 pub author: Author,
13 pub action: Action, 13 pub action: Action,
14 #[serde(default)]
15 pub clock: u64,
14 } 16 }
15 17
16 #[derive(Debug, Clone, Serialize, Deserialize)] 18 #[derive(Debug, Clone, Serialize, Deserialize)]
17 #[serde(tag = "type")] 19 #[serde(tag = "type")]
18 pub enum Action { 20 pub enum Action {
21 #[serde(rename = "issue.open")]
19 IssueOpen { 22 IssueOpen {
20 title: String, 23 title: String,
21 body: String, 24 body: String,
22 }, 25 },
26 #[serde(rename = "issue.comment")]
23 IssueComment { 27 IssueComment {
24 body: String, 28 body: String,
25 }, 29 },
30 #[serde(rename = "issue.close")]
26 IssueClose { 31 IssueClose {
27 reason: Option<String>, 32 reason: Option<String>,
28 }, 33 },
34 #[serde(rename = "issue.edit")]
29 IssueEdit { 35 IssueEdit {
30 title: Option<String>, 36 title: Option<String>,
31 body: Option<String>, 37 body: Option<String>,
32 }, 38 },
39 #[serde(rename = "issue.label")]
33 IssueLabel { 40 IssueLabel {
34 label: String, 41 label: String,
35 }, 42 },
43 #[serde(rename = "issue.unlabel")]
36 IssueUnlabel { 44 IssueUnlabel {
37 label: String, 45 label: String,
38 }, 46 },
47 #[serde(rename = "issue.assign")]
39 IssueAssign { 48 IssueAssign {
40 assignee: String, 49 assignee: String,
41 }, 50 },
51 #[serde(rename = "issue.unassign")]
42 IssueUnassign { 52 IssueUnassign {
43 assignee: String, 53 assignee: String,
44 }, 54 },
55 #[serde(rename = "issue.reopen")]
45 IssueReopen, 56 IssueReopen,
57 #[serde(rename = "patch.create")]
46 PatchCreate { 58 PatchCreate {
47 title: String, 59 title: String,
48 body: String, 60 body: String,
@@ -51,25 +63,32 @@ pub enum Action {
51 #[serde(default, skip_serializing_if = "Option::is_none")] 63 #[serde(default, skip_serializing_if = "Option::is_none")]
52 fixes: Option<String>, 64 fixes: Option<String>,
53 }, 65 },
66 #[serde(rename = "patch.revise")]
54 PatchRevise { 67 PatchRevise {
55 body: Option<String>, 68 body: Option<String>,
56 }, 69 },
70 #[serde(rename = "patch.review")]
57 PatchReview { 71 PatchReview {
58 verdict: ReviewVerdict, 72 verdict: ReviewVerdict,
59 body: String, 73 body: String,
60 }, 74 },
75 #[serde(rename = "patch.comment")]
61 PatchComment { 76 PatchComment {
62 body: String, 77 body: String,
63 }, 78 },
79 #[serde(rename = "patch.inline_comment")]
64 PatchInlineComment { 80 PatchInlineComment {
65 file: String, 81 file: String,
66 line: u32, 82 line: u32,
67 body: String, 83 body: String,
68 }, 84 },
85 #[serde(rename = "patch.close")]
69 PatchClose { 86 PatchClose {
70 reason: Option<String>, 87 reason: Option<String>,
71 }, 88 },
89 #[serde(rename = "patch.merge")]
72 PatchMerge, 90 PatchMerge,
91 #[serde(rename = "collab.merge")]
73 Merge, 92 Merge,
74 } 93 }
75 94
src/issue.rs
Old New
@@ -16,6 +16,7 @@ pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate:
16 title: title.to_string(), 16 title: title.to_string(),
17 body: body.to_string(), 17 body: body.to_string(),
18 }, 18 },
19 clock: 0,
19 }; 20 };
20 let oid = dag::create_root_event(repo, &event, &sk)?; 21 let oid = dag::create_root_event(repo, &event, &sk)?;
21 let id = oid.to_string(); 22 let id = oid.to_string();
@@ -82,6 +83,7 @@ pub fn label(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crat
82 action: Action::IssueLabel { 83 action: Action::IssueLabel {
83 label: label.to_string(), 84 label: label.to_string(),
84 }, 85 },
86 clock: 0,
85 }; 87 };
86 dag::append_event(repo, &ref_name, &event, &sk)?; 88 dag::append_event(repo, &ref_name, &event, &sk)?;
87 Ok(()) 89 Ok(())
@@ -97,6 +99,7 @@ pub fn unlabel(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), cr
97 action: Action::IssueUnlabel { 99 action: Action::IssueUnlabel {
98 label: label.to_string(), 100 label: label.to_string(),
99 }, 101 },
102 clock: 0,
100 }; 103 };
101 dag::append_event(repo, &ref_name, &event, &sk)?; 104 dag::append_event(repo, &ref_name, &event, &sk)?;
102 Ok(()) 105 Ok(())
@@ -116,6 +119,7 @@ pub fn assign(
116 action: Action::IssueAssign { 119 action: Action::IssueAssign {
117 assignee: assignee.to_string(), 120 assignee: assignee.to_string(),
118 }, 121 },
122 clock: 0,
119 }; 123 };
120 dag::append_event(repo, &ref_name, &event, &sk)?; 124 dag::append_event(repo, &ref_name, &event, &sk)?;
121 Ok(()) 125 Ok(())
@@ -135,6 +139,7 @@ pub fn unassign(
135 action: Action::IssueUnassign { 139 action: Action::IssueUnassign {
136 assignee: assignee.to_string(), 140 assignee: assignee.to_string(),
137 }, 141 },
142 clock: 0,
138 }; 143 };
139 dag::append_event(repo, &ref_name, &event, &sk)?; 144 dag::append_event(repo, &ref_name, &event, &sk)?;
140 Ok(()) 145 Ok(())
@@ -161,6 +166,7 @@ pub fn edit(
161 title: title.map(|s| s.to_string()), 166 title: title.map(|s| s.to_string()),
162 body: body.map(|s| s.to_string()), 167 body: body.map(|s| s.to_string()),
163 }, 168 },
169 clock: 0,
164 }; 170 };
165 dag::append_event(repo, &ref_name, &event, &sk)?; 171 dag::append_event(repo, &ref_name, &event, &sk)?;
166 Ok(()) 172 Ok(())
@@ -176,6 +182,7 @@ pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), cra
176 action: Action::IssueComment { 182 action: Action::IssueComment {
177 body: body.to_string(), 183 body: body.to_string(),
178 }, 184 },
185 clock: 0,
179 }; 186 };
180 dag::append_event(repo, &ref_name, &event, &sk)?; 187 dag::append_event(repo, &ref_name, &event, &sk)?;
181 Ok(()) 188 Ok(())
@@ -195,6 +202,7 @@ pub fn close(
195 action: Action::IssueClose { 202 action: Action::IssueClose {
196 reason: reason.map(|s| s.to_string()), 203 reason: reason.map(|s| s.to_string()),
197 }, 204 },
205 clock: 0,
198 }; 206 };
199 dag::append_event(repo, &ref_name, &event, &sk)?; 207 dag::append_event(repo, &ref_name, &event, &sk)?;
200 Ok(()) 208 Ok(())
@@ -208,6 +216,7 @@ pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Er
208 timestamp: chrono::Utc::now().to_rfc3339(), 216 timestamp: chrono::Utc::now().to_rfc3339(),
209 author, 217 author,
210 action: Action::IssueReopen, 218 action: Action::IssueReopen,
219 clock: 0,
211 }; 220 };
212 dag::append_event(repo, &ref_name, &event, &sk)?; 221 dag::append_event(repo, &ref_name, &event, &sk)?;
213 Ok(()) 222 Ok(())
src/lib.rs
Old New
@@ -9,6 +9,7 @@ pub mod patch;
9 pub mod state; 9 pub mod state;
10 pub mod signing; 10 pub mod signing;
11 pub mod sync; 11 pub mod sync;
12 pub mod sync_lock;
12 pub mod trust; 13 pub mod trust;
13 pub mod tui; 14 pub mod tui;
14 15
src/main.rs
Old New
@@ -15,7 +15,15 @@ fn main() {
15 }; 15 };
16 16
17 if let Err(e) = git_collab::run(cli, &repo) { 17 if let Err(e) = git_collab::run(cli, &repo) {
18 eprintln!("error: {}", e); 18 match &e {
19 std::process::exit(1); 19 git_collab::error::Error::PartialSync { .. } => {
20 // Summary already printed by sync; just exit with code 1
21 std::process::exit(1);
22 }
23 _ => {
24 eprintln!("error: {}", e);
25 std::process::exit(1);
26 }
27 }
20 } 28 }
21 } 29 }
src/patch.rs
Old New
@@ -50,6 +50,7 @@ pub fn create(
50 branch: branch.to_string(), 50 branch: branch.to_string(),
51 fixes: fixes.map(|s| s.to_string()), 51 fixes: fixes.map(|s| s.to_string()),
52 }, 52 },
53 clock: 0,
53 }; 54 };
54 let oid = dag::create_root_event(repo, &event, &sk)?; 55 let oid = dag::create_root_event(repo, &event, &sk)?;
55 let id = oid.to_string(); 56 let id = oid.to_string();
@@ -104,6 +105,7 @@ pub fn comment(
104 timestamp: chrono::Utc::now().to_rfc3339(), 105 timestamp: chrono::Utc::now().to_rfc3339(),
105 author, 106 author,
106 action, 107 action,
108 clock: 0,
107 }; 109 };
108 dag::append_event(repo, &ref_name, &event, &sk)?; 110 dag::append_event(repo, &ref_name, &event, &sk)?;
109 Ok(()) 111 Ok(())
@@ -125,6 +127,7 @@ pub fn review(
125 verdict, 127 verdict,
126 body: body.to_string(), 128 body: body.to_string(),
127 }, 129 },
130 clock: 0,
128 }; 131 };
129 dag::append_event(repo, &ref_name, &event, &sk)?; 132 dag::append_event(repo, &ref_name, &event, &sk)?;
130 Ok(()) 133 Ok(())
@@ -144,6 +147,7 @@ pub fn revise(
144 action: Action::PatchRevise { 147 action: Action::PatchRevise {
145 body: body.map(|s| s.to_string()), 148 body: body.map(|s| s.to_string()),
146 }, 149 },
150 clock: 0,
147 }; 151 };
148 dag::append_event(repo, &ref_name, &event, &sk)?; 152 dag::append_event(repo, &ref_name, &event, &sk)?;
149 Ok(()) 153 Ok(())
@@ -207,6 +211,7 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er
207 timestamp: chrono::Utc::now().to_rfc3339(), 211 timestamp: chrono::Utc::now().to_rfc3339(),
208 author: author.clone(), 212 author: author.clone(),
209 action: Action::PatchMerge, 213 action: Action::PatchMerge,
214 clock: 0,
210 }; 215 };
211 dag::append_event(repo, &ref_name, &event, &sk)?; 216 dag::append_event(repo, &ref_name, &event, &sk)?;
212 217
@@ -219,6 +224,7 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er
219 action: Action::IssueClose { 224 action: Action::IssueClose {
220 reason: Some(format!("Fixed by patch {:.8}", p.id)), 225 reason: Some(format!("Fixed by patch {:.8}", p.id)),
221 }, 226 },
227 clock: 0,
222 }; 228 };
223 dag::append_event(repo, &issue_ref, &close_event, &sk)?; 229 dag::append_event(repo, &issue_ref, &close_event, &sk)?;
224 } 230 }
@@ -297,6 +303,7 @@ pub fn close(
297 action: Action::PatchClose { 303 action: Action::PatchClose {
298 reason: reason.map(|s| s.to_string()), 304 reason: reason.map(|s| s.to_string()),
299 }, 305 },
306 clock: 0,
300 }; 307 };
301 dag::append_event(repo, &ref_name, &event, &sk)?; 308 dag::append_event(repo, &ref_name, &event, &sk)?;
302 Ok(()) 309 Ok(())
src/signing.rs
Old New
@@ -6,7 +6,6 @@ use base64::Engine;
6 use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; 6 use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
7 use git2::Oid; 7 use git2::Oid;
8 use rand_core::OsRng; 8 use rand_core::OsRng;
9 use serde::{Deserialize, Serialize};
10 9
11 use git2::{Repository, Sort}; 10 use git2::{Repository, Sort};
12 11
@@ -28,12 +27,11 @@ pub fn signing_key_dir() -> Result<PathBuf, Error> {
28 } 27 }
29 } 28 }
30 29
31 /// Wrapper around Event that adds Ed25519 signature fields. 30 /// Detached signature data for an event commit.
32 /// Serialized as the event.json blob in git commits. 31 /// Stored as separate blobs (`signature` and `pubkey`) in the commit tree,
33 #[derive(Debug, Clone, Serialize, Deserialize)] 32 /// alongside the plain `event.json`.
34 pub struct SignedEvent { 33 #[derive(Debug, Clone)]
35 #[serde(flatten)] 34 pub struct DetachedSignature {
36 pub event: Event,
37 pub signature: String, 35 pub signature: String,
38 pub pubkey: String, 36 pub pubkey: String,
39 } 37 }
@@ -152,33 +150,35 @@ pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> {
152 Ok(json.into_bytes()) 150 Ok(json.into_bytes())
153 } 151 }
154 152
155 /// Sign an Event with the given signing key, producing a SignedEvent. 153 /// Sign an Event with the given signing key, producing a detached signature.
156 pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<SignedEvent, Error> { 154 pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<DetachedSignature, Error> {
157 let canonical = canonical_json(event)?; 155 let canonical = canonical_json(event)?;
158 let signature = signing_key.sign(&canonical); 156 let signature = signing_key.sign(&canonical);
159 let verifying_key = signing_key.verifying_key(); 157 let verifying_key = signing_key.verifying_key();
160 158
161 Ok(SignedEvent { 159 Ok(DetachedSignature {
162 event: event.clone(),
163 signature: STANDARD.encode(signature.to_bytes()), 160 signature: STANDARD.encode(signature.to_bytes()),
164 pubkey: STANDARD.encode(verifying_key.to_bytes()), 161 pubkey: STANDARD.encode(verifying_key.to_bytes()),
165 }) 162 })
166 } 163 }
167 164
168 /// Verify a SignedEvent's signature against its embedded public key. 165 /// Verify a detached signature against an event and its public key.
169 /// 166 ///
170 /// Returns `Missing` if signature or pubkey fields are empty, 167 /// Returns `Missing` if signature or pubkey fields are empty,
171 /// `Valid` if the signature checks out, `Invalid` otherwise. 168 /// `Valid` if the signature checks out, `Invalid` otherwise.
172 pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> { 169 pub fn verify_detached(
173 if signed.signature.is_empty() || signed.pubkey.is_empty() { 170 event: &Event,
171 sig: &DetachedSignature,
172 ) -> Result<VerifyStatus, Error> {
173 if sig.signature.is_empty() || sig.pubkey.is_empty() {
174 return Ok(VerifyStatus::Missing); 174 return Ok(VerifyStatus::Missing);
175 } 175 }
176 176
177 let sig_bytes = match STANDARD.decode(&signed.signature) { 177 let sig_bytes = match STANDARD.decode(&sig.signature) {
178 Ok(b) => b, 178 Ok(b) => b,
179 Err(_) => return Ok(VerifyStatus::Invalid), 179 Err(_) => return Ok(VerifyStatus::Invalid),
180 }; 180 };
181 let pubkey_bytes = match STANDARD.decode(&signed.pubkey) { 181 let pubkey_bytes = match STANDARD.decode(&sig.pubkey) {
182 Ok(b) => b, 182 Ok(b) => b,
183 Err(_) => return Ok(VerifyStatus::Invalid), 183 Err(_) => return Ok(VerifyStatus::Invalid),
184 }; 184 };
@@ -198,7 +198,7 @@ pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error>
198 Err(_) => return Ok(VerifyStatus::Invalid), 198 Err(_) => return Ok(VerifyStatus::Invalid),
199 }; 199 };
200 200
201 let canonical = canonical_json(&signed.event)?; 201 let canonical = canonical_json(event)?;
202 202
203 match verifying_key.verify(&canonical, &signature) { 203 match verifying_key.verify(&canonical, &signature) {
204 Ok(()) => Ok(VerifyStatus::Valid), 204 Ok(()) => Ok(VerifyStatus::Valid),
@@ -208,9 +208,8 @@ pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error>
208 208
209 /// Walk the DAG for the given ref and verify every event commit's signature. 209 /// Walk the DAG for the given ref and verify every event commit's signature.
210 /// 210 ///
211 /// For each commit, reads `event.json` from the tree: 211 /// For each commit, reads `event.json`, `signature`, and `pubkey` blobs from the tree.
212 /// - If it deserializes as a `SignedEvent`, calls `verify_signed_event()`. 212 /// If signature/pubkey blobs are missing, marks as `Missing`.
213 /// - If it only deserializes as a plain `Event` (no signature/pubkey), marks as `Missing`.
214 /// 213 ///
215 /// Returns one `SignatureVerificationResult` per commit. 214 /// Returns one `SignatureVerificationResult` per commit.
216 pub fn verify_ref( 215 pub fn verify_ref(
@@ -227,28 +226,42 @@ pub fn verify_ref(
227 let oid = oid_result?; 226 let oid = oid_result?;
228 let commit = repo.find_commit(oid)?; 227 let commit = repo.find_commit(oid)?;
229 let tree = commit.tree()?; 228 let tree = commit.tree()?;
230 let entry = tree 229
230 // Read event.json
231 let event_entry = tree
231 .get_name("event.json") 232 .get_name("event.json")
232 .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; 233 .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
233 let blob = repo.find_blob(entry.id())?; 234 let event_blob = repo.find_blob(event_entry.id())?;
234 let content = blob.content(); 235 let event: Event = serde_json::from_slice(event_blob.content())?;
235 236
236 // Try to deserialize as SignedEvent first 237 // Read signature and pubkey blob OIDs
237 if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) { 238 let sig_oid = tree.get_name("signature").map(|e| e.id());
238 if signed.signature.is_empty() || signed.pubkey.is_empty() { 239 let pubkey_oid = tree.get_name("pubkey").map(|e| e.id());
239 results.push(SignatureVerificationResult { 240
240 commit_id: oid, 241 match (sig_oid, pubkey_oid) {
241 status: VerifyStatus::Missing, 242 (Some(sid), Some(pid)) => {
242 pubkey: None, 243 let sig_blob = repo.find_blob(sid)?;
243 error: Some("missing signature".to_string()), 244 let pubkey_blob = repo.find_blob(pid)?;
244 }); 245 let sig_str = std::str::from_utf8(sig_blob.content())
245 } else { 246 .unwrap_or("")
246 match verify_signed_event(&signed)? { 247 .trim()
248 .to_string();
249 let pubkey_str = std::str::from_utf8(pubkey_blob.content())
250 .unwrap_or("")
251 .trim()
252 .to_string();
253
254 let detached = DetachedSignature {
255 signature: sig_str.clone(),
256 pubkey: pubkey_str.clone(),
257 };
258
259 match verify_detached(&event, &detached)? {
247 VerifyStatus::Valid => { 260 VerifyStatus::Valid => {
248 results.push(SignatureVerificationResult { 261 results.push(SignatureVerificationResult {
249 commit_id: oid, 262 commit_id: oid,
250 status: VerifyStatus::Valid, 263 status: VerifyStatus::Valid,
251 pubkey: Some(signed.pubkey), 264 pubkey: Some(pubkey_str),
252 error: None, 265 error: None,
253 }); 266 });
254 } 267 }
@@ -256,7 +269,7 @@ pub fn verify_ref(
256 results.push(SignatureVerificationResult { 269 results.push(SignatureVerificationResult {
257 commit_id: oid, 270 commit_id: oid,
258 status: VerifyStatus::Invalid, 271 status: VerifyStatus::Invalid,
259 pubkey: Some(signed.pubkey), 272 pubkey: Some(pubkey_str),
260 error: Some("invalid signature".to_string()), 273 error: Some("invalid signature".to_string()),
261 }); 274 });
262 } 275 }
@@ -269,25 +282,23 @@ pub fn verify_ref(
269 }); 282 });
270 } 283 }
271 VerifyStatus::Untrusted => { 284 VerifyStatus::Untrusted => {
272 // verify_signed_event never returns Untrusted,
273 // but handle it for exhaustiveness
274 results.push(SignatureVerificationResult { 285 results.push(SignatureVerificationResult {
275 commit_id: oid, 286 commit_id: oid,
276 status: VerifyStatus::Untrusted, 287 status: VerifyStatus::Untrusted,
277 pubkey: Some(signed.pubkey), 288 pubkey: Some(pubkey_str),
278 error: Some("untrusted key".to_string()), 289 error: Some("untrusted key".to_string()),
279 }); 290 });
280 } 291 }
281 } 292 }
282 } 293 }
283 } else { 294 _ => {
284 // Plain Event without signature fields 295 results.push(SignatureVerificationResult {
285 results.push(SignatureVerificationResult { 296 commit_id: oid,
286 commit_id: oid, 297 status: VerifyStatus::Missing,
287 status: VerifyStatus::Missing, 298 pubkey: None,
288 pubkey: None, 299 error: Some("missing signature".to_string()),
289 error: Some("missing signature".to_string()), 300 });
290 }); 301 }
291 } 302 }
292 } 303 }
293 304
@@ -300,7 +311,7 @@ mod tests {
300 use crate::event::{Action, Author}; 311 use crate::event::{Action, Author};
301 312
302 #[test] 313 #[test]
303 fn signed_event_flatten_round_trip() { 314 fn detached_signature_round_trip() {
304 let event = Event { 315 let event = Event {
305 timestamp: "2026-03-21T00:00:00Z".to_string(), 316 timestamp: "2026-03-21T00:00:00Z".to_string(),
306 author: Author { 317 author: Author {
@@ -311,19 +322,16 @@ mod tests {
311 title: "Test".to_string(), 322 title: "Test".to_string(),
312 body: "Body".to_string(), 323 body: "Body".to_string(),
313 }, 324 },
325 clock: 0,
314 }; 326 };
315 let signed = SignedEvent { 327
316 event, 328 let sk = SigningKey::generate(&mut rand_core::OsRng);
317 signature: "dGVzdA==".to_string(), 329 let sig = sign_event(&event, &sk).unwrap();
318 pubkey: "cHVia2V5".to_string(), 330
319 }; 331 assert!(!sig.signature.is_empty());
320 let json = serde_json::to_string_pretty(&signed).unwrap(); 332 assert!(!sig.pubkey.is_empty());
321 let deserialized: SignedEvent = serde_json::from_str(&json).unwrap(); 333
322 assert_eq!(deserialized.signature, "dGVzdA=="); 334 let status = verify_detached(&event, &sig).unwrap();
323 assert_eq!(deserialized.pubkey, "cHVia2V5"); 335 assert_eq!(status, VerifyStatus::Valid);
324 match deserialized.event.action {
325 Action::IssueOpen { ref title, .. } => assert_eq!(title, "Test"),
326 _ => panic!("Wrong action type after round-trip"),
327 }
328 } 336 }
329 } 337 }
src/state.rs
Old New
@@ -83,10 +83,9 @@ impl IssueState {
83 let events = dag::walk_events(repo, ref_name)?; 83 let events = dag::walk_events(repo, ref_name)?;
84 let mut state: Option<IssueState> = None; 84 let mut state: Option<IssueState> = None;
85 85
86 // Track the timestamp of the latest status-changing event so that 86 // Track the (clock, commit_oid_hex) of the latest status-changing event.
87 // concurrent close/reopen conflicts resolve deterministically: 87 // Higher clock wins; on tie, lexicographically higher OID wins.
88 // the event with the later timestamp wins. 88 let mut status_key: Option<(u64, String)> = None;
89 let mut status_ts: Option<String> = None;
90 89
91 for (oid, event) in events { 90 for (oid, event) in events {
92 match event.action { 91 match event.action {
@@ -117,11 +116,12 @@ impl IssueState {
117 } 116 }
118 Action::IssueClose { reason } => { 117 Action::IssueClose { reason } => {
119 if let Some(ref mut s) = state { 118 if let Some(ref mut s) = state {
120 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { 119 let key = (event.clock, oid.to_string());
120 if status_key.as_ref().is_none_or(|k| key >= *k) {
121 s.status = IssueStatus::Closed; 121 s.status = IssueStatus::Closed;
122 s.close_reason = reason; 122 s.close_reason = reason;
123 s.closed_by = Some(oid); 123 s.closed_by = Some(oid);
124 status_ts = Some(event.timestamp.clone()); 124 status_key = Some(key);
125 } 125 }
126 } 126 }
127 } 127 }
@@ -161,11 +161,12 @@ impl IssueState {
161 } 161 }
162 Action::IssueReopen => { 162 Action::IssueReopen => {
163 if let Some(ref mut s) = state { 163 if let Some(ref mut s) = state {
164 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { 164 let key = (event.clock, oid.to_string());
165 if status_key.as_ref().is_none_or(|k| key >= *k) {
165 s.status = IssueStatus::Open; 166 s.status = IssueStatus::Open;
166 s.close_reason = None; 167 s.close_reason = None;
167 s.closed_by = None; 168 s.closed_by = None;
168 status_ts = Some(event.timestamp.clone()); 169 status_key = Some(key);
169 } 170 }
170 } 171 }
171 } 172 }
@@ -209,7 +210,7 @@ impl PatchState {
209 let events = dag::walk_events(repo, ref_name)?; 210 let events = dag::walk_events(repo, ref_name)?;
210 let mut state: Option<PatchState> = None; 211 let mut state: Option<PatchState> = None;
211 212
212 let mut status_ts: Option<String> = None; 213 let mut status_key: Option<(u64, String)> = None;
213 214
214 for (oid, event) in events { 215 for (oid, event) in events {
215 match event.action { 216 match event.action {
@@ -275,17 +276,19 @@ impl PatchState {
275 } 276 }
276 Action::PatchClose { .. } => { 277 Action::PatchClose { .. } => {
277 if let Some(ref mut s) = state { 278 if let Some(ref mut s) = state {
278 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { 279 let key = (event.clock, oid.to_string());
280 if status_key.as_ref().is_none_or(|k| key >= *k) {
279 s.status = PatchStatus::Closed; 281 s.status = PatchStatus::Closed;
280 status_ts = Some(event.timestamp.clone()); 282 status_key = Some(key);
281 } 283 }
282 } 284 }
283 } 285 }
284 Action::PatchMerge => { 286 Action::PatchMerge => {
285 if let Some(ref mut s) = state { 287 if let Some(ref mut s) = state {
286 if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { 288 let key = (event.clock, oid.to_string());
289 if status_key.as_ref().is_none_or(|k| key >= *k) {
287 s.status = PatchStatus::Merged; 290 s.status = PatchStatus::Merged;
288 status_ts = Some(event.timestamp.clone()); 291 status_key = Some(key);
289 } 292 }
290 } 293 }
291 } 294 }
src/sync.rs
Old New
@@ -1,13 +1,258 @@
1 use std::fs;
2 use std::path::{Path, PathBuf};
1 use std::process::Command; 3 use std::process::Command;
2 4
3 use git2::Repository; 5 use git2::Repository;
6 use serde::{Deserialize, Serialize};
4 7
5 use crate::dag; 8 use crate::dag;
6 use crate::error::Error; 9 use crate::error::Error;
7 use crate::identity::get_author; 10 use crate::identity::get_author;
8 use crate::signing; 11 use crate::signing;
12 use crate::sync_lock::SyncLock;
9 use crate::trust; 13 use crate::trust;
10 14
15 // ---------------------------------------------------------------------------
16 // Ref name validation
17 // ---------------------------------------------------------------------------
18
19 /// Validate that a collab ref ID is a valid 40-character lowercase hex string.
20 /// This prevents path traversal, null byte injection, and other malicious ref names.
21 pub fn validate_collab_ref_id(id: &str) -> Result<(), Error> {
22 if id.len() != 40 {
23 return Err(Error::InvalidRefName(format!(
24 "ref ID must be exactly 40 characters, got {}",
25 id.len()
26 )));
27 }
28 if !id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
29 return Err(Error::InvalidRefName(format!(
30 "ref ID must contain only lowercase hex characters [0-9a-f], got {:?}",
31 id
32 )));
33 }
34 Ok(())
35 }
36
37 // ---------------------------------------------------------------------------
38 // Per-ref push types (T002a)
39 // ---------------------------------------------------------------------------
40
41 /// Status of pushing a single ref.
42 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43 pub enum PushStatus {
44 Pushed,
45 Failed,
46 }
47
48 /// Result of pushing a single ref to the remote.
49 #[derive(Debug, Clone, Serialize, Deserialize)]
50 pub struct RefPushResult {
51 pub ref_name: String,
52 pub status: PushStatus,
53 pub error: Option<String>,
54 }
55
56 /// Aggregated outcome of the entire push phase.
57 #[derive(Debug, Clone)]
58 pub struct SyncResult {
59 pub results: Vec<RefPushResult>,
60 pub remote: String,
61 }
62
63 impl SyncResult {
64 pub fn succeeded(&self) -> Vec<&RefPushResult> {
65 self.results
66 .iter()
67 .filter(|r| r.status == PushStatus::Pushed)
68 .collect()
69 }
70
71 pub fn failed(&self) -> Vec<&RefPushResult> {
72 self.results
73 .iter()
74 .filter(|r| r.status == PushStatus::Failed)
75 .collect()
76 }
77
78 pub fn is_complete(&self) -> bool {
79 self.results.iter().all(|r| r.status == PushStatus::Pushed)
80 }
81 }
82
83 // ---------------------------------------------------------------------------
84 // Persistent sync state (T002b)
85 // ---------------------------------------------------------------------------
86
87 /// Persistent record of a partially-completed sync.
88 #[derive(Debug, Clone, Serialize, Deserialize)]
89 pub struct SyncState {
90 pub remote: String,
91 pub pending_refs: Vec<(String, String)>,
92 pub timestamp: String,
93 }
94
95 impl SyncState {
96 /// Path to the sync state file within a repo.
97 fn path(repo: &Repository) -> PathBuf {
98 let git_dir = repo.path();
99 git_dir.join("collab").join("sync-state.json")
100 }
101
102 /// Load sync state from disk. Returns None if the file does not exist.
103 /// If the file is corrupted, prints a warning, deletes it, and returns None.
104 pub fn load(repo: &Repository) -> Option<SyncState> {
105 let path = Self::path(repo);
106 if !path.exists() {
107 return None;
108 }
109 match fs::read_to_string(&path) {
110 Ok(contents) => match serde_json::from_str(&contents) {
111 Ok(state) => Some(state),
112 Err(e) => {
113 eprintln!(
114 "warning: corrupted sync state file ({}); ignoring and proceeding with full sync",
115 e
116 );
117 let _ = fs::remove_file(&path);
118 None
119 }
120 },
121 Err(_) => None,
122 }
123 }
124
125 /// Save sync state to disk.
126 pub fn save(&self, repo: &Repository) -> Result<(), Error> {
127 let path = Self::path(repo);
128 if let Some(parent) = path.parent() {
129 fs::create_dir_all(parent)?;
130 }
131 let json = serde_json::to_string_pretty(self)?;
132 fs::write(&path, json)?;
133 Ok(())
134 }
135
136 /// Delete the sync state file.
137 pub fn clear(repo: &Repository) -> Result<(), Error> {
138 let path = Self::path(repo);
139 if path.exists() {
140 fs::remove_file(&path)?;
141 }
142 Ok(())
143 }
144 }
145
146 // ---------------------------------------------------------------------------
147 // Push helpers (T003, T004)
148 // ---------------------------------------------------------------------------
149
150 /// Push a single ref to the remote. Returns a RefPushResult.
151 fn push_ref(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult {
152 let refspec = format!("+{}:{}", ref_name, ref_name);
153 match Command::new("git")
154 .args(["push", remote_name, &refspec])
155 .current_dir(workdir)
156 .output()
157 {
158 Ok(output) => {
159 if output.status.success() {
160 RefPushResult {
161 ref_name: ref_name.to_string(),
162 status: PushStatus::Pushed,
163 error: None,
164 }
165 } else {
166 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
167 RefPushResult {
168 ref_name: ref_name.to_string(),
169 status: PushStatus::Failed,
170 error: Some(stderr),
171 }
172 }
173 }
174 Err(e) => RefPushResult {
175 ref_name: ref_name.to_string(),
176 status: PushStatus::Failed,
177 error: Some(format!("failed to run git push: {}", e)),
178 },
179 }
180 }
181
182 /// Collect all collab ref names that should be pushed.
183 fn collect_push_refs(repo: &Repository) -> Result<Vec<String>, Error> {
184 let mut refs = Vec::new();
185 for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] {
186 let iter = repo.references_glob(pattern)?;
187 for r in iter {
188 let r = r?;
189 if let Some(name) = r.name() {
190 refs.push(name.to_string());
191 }
192 }
193 }
194 Ok(refs)
195 }
196
197 // ---------------------------------------------------------------------------
198 // Summary printing (T012)
199 // ---------------------------------------------------------------------------
200
201 /// Print a failure summary to stderr.
202 fn print_sync_summary(result: &SyncResult) {
203 let succeeded = result.succeeded().len();
204 let total = result.results.len();
205
206 if succeeded == 0 {
207 eprintln!("\nSync failed: {} of {} refs pushed.", succeeded, total);
208 } else {
209 eprintln!(
210 "\nSync partially failed: {} of {} refs pushed.",
211 succeeded, total
212 );
213 }
214
215 let failed = result.failed();
216 eprintln!("Failed refs:");
217 for f in &failed {
218 eprintln!(
219 " {}: {}",
220 f.ref_name,
221 f.error.as_deref().unwrap_or("unknown error")
222 );
223 }
224
225 eprintln!(
226 "\nRun `collab sync {}` again to retry {} failed ref(s).",
227 result.remote,
228 failed.len()
229 );
230 }
231
232 /// Clean up refs/collab/sync/* refs.
233 fn cleanup_sync_refs(repo: &Repository) -> Result<(), Error> {
234 for prefix in &["refs/collab/sync/issues/", "refs/collab/sync/patches/"] {
235 let refs: Vec<String> = repo
236 .references_glob(&format!("{}*", prefix))?
237 .filter_map(|r| r.ok()?.name().map(|n| n.to_string()))
238 .collect();
239 for ref_name in refs {
240 let mut r = repo.find_reference(&ref_name)?;
241 r.delete()?;
242 }
243 }
244 // Also clean up any refs under refs/collab/sync/ with remote-name prefix
245 let refs: Vec<String> = repo
246 .references_glob("refs/collab/sync/*")?
247 .filter_map(|r| r.ok()?.name().map(|n| n.to_string()))
248 .collect();
249 for ref_name in refs {
250 let mut r = repo.find_reference(&ref_name)?;
251 r.delete()?;
252 }
253 Ok(())
254 }
255
11 /// Add collab refspecs to all remotes. 256 /// Add collab refspecs to all remotes.
12 pub fn init(repo: &Repository) -> Result<(), Error> { 257 pub fn init(repo: &Repository) -> Result<(), Error> {
13 let remotes = repo.remotes()?; 258 let remotes = repo.remotes()?;
@@ -26,9 +271,21 @@ pub fn init(repo: &Repository) -> Result<(), Error> {
26 271
27 /// Sync with a specific remote: fetch, reconcile, push. 272 /// Sync with a specific remote: fetch, reconcile, push.
28 pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { 273 pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
29 let author = get_author(repo)?; 274 // Acquire advisory lock — held until _lock is dropped (RAII)
275 let _lock = SyncLock::acquire(repo)?;
276
30 let workdir = repo.path().parent().unwrap_or(repo.path()).to_path_buf(); 277 let workdir = repo.path().parent().unwrap_or(repo.path()).to_path_buf();
31 278
279 // Check for existing sync state (resume mode)
280 if let Some(state) = SyncState::load(repo) {
281 if state.remote == remote_name {
282 return sync_resume(repo, remote_name, &workdir, &state);
283 }
284 // State is for a different remote — ignore it, run full sync
285 }
286
287 let author = get_author(repo)?;
288
32 // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.) 289 // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.)
33 println!("Fetching from '{}'...", remote_name); 290 println!("Fetching from '{}'...", remote_name);
34 let fetch_status = Command::new("git") 291 let fetch_status = Command::new("git")
@@ -56,57 +313,146 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
56 reconcile_refs(&repo, "issues", &author, &sk)?; 313 reconcile_refs(&repo, "issues", &author, &sk)?;
57 reconcile_refs(&repo, "patches", &author, &sk)?; 314 reconcile_refs(&repo, "patches", &author, &sk)?;
58 315
59 // Step 3: Push collab refs using system git 316 // Step 3: Push collab refs individually
60 println!("Pushing to '{}'...", remote_name); 317 println!("Pushing to '{}'...", remote_name);
61 let mut push_args = vec!["push", remote_name]; 318 let refs_to_push = collect_push_refs(&repo)?;
62 let mut refspecs: Vec<String> = Vec::new();
63
64 for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] {
65 let refs = repo.references_glob(pattern)?;
66 for r in refs {
67 let r = r?;
68 if let Some(name) = r.name() {
69 refspecs.push(format!("+{}:{}", name, name));
70 }
71 }
72 }
73 319
74 if refspecs.is_empty() { 320 if refs_to_push.is_empty() {
75 println!("Nothing to push."); 321 println!("Nothing to push.");
76 } else { 322 } else {
77 let refspec_strs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect(); 323 let sync_result = push_refs_individually(&workdir, remote_name, &refs_to_push);
78 push_args.extend(refspec_strs); 324
79 325 if !sync_result.is_complete() {
80 let push_status = Command::new("git") 326 // Save state for resume
81 .args(&push_args) 327 let now = chrono::Utc::now().to_rfc3339();
82 .current_dir(&workdir) 328 let pending: Vec<(String, String)> = sync_result
83 .status() 329 .failed()
84 .map_err(|e| Error::Cmd(format!("failed to run git push: {}", e)))?; 330 .iter()
85 331 .map(|r| {
86 if !push_status.success() { 332 (
87 return Err(Error::Cmd(format!( 333 r.ref_name.clone(),
88 "git push exited with status {}", 334 r.error.clone().unwrap_or_else(|| "unknown error".to_string()),
89 push_status 335 )
90 ))); 336 })
337 .collect();
338 let state = SyncState {
339 remote: remote_name.to_string(),
340 pending_refs: pending,
341 timestamp: now,
342 };
343 state.save(&repo)?;
344
345 print_sync_summary(&sync_result);
346
347 // Clean up sync refs before returning error
348 cleanup_sync_refs(&repo)?;
349
350 let succeeded = sync_result.succeeded().len();
351 let total = sync_result.results.len();
352 return Err(Error::PartialSync {
353 succeeded,
354 total,
355 });
91 } 356 }
92 } 357 }
93 358
94 // Step 4: Clean up sync refs 359 // Step 4: Clean up sync refs
95 for prefix in &["refs/collab/sync/issues/", "refs/collab/sync/patches/"] { 360 cleanup_sync_refs(&repo)?;
96 let refs: Vec<String> = repo
97 .references_glob(&format!("{}*", prefix))?
98 .filter_map(|r| r.ok()?.name().map(|n| n.to_string()))
99 .collect();
100 for ref_name in refs {
101 let mut r = repo.find_reference(&ref_name)?;
102 r.delete()?;
103 }
104 }
105 361
106 println!("Sync complete."); 362 println!("Sync complete.");
107 Ok(()) 363 Ok(())
108 } 364 }
109 365
366 /// Resume a partially-failed sync by retrying only the pending refs.
367 fn sync_resume(
368 repo: &Repository,
369 remote_name: &str,
370 workdir: &Path,
371 state: &SyncState,
372 ) -> Result<(), Error> {
373 println!(
374 "Resuming sync to '{}' ({} refs pending from previous failure)...",
375 remote_name,
376 state.pending_refs.len()
377 );
378
379 // Print pending refs with their last error
380 for (ref_name, last_error) in &state.pending_refs {
381 println!(" Pending: {} (last error: {})", ref_name, last_error);
382 }
383
384 // Clean up stale sync refs (T018b)
385 cleanup_sync_refs(repo)?;
386
387 // Push the pending refs
388 let ref_names: Vec<String> = state.pending_refs.iter().map(|(r, _)| r.clone()).collect();
389 let sync_result = push_refs_individually(workdir, remote_name, &ref_names);
390
391 if sync_result.is_complete() {
392 // All pending refs pushed successfully
393 SyncState::clear(repo)?;
394 if sync_result.results.is_empty() {
395 println!("All pending refs are already up to date. Clearing stale sync state.");
396 } else {
397 println!("\nSync complete. All previously-failed refs pushed.");
398 }
399 println!("Sync complete.");
400 Ok(())
401 } else {
402 // Some refs still failing - update state
403 let now = chrono::Utc::now().to_rfc3339();
404 let pending: Vec<(String, String)> = sync_result
405 .failed()
406 .iter()
407 .map(|r| {
408 (
409 r.ref_name.clone(),
410 r.error.clone().unwrap_or_else(|| "unknown error".to_string()),
411 )
412 })
413 .collect();
414 let new_state = SyncState {
415 remote: remote_name.to_string(),
416 pending_refs: pending,
417 timestamp: now,
418 };
419 new_state.save(repo)?;
420
421 print_sync_summary(&sync_result);
422 eprintln!("To force a full sync, delete .git/collab/sync-state.json");
423
424 let succeeded = sync_result.succeeded().len();
425 let total = sync_result.results.len();
426 Err(Error::PartialSync {
427 succeeded,
428 total,
429 })
430 }
431 }
432
433 /// Push refs one at a time, printing per-ref status, and return aggregated results.
434 fn push_refs_individually(workdir: &Path, remote_name: &str, refs: &[String]) -> SyncResult {
435 let mut results = Vec::new();
436 for ref_name in refs {
437 let result = push_ref(workdir, remote_name, ref_name);
438 match &result.status {
439 PushStatus::Pushed => println!(" Pushed {}", ref_name),
440 PushStatus::Failed => {
441 eprintln!(
442 " FAILED {}: {}",
443 ref_name,
444 result.error.as_deref().unwrap_or("unknown error")
445 );
446 }
447 }
448 results.push(result);
449 }
450 SyncResult {
451 results,
452 remote: remote_name.to_string(),
453 }
454 }
455
110 /// Reconcile all refs of a given kind (issues or patches) from sync refs. 456 /// Reconcile all refs of a given kind (issues or patches) from sync refs.
111 fn reconcile_refs( 457 fn reconcile_refs(
112 repo: &Repository, 458 repo: &Repository,
@@ -131,6 +477,12 @@ fn reconcile_refs(
131 let mut warned_unconfigured = false; 477 let mut warned_unconfigured = false;
132 478
133 for (remote_ref, id) in &sync_refs { 479 for (remote_ref, id) in &sync_refs {
480 // Validate the ref ID format before processing
481 if let Err(e) = validate_collab_ref_id(id) {
482 eprintln!(" Skipping {} with invalid ref ID {:.8}: {}", kind, id, e);
483 continue;
484 }
485
134 // Verify all commits on the remote ref before reconciling 486 // Verify all commits on the remote ref before reconciling
135 match signing::verify_ref(repo, remote_ref) { 487 match signing::verify_ref(repo, remote_ref) {
136 Ok(results) => { 488 Ok(results) => {
src/sync_lock.rs
Old New
@@ -0,0 +1,203 @@
1 use std::fs::{self, OpenOptions};
2 use std::io::Write;
3 use std::path::PathBuf;
4 use std::process::Command;
5
6 use serde::{Deserialize, Serialize};
7
8 use crate::error::Error;
9
10 /// Default staleness threshold in minutes.
11 const STALE_THRESHOLD_MINUTES: u64 = 10;
12
13 /// Metadata stored in the lockfile for stale detection and error reporting.
14 #[derive(Debug, Serialize, Deserialize)]
15 pub struct SyncLockInfo {
16 pub pid: u32,
17 pub timestamp: String,
18 }
19
20 impl SyncLockInfo {
21 /// Serialize to JSON string.
22 pub fn to_json(&self) -> String {
23 serde_json::to_string(self).expect("SyncLockInfo serialization should not fail")
24 }
25
26 /// Deserialize from JSON string.
27 pub fn from_json(json: &str) -> Result<Self, Error> {
28 Ok(serde_json::from_str(json)?)
29 }
30 }
31
32 /// RAII guard that owns the lockfile. Deletes the lockfile on drop.
33 #[derive(Debug)]
34 pub struct SyncLock {
35 pub lock_path: PathBuf,
36 }
37
38 impl SyncLock {
39 /// Attempt to acquire the sync lock for the given repository.
40 ///
41 /// Creates `.git/collab/sync.lock` atomically using `create_new(true)`.
42 /// If the lockfile already exists, checks for staleness before returning an error.
43 pub fn acquire(repo: &git2::Repository) -> Result<Self, Error> {
44 let collab_dir = repo.path().join("collab");
45 fs::create_dir_all(&collab_dir)?;
46 let lock_path = collab_dir.join("sync.lock");
47
48 match Self::try_create_lock(&lock_path) {
49 Ok(lock) => Ok(lock),
50 Err(_) => {
51 // Lockfile exists — check if stale
52 Self::handle_existing_lock(&lock_path)
53 }
54 }
55 }
56
57 /// Try to atomically create the lockfile. Returns Err on any failure.
58 fn try_create_lock(lock_path: &PathBuf) -> Result<Self, Error> {
59 let info = SyncLockInfo {
60 pid: std::process::id(),
61 timestamp: chrono::Utc::now().to_rfc3339(),
62 };
63 let json = info.to_json();
64
65 let mut file = OpenOptions::new()
66 .write(true)
67 .create_new(true)
68 .open(lock_path)?;
69 file.write_all(json.as_bytes())?;
70 file.flush()?;
71
72 Ok(SyncLock {
73 lock_path: lock_path.clone(),
74 })
75 }
76
77 /// Handle the case where a lockfile already exists.
78 /// Check staleness and either clean up and retry, or return SyncLocked error.
79 fn handle_existing_lock(lock_path: &PathBuf) -> Result<Self, Error> {
80 let content = match fs::read_to_string(lock_path) {
81 Ok(c) => c,
82 Err(_) => {
83 // Can't read — treat as corrupted/stale
84 Self::remove_stale_and_retry(lock_path)?;
85 return Self::try_create_or_contention(lock_path);
86 }
87 };
88
89 let info = match SyncLockInfo::from_json(&content) {
90 Ok(info) => info,
91 Err(_) => {
92 // Corrupted JSON — treat as stale
93 Self::remove_stale_and_retry(lock_path)?;
94 return Self::try_create_or_contention(lock_path);
95 }
96 };
97
98 if is_stale(&info, STALE_THRESHOLD_MINUTES) {
99 Self::remove_stale_and_retry(lock_path)?;
100 return Self::try_create_or_contention(lock_path);
101 }
102
103 // Lock is held by a live process — return error with enhanced message
104 Err(Error::SyncLocked {
105 pid: info.pid,
106 since: format!(
107 "{} — wait for it to finish, or remove .git/collab/sync.lock if the process is no longer running",
108 format_lock_age(&info.timestamp)
109 ),
110 })
111 }
112
113 /// Remove the stale lockfile. Ignores errors if the file is already gone.
114 fn remove_stale_and_retry(lock_path: &PathBuf) -> Result<(), Error> {
115 eprintln!("Removing stale sync lock...");
116 let _ = fs::remove_file(lock_path);
117 Ok(())
118 }
119
120 /// Try to create the lock; if it fails (race with another process), read the
121 /// new holder's info and return SyncLocked.
122 fn try_create_or_contention(lock_path: &PathBuf) -> Result<Self, Error> {
123 match Self::try_create_lock(lock_path) {
124 Ok(lock) => Ok(lock),
125 Err(_) => {
126 // Another process won the race — read the new lock info
127 let content = fs::read_to_string(lock_path).unwrap_or_default();
128 let info = SyncLockInfo::from_json(&content).unwrap_or(SyncLockInfo {
129 pid: 0,
130 timestamp: String::new(),
131 });
132 Err(Error::SyncLocked {
133 pid: info.pid,
134 since: format!(
135 "{} — wait for it to finish, or remove .git/collab/sync.lock if the process is no longer running",
136 format_lock_age(&info.timestamp)
137 ),
138 })
139 }
140 }
141 }
142 }
143
144 impl Drop for SyncLock {
145 fn drop(&mut self) {
146 let _ = fs::remove_file(&self.lock_path);
147 }
148 }
149
150 /// Check if a process with the given PID is alive using `kill -0`.
151 pub fn is_process_alive(pid: u32) -> bool {
152 Command::new("kill")
153 .args(["-0", &pid.to_string()])
154 .stdout(std::process::Stdio::null())
155 .stderr(std::process::Stdio::null())
156 .status()
157 .map(|s| s.success())
158 .unwrap_or(false)
159 }
160
161 /// Check if a lock is stale based on PID liveness and timestamp age.
162 pub fn is_stale(info: &SyncLockInfo, threshold_minutes: u64) -> bool {
163 // If PID is dead, it's stale
164 if !is_process_alive(info.pid) {
165 return true;
166 }
167
168 // If the lock is older than the threshold, treat as stale (PID reuse protection)
169 if let Ok(lock_time) = chrono::DateTime::parse_from_rfc3339(&info.timestamp) {
170 let age = chrono::Utc::now() - lock_time.with_timezone(&chrono::Utc);
171 if age.num_minutes() >= threshold_minutes as i64 {
172 return true;
173 }
174 } else {
175 // Can't parse timestamp — treat as stale
176 return true;
177 }
178
179 false
180 }
181
182 /// Convert an RFC 3339 timestamp to a human-readable duration (e.g., "3 seconds ago").
183 pub fn format_lock_age(timestamp: &str) -> String {
184 let lock_time = match chrono::DateTime::parse_from_rfc3339(timestamp) {
185 Ok(t) => t,
186 Err(_) => return format!("unknown time ({})", timestamp),
187 };
188
189 let age = chrono::Utc::now() - lock_time.with_timezone(&chrono::Utc);
190 let secs = age.num_seconds();
191
192 if secs < 0 {
193 "just now".to_string()
194 } else if secs < 60 {
195 format!("{} second{} ago", secs, if secs == 1 { "" } else { "s" })
196 } else if secs < 3600 {
197 let mins = secs / 60;
198 format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" })
199 } else {
200 let hours = secs / 3600;
201 format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
202 }
203 }
src/tui.rs
Old New
@@ -1991,6 +1991,7 @@ mod tests {
1991 title: "Test Issue".to_string(), 1991 title: "Test Issue".to_string(),
1992 body: "This is the body".to_string(), 1992 body: "This is the body".to_string(),
1993 }, 1993 },
1994 clock: 0,
1994 }, 1995 },
1995 ), 1996 ),
1996 ( 1997 (
@@ -2004,6 +2005,7 @@ mod tests {
2004 action: Action::IssueComment { 2005 action: Action::IssueComment {
2005 body: "A comment on the issue".to_string(), 2006 body: "A comment on the issue".to_string(),
2006 }, 2007 },
2008 clock: 0,
2007 }, 2009 },
2008 ), 2010 ),
2009 ( 2011 (
@@ -2014,6 +2016,7 @@ mod tests {
2014 action: Action::IssueClose { 2016 action: Action::IssueClose {
2015 reason: Some("fixed".to_string()), 2017 reason: Some("fixed".to_string()),
2016 }, 2018 },
2019 clock: 0,
2017 }, 2020 },
2018 ), 2021 ),
2019 ] 2022 ]
@@ -2097,6 +2100,7 @@ mod tests {
2097 title: "My Issue".to_string(), 2100 title: "My Issue".to_string(),
2098 body: "Description here".to_string(), 2101 body: "Description here".to_string(),
2099 }, 2102 },
2103 clock: 0,
2100 }; 2104 };
2101 let detail = format_event_detail(&oid, &event); 2105 let detail = format_event_detail(&oid, &event);
2102 assert!(detail.contains("aaaaaaa")); 2106 assert!(detail.contains("aaaaaaa"));
@@ -2116,6 +2120,7 @@ mod tests {
2116 action: Action::IssueClose { 2120 action: Action::IssueClose {
2117 reason: Some("resolved".to_string()), 2121 reason: Some("resolved".to_string()),
2118 }, 2122 },
2123 clock: 0,
2119 }; 2124 };
2120 let detail = format_event_detail(&oid, &event); 2125 let detail = format_event_detail(&oid, &event);
2121 assert!(detail.contains("Issue Close")); 2126 assert!(detail.contains("Issue Close"));
@@ -2132,6 +2137,7 @@ mod tests {
2132 verdict: ReviewVerdict::Approve, 2137 verdict: ReviewVerdict::Approve,
2133 body: "Looks good!".to_string(), 2138 body: "Looks good!".to_string(),
2134 }, 2139 },
2140 clock: 0,
2135 }; 2141 };
2136 let detail = format_event_detail(&oid, &event); 2142 let detail = format_event_detail(&oid, &event);
2137 assert!(detail.contains("Patch Review")); 2143 assert!(detail.contains("Patch Review"));
@@ -2146,6 +2152,7 @@ mod tests {
2146 timestamp: "2026-01-01T00:00:00Z".to_string(), 2152 timestamp: "2026-01-01T00:00:00Z".to_string(),
2147 author: test_author(), 2153 author: test_author(),
2148 action: Action::IssueReopen, 2154 action: Action::IssueReopen,
2155 clock: 0,
2149 }; 2156 };
2150 let detail = format_event_detail(&oid, &event); 2157 let detail = format_event_detail(&oid, &event);
2151 assert!(detail.contains("1234567")); 2158 assert!(detail.contains("1234567"));
tests/adversarial_test.rs
Old New
@@ -0,0 +1,699 @@
1 //! Adversarial & fuzz testing for untrusted input.
2 //!
3 //! Tests that malformed JSON, corrupted git trees, oversized payloads,
4 //! and malicious ref names are all handled gracefully (Result::Err, never panic).
5
6 mod common;
7
8 use git2::Repository;
9 use tempfile::TempDir;
10
11 use git_collab::dag::{self, MAX_EVENT_BLOB_SIZE};
12 use git_collab::event::{Action, Author, Event};
13 use git_collab::sync::validate_collab_ref_id;
14
15 use common::{alice, init_repo, now};
16
17 // ===========================================================================
18 // Helper functions
19 // ===========================================================================
20
21 /// Create a git commit whose tree contains an event.json blob with the given
22 /// raw bytes. Returns (TempDir, repo, ref_name) so callers can use dag::walk_events.
23 fn repo_with_blob(content: &[u8]) -> (TempDir, Repository, String) {
24 let tmp = TempDir::new().unwrap();
25 let repo = init_repo(tmp.path(), &alice());
26
27 let oid = {
28 let blob_oid = repo.blob(content).unwrap();
29 let manifest = br#"{"version":1,"format":"git-collab"}"#;
30 let manifest_blob = repo.blob(manifest).unwrap();
31
32 let mut tb = repo.treebuilder(None).unwrap();
33 tb.insert("event.json", blob_oid, 0o100644).unwrap();
34 tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
35 let tree_oid = tb.write().unwrap();
36 drop(tb);
37 let tree = repo.find_tree(tree_oid).unwrap();
38
39 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
40 repo.commit(None, &sig, &sig, "adversarial test", &tree, &[])
41 .unwrap()
42 };
43
44 let ref_name = format!("refs/collab/issues/{}", oid);
45 repo.reference(&ref_name, oid, false, "test").unwrap();
46
47 (tmp, repo, ref_name)
48 }
49
50 /// Create a git commit with a custom tree structure. The closure receives
51 /// the repo and must return a tree OID.
52 /// Returns (TempDir, repo, ref_name).
53 fn repo_with_custom_tree<F>(builder_fn: F) -> (TempDir, Repository, String)
54 where
55 F: FnOnce(&Repository) -> git2::Oid,
56 {
57 let tmp = TempDir::new().unwrap();
58 let repo = init_repo(tmp.path(), &alice());
59
60 let oid = {
61 let tree_oid = builder_fn(&repo);
62 let tree = repo.find_tree(tree_oid).unwrap();
63
64 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
65 repo.commit(None, &sig, &sig, "custom tree test", &tree, &[])
66 .unwrap()
67 };
68
69 let ref_name = format!("refs/collab/issues/{}", oid);
70 repo.reference(&ref_name, oid, false, "test").unwrap();
71
72 (tmp, repo, ref_name)
73 }
74
75 /// Build a valid Event JSON string for use in tests.
76 fn valid_event_json() -> String {
77 serde_json::to_string(&Event {
78 timestamp: now(),
79 author: alice(),
80 action: Action::IssueOpen {
81 title: "Test".to_string(),
82 body: "Body".to_string(),
83 },
84 clock: 1,
85 })
86 .unwrap()
87 }
88
89 // ===========================================================================
90 // Phase 3: User Story 1 — Resilient Event Parsing
91 // ===========================================================================
92
93 #[test]
94 fn invalid_json_returns_error() {
95 let (_tmp, repo, ref_name) = repo_with_blob(b"{not valid json");
96 let result = dag::walk_events(&repo, &ref_name);
97 assert!(result.is_err(), "invalid JSON should return Err");
98 }
99
100 #[test]
101 fn empty_blob_returns_error() {
102 let (_tmp, repo, ref_name) = repo_with_blob(b"");
103 let result = dag::walk_events(&repo, &ref_name);
104 assert!(result.is_err(), "empty blob should return Err");
105 }
106
107 #[test]
108 fn missing_type_field_returns_error() {
109 let json = br#"{"timestamp":"t","author":{"name":"a","email":"e"}}"#;
110 let (_tmp, repo, ref_name) = repo_with_blob(json);
111 let result = dag::walk_events(&repo, &ref_name);
112 assert!(result.is_err(), "missing action/type field should return Err");
113 }
114
115 #[test]
116 fn unknown_action_type_returns_error() {
117 let json =
118 br#"{"timestamp":"t","author":{"name":"a","email":"e"},"action":{"type":"UnknownAction"}}"#;
119 let (_tmp, repo, ref_name) = repo_with_blob(json);
120 let result = dag::walk_events(&repo, &ref_name);
121 assert!(result.is_err(), "unknown action type should return Err");
122 }
123
124 #[test]
125 fn missing_required_action_fields_returns_error() {
126 // IssueOpen requires title and body
127 let json =
128 br#"{"timestamp":"t","author":{"name":"a","email":"e"},"action":{"type":"issue.open"}}"#;
129 let (_tmp, repo, ref_name) = repo_with_blob(json);
130 let result = dag::walk_events(&repo, &ref_name);
131 assert!(
132 result.is_err(),
133 "IssueOpen missing title/body should return Err"
134 );
135 }
136
137 #[test]
138 fn wrong_field_types_returns_error() {
139 // timestamp as number, author as string instead of object
140 let json = br#"{"timestamp":123,"author":"not-object","action":{"type":"issue.open"}}"#;
141 let (_tmp, repo, ref_name) = repo_with_blob(json);
142 let result = dag::walk_events(&repo, &ref_name);
143 assert!(result.is_err(), "wrong field types should return Err");
144 }
145
146 #[test]
147 fn valid_json_wrong_schema_returns_error() {
148 let json = br#"{"name":"package","version":"1.0"}"#;
149 let (_tmp, repo, ref_name) = repo_with_blob(json);
150 let result = dag::walk_events(&repo, &ref_name);
151 assert!(
152 result.is_err(),
153 "valid JSON with wrong schema should return Err"
154 );
155 }
156
157 #[test]
158 fn deeply_nested_json_returns_error() {
159 // Build 1000-level nested JSON: {"a":{"a":{"a":...}}}
160 let mut json = String::from(r#"{"a":"#);
161 for _ in 0..999 {
162 json.push_str(r#"{"a":"#);
163 }
164 json.push('1');
165 for _ in 0..1000 {
166 json.push('}');
167 }
168
169 let (_tmp, repo, ref_name) = repo_with_blob(json.as_bytes());
170 // serde_json has a recursion limit of 128; this should either return Err
171 // or panic (which we catch). Either way, it must not corrupt state.
172 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
173 dag::walk_events(&repo, &ref_name)
174 }));
175 match result {
176 Ok(Err(_)) => {} // Clean error — good
177 Ok(Ok(_)) => panic!("deeply nested JSON should not parse as a valid Event"),
178 Err(_) => {} // Panic caught — acceptable (serde stack overflow)
179 }
180 }
181
182 #[test]
183 fn null_bytes_in_json_string_returns_error_or_ok() {
184 // Valid Event JSON but with null bytes embedded in string fields.
185 // serde_json accepts null bytes in strings, so this may succeed. Either is acceptable.
186 let json = r#"{"timestamp":"2024-01-01T00:00:00Z","author":{"name":"a\u0000b","email":"e"},"action":{"type":"issue.open","title":"t\u0000t","body":"b"},"clock":1}"#;
187 let (_tmp, repo, ref_name) = repo_with_blob(json.as_bytes());
188 let result = dag::walk_events(&repo, &ref_name);
189 // Either Ok or Err is acceptable; the key requirement is no panic.
190 let _ = result;
191 }
192
193 // ===========================================================================
194 // Phase 4: User Story 2 — Corrupted Git Tree Structures
195 // ===========================================================================
196
197 #[test]
198 fn missing_event_json_entry_returns_error() {
199 // Create a commit with an empty tree (no event.json entry)
200 let (_tmp, repo, ref_name) = repo_with_custom_tree(|repo| {
201 let tb = repo.treebuilder(None).unwrap();
202 tb.write().unwrap()
203 });
204 let result = dag::walk_events(&repo, &ref_name);
205 assert!(result.is_err(), "missing event.json should return Err");
206 let err_msg = format!("{}", result.unwrap_err());
207 assert!(
208 err_msg.contains("missing event.json"),
209 "error should mention missing event.json, got: {}",
210 err_msg
211 );
212 }
213
214 #[test]
215 fn event_json_points_to_tree_returns_error() {
216 // Create a commit where event.json entry points to a tree object instead of a blob
217 let (_tmp, repo, ref_name) = repo_with_custom_tree(|repo| {
218 // Create an inner empty tree
219 let inner_tb = repo.treebuilder(None).unwrap();
220 let inner_tree_oid = inner_tb.write().unwrap();
221 // Insert it as "event.json" but pointing to a tree
222 let mut tb = repo.treebuilder(None).unwrap();
223 tb.insert("event.json", inner_tree_oid, 0o040000).unwrap();
224 tb.write().unwrap()
225 });
226 let result = dag::walk_events(&repo, &ref_name);
227 assert!(
228 result.is_err(),
229 "event.json pointing to tree should return Err"
230 );
231 }
232
233 #[test]
234 fn extra_entries_in_tree_still_works() {
235 // Create a commit with event.json plus extra entries — should still parse OK
236 let json = valid_event_json();
237 let (_tmp, repo, ref_name) = repo_with_custom_tree(|repo| {
238 let blob_oid = repo.blob(json.as_bytes()).unwrap();
239
240 let extra = repo.blob(b"extra content").unwrap();
241 let extra2 = repo.blob(b"more stuff").unwrap();
242 let manifest = repo
243 .blob(br#"{"version":1,"format":"git-collab"}"#)
244 .unwrap();
245
246 let mut tb = repo.treebuilder(None).unwrap();
247 tb.insert("event.json", blob_oid, 0o100644).unwrap();
248 tb.insert("README.md", extra, 0o100644).unwrap();
249 tb.insert("random.txt", extra2, 0o100644).unwrap();
250 tb.insert("manifest.json", manifest, 0o100644).unwrap();
251 tb.write().unwrap()
252 });
253 let result = dag::walk_events(&repo, &ref_name);
254 assert!(
255 result.is_ok(),
256 "extra entries should not prevent parsing: {:?}",
257 result.err()
258 );
259 let events = result.unwrap();
260 assert_eq!(events.len(), 1);
261 }
262
263 #[test]
264 fn dag_with_one_corrupted_commit_in_middle() {
265 // Create a 3-commit chain where the middle commit has invalid event.json
266 let tmp = TempDir::new().unwrap();
267 let repo = init_repo(tmp.path(), &alice());
268 let sk = common::test_signing_key();
269
270 // Commit 1: valid
271 let event1 = Event {
272 timestamp: now(),
273 author: alice(),
274 action: Action::IssueOpen {
275 title: "Test".to_string(),
276 body: "Body".to_string(),
277 },
278 clock: 0,
279 };
280 let oid1 = dag::create_root_event(&repo, &event1, &sk).unwrap();
281 let ref_name = format!("refs/collab/issues/{}", oid1);
282 repo.reference(&ref_name, oid1, false, "test").unwrap();
283
284 // Commit 2: corrupted (invalid JSON)
285 let bad_blob = repo.blob(b"not json").unwrap();
286 let manifest_blob = repo
287 .blob(br#"{"version":1,"format":"git-collab"}"#)
288 .unwrap();
289 let mut tb = repo.treebuilder(None).unwrap();
290 tb.insert("event.json", bad_blob, 0o100644).unwrap();
291 tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
292 let tree_oid = tb.write().unwrap();
293 let tree = repo.find_tree(tree_oid).unwrap();
294 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
295 let parent1 = repo.find_commit(oid1).unwrap();
296 let oid2 = repo
297 .commit(None, &sig, &sig, "bad commit", &tree, &[&parent1])
298 .unwrap();
299
300 // Commit 3: valid (child of corrupted)
301 let good_blob = repo.blob(valid_event_json().as_bytes()).unwrap();
302 let manifest_blob2 = repo
303 .blob(br#"{"version":1,"format":"git-collab"}"#)
304 .unwrap();
305 let mut tb = repo.treebuilder(None).unwrap();
306 tb.insert("event.json", good_blob, 0o100644).unwrap();
307 tb.insert("manifest.json", manifest_blob2, 0o100644).unwrap();
308 let tree_oid = tb.write().unwrap();
309 let tree = repo.find_tree(tree_oid).unwrap();
310 let parent2 = repo.find_commit(oid2).unwrap();
311 let oid3 = repo
312 .commit(None, &sig, &sig, "good commit 3", &tree, &[&parent2])
313 .unwrap();
314
315 // Update ref to point to tip
316 repo.reference(&ref_name, oid3, true, "update tip").unwrap();
317
318 let result = dag::walk_events(&repo, &ref_name);
319 assert!(
320 result.is_err(),
321 "DAG with corrupted middle commit should return Err"
322 );
323 }
324
325 // ===========================================================================
326 // Phase 5: User Story 3 — Oversized Payload Rejection
327 // ===========================================================================
328
329 #[test]
330 fn oversized_blob_returns_error() {
331 // 2 MB blob
332 let content = vec![b' '; 2 * 1024 * 1024];
333 let (_tmp, repo, ref_name) = repo_with_blob(&content);
334 let result = dag::walk_events(&repo, &ref_name);
335 assert!(result.is_err(), "2 MB blob should return Err");
336 let err_msg = format!("{}", result.unwrap_err());
337 assert!(
338 err_msg.contains("exceeds") || err_msg.contains("too large") || err_msg.contains("limit"),
339 "error should mention size limit, got: {}",
340 err_msg
341 );
342 }
343
344 #[test]
345 fn blob_at_limit_succeeds() {
346 // Build a valid event JSON padded to just under 1 MB by making the body field large.
347 let base = valid_event_json();
348 let padding_needed = MAX_EVENT_BLOB_SIZE - base.len() - 1; // -1 to stay under
349 let body_padding = " ".repeat(padding_needed.saturating_sub(100));
350 let event = Event {
351 timestamp: now(),
352 author: alice(),
353 action: Action::IssueOpen {
354 title: "Test".to_string(),
355 body: body_padding,
356 },
357 clock: 1,
358 };
359 let padded = serde_json::to_string(&event).unwrap();
360 // Ensure we're under the limit
361 assert!(
362 padded.len() <= MAX_EVENT_BLOB_SIZE,
363 "padded event is {} bytes, limit is {}",
364 padded.len(),
365 MAX_EVENT_BLOB_SIZE
366 );
367
368 let (_tmp, repo, ref_name) = repo_with_blob(padded.as_bytes());
369 let result = dag::walk_events(&repo, &ref_name);
370 assert!(
371 result.is_ok(),
372 "blob at/under limit should succeed: {:?}",
373 result.err()
374 );
375 }
376
377 #[test]
378 fn blob_just_over_limit_returns_error() {
379 // Exactly 1,048,577 bytes (MAX_EVENT_BLOB_SIZE + 1)
380 let content = vec![b'x'; MAX_EVENT_BLOB_SIZE + 1];
381 let (_tmp, repo, ref_name) = repo_with_blob(&content);
382 let result = dag::walk_events(&repo, &ref_name);
383 assert!(
384 result.is_err(),
385 "blob just over limit should return Err"
386 );
387 }
388
389 // ===========================================================================
390 // Phase 6: User Story 4 — Malicious Ref Name Validation
391 // ===========================================================================
392
393 #[test]
394 fn ref_id_with_path_traversal_rejected() {
395 let result = validate_collab_ref_id("../../HEAD");
396 assert!(result.is_err(), "path traversal should be rejected");
397 }
398
399 #[test]
400 fn ref_id_with_null_bytes_rejected() {
401 let result = validate_collab_ref_id("abc\0def");
402 assert!(result.is_err(), "null bytes should be rejected");
403 }
404
405 #[test]
406 fn ref_id_with_control_chars_rejected() {
407 for ch in &['\n', '\r', '\t'] {
408 let id = format!("abcdef1234567890abcdef1234567890abcdef1{}", ch);
409 let result = validate_collab_ref_id(&id);
410 assert!(
411 result.is_err(),
412 "control char {:?} should be rejected",
413 ch
414 );
415 }
416 }
417
418 #[test]
419 fn ref_id_non_hex_rejected() {
420 let result = validate_collab_ref_id("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ");
421 assert!(result.is_err(), "non-hex characters should be rejected");
422 }
423
424 #[test]
425 fn ref_id_wrong_length_rejected() {
426 // Too short
427 let result = validate_collab_ref_id("abcdef");
428 assert!(result.is_err(), "6-char ID should be rejected");
429
430 // Too long
431 let result =
432 validate_collab_ref_id("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678ab");
433 assert!(result.is_err(), "80-char ID should be rejected");
434 }
435
436 #[test]
437 fn valid_hex_oid_accepted() {
438 let result = validate_collab_ref_id("abcdef1234567890abcdef1234567890abcdef12");
439 assert!(
440 result.is_ok(),
441 "valid 40-char hex OID should be accepted: {:?}",
442 result.err()
443 );
444 }
445
446 #[test]
447 fn ref_id_uppercase_hex_rejected() {
448 // Uppercase hex should be rejected (we require lowercase)
449 let result = validate_collab_ref_id("ABCDEF1234567890ABCDEF1234567890ABCDEF12");
450 assert!(result.is_err(), "uppercase hex should be rejected");
451 }
452
453 #[test]
454 fn malformed_remote_event_no_local_state_change() {
455 // FR-010: corrupted remote event must not modify local ref
456 let tmp = TempDir::new().unwrap();
457 let repo = init_repo(tmp.path(), &alice());
458 let sk = common::test_signing_key();
459
460 // Create a valid issue
461 let event = Event {
462 timestamp: now(),
463 author: alice(),
464 action: Action::IssueOpen {
465 title: "Original".to_string(),
466 body: "Body".to_string(),
467 },
468 clock: 0,
469 };
470 let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
471 let local_ref = format!("refs/collab/issues/{}", oid);
472 repo.reference(&local_ref, oid, false, "test").unwrap();
473
474 // Record the OID the local ref points to
475 let original_oid = repo.refname_to_id(&local_ref).unwrap();
476
477 // Create a corrupted "remote" commit (bad JSON)
478 let bad_blob = repo.blob(b"corrupted json!!!").unwrap();
479 let manifest_blob = repo
480 .blob(br#"{"version":1,"format":"git-collab"}"#)
481 .unwrap();
482 let mut tb = repo.treebuilder(None).unwrap();
483 tb.insert("event.json", bad_blob, 0o100644).unwrap();
484 tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
485 let tree_oid = tb.write().unwrap();
486 let tree = repo.find_tree(tree_oid).unwrap();
487 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
488 let corrupted_oid = repo
489 .commit(None, &sig, &sig, "corrupted", &tree, &[])
490 .unwrap();
491
492 // Point a "remote" ref to the corrupted commit
493 let remote_ref = format!("refs/collab/sync/issues/{}", oid);
494 repo.reference(&remote_ref, corrupted_oid, false, "fake remote")
495 .unwrap();
496
497 // Try to reconcile — this should fail because walk_events on the remote
498 // will fail when trying to read the corrupted event
499 let reconcile_result = dag::reconcile(&repo, &local_ref, &remote_ref, &alice(), &sk);
500 // The reconcile may or may not fail depending on whether it needs to walk events
501 // (it only walks events for merge commits, not fast-forward). The key assertion is:
502
503 // Verify local ref still points to the original OID
504 let current_oid = repo.refname_to_id(&local_ref).unwrap();
505 assert_eq!(
506 original_oid, current_oid,
507 "local ref must not change after encountering corrupted remote event"
508 );
509 // Suppress unused variable warning
510 let _ = reconcile_result;
511 }
512
513 // ===========================================================================
514 // Phase 7: User Story 5 — Property-Based Testing
515 // ===========================================================================
516
517 use proptest::prelude::*;
518
519 /// Generate an arbitrary Author
520 fn arb_author() -> impl Strategy<Value = Author> {
521 ("[a-zA-Z0-9 ]{0,50}", "[a-zA-Z0-9@.]{0,50}").prop_map(|(name, email)| Author {
522 name,
523 email,
524 })
525 }
526
527 /// Generate an arbitrary Action variant
528 fn arb_action() -> impl Strategy<Value = Action> {
529 prop_oneof![
530 (".*", ".*").prop_map(|(title, body)| Action::IssueOpen { title, body }),
531 ".*".prop_map(|body| Action::IssueComment { body }),
532 proptest::option::of(".*").prop_map(|reason| Action::IssueClose { reason }),
533 Just(Action::IssueReopen),
534 (".*", ".*", ".*", ".*").prop_map(|(title, body, base_ref, branch)| {
535 Action::PatchCreate {
536 title,
537 body,
538 base_ref,
539 branch,
540 fixes: None,
541 }
542 }),
543 proptest::option::of(".*").prop_map(|body| Action::PatchRevise { body }),
544 (".*",).prop_map(|(body,)| Action::PatchComment { body }),
545 Just(Action::PatchMerge),
546 Just(Action::Merge),
547 ]
548 }
549
550 /// Generate an arbitrary Event
551 fn arb_event() -> impl Strategy<Value = Event> {
552 (arb_author(), arb_action(), 0u64..1000).prop_map(|(author, action, clock)| Event {
553 timestamp: "2024-01-01T00:00:00Z".to_string(),
554 author,
555 action,
556 clock,
557 })
558 }
559
560 proptest! {
561 #[test]
562 fn event_roundtrip_never_panics(event in arb_event()) {
563 // Serialize to JSON, deserialize back. Must not panic.
564 let json = serde_json::to_vec(&event).unwrap();
565 let _result: Result<Event, _> = serde_json::from_slice(&json);
566 // Ok or Err both acceptable; no panic is the requirement.
567 }
568
569 #[test]
570 fn arbitrary_bytes_never_panic_parser(data in proptest::collection::vec(any::<u8>(), 0..1024)) {
571 // Arbitrary bytes passed to the Event parser must not panic.
572 let _result: Result<Event, _> = serde_json::from_slice(&data);
573 }
574
575 #[test]
576 fn walk_events_with_arbitrary_blob_never_panics(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
577 // Create a commit with arbitrary blob content and walk it.
578 let tmp = TempDir::new().unwrap();
579 let repo = init_repo(tmp.path(), &alice());
580
581 let blob_oid = repo.blob(&data).unwrap();
582 let manifest_blob = repo.blob(br#"{"version":1,"format":"git-collab"}"#).unwrap();
583
584 let mut tb = repo.treebuilder(None).unwrap();
585 tb.insert("event.json", blob_oid, 0o100644).unwrap();
586 tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
587 let tree_oid = tb.write().unwrap();
588 let tree = repo.find_tree(tree_oid).unwrap();
589
590 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
591 let oid = repo.commit(None, &sig, &sig, "fuzz test", &tree, &[]).unwrap();
592 let ref_name = format!("refs/collab/issues/{}", oid);
593 repo.reference(&ref_name, oid, false, "test").unwrap();
594
595 // Must not panic — Ok or Err both acceptable
596 let _result = dag::walk_events(&repo, &ref_name);
597 }
598
599 #[test]
600 fn validate_ref_id_never_panics(id in ".*") {
601 // Arbitrary string passed to validate_collab_ref_id must not panic.
602 let _result = validate_collab_ref_id(&id);
603 }
604 }
605
606 // ===========================================================================
607 // Phase 8: Polish & Cross-Cutting
608 // ===========================================================================
609
610 #[test]
611 fn timestamp_not_rfc3339_accepted_by_serde() {
612 // timestamp is just a String in the Event struct, not validated as RFC3339.
613 // This documents that non-RFC3339 timestamps are accepted.
614 let json = br#"{"timestamp":"not-a-date","author":{"name":"a","email":"e"},"action":{"type":"issue.open","title":"t","body":"b"},"clock":1}"#;
615 let result: Result<Event, _> = serde_json::from_slice(json);
616 assert!(
617 result.is_ok(),
618 "non-RFC3339 timestamp should be accepted by serde (it's just a String): {:?}",
619 result.err()
620 );
621 assert_eq!(result.unwrap().timestamp, "not-a-date");
622 }
623
624 #[test]
625 fn merge_commit_with_one_corrupted_parent() {
626 // Create a DAG with a merge commit where one parent branch has a corrupted event.
627 let tmp = TempDir::new().unwrap();
628 let repo = init_repo(tmp.path(), &alice());
629
630 // Branch 1: valid event
631 let valid_json = valid_event_json();
632 let valid_blob = repo.blob(valid_json.as_bytes()).unwrap();
633 let manifest_blob = repo
634 .blob(br#"{"version":1,"format":"git-collab"}"#)
635 .unwrap();
636 let mut tb = repo.treebuilder(None).unwrap();
637 tb.insert("event.json", valid_blob, 0o100644).unwrap();
638 tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
639 let tree_oid = tb.write().unwrap();
640 let tree = repo.find_tree(tree_oid).unwrap();
641 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
642 let valid_oid = repo
643 .commit(None, &sig, &sig, "valid branch", &tree, &[])
644 .unwrap();
645
646 // Branch 2: corrupted event
647 let bad_blob = repo.blob(b"not json at all").unwrap();
648 let manifest_blob2 = repo
649 .blob(br#"{"version":1,"format":"git-collab"}"#)
650 .unwrap();
651 let mut tb = repo.treebuilder(None).unwrap();
652 tb.insert("event.json", bad_blob, 0o100644).unwrap();
653 tb.insert("manifest.json", manifest_blob2, 0o100644).unwrap();
654 let tree_oid = tb.write().unwrap();
655 let tree = repo.find_tree(tree_oid).unwrap();
656 let corrupted_oid = repo
657 .commit(None, &sig, &sig, "corrupted branch", &tree, &[])
658 .unwrap();
659
660 // Merge commit (valid event JSON but parents include corrupted branch)
661 let merge_json = serde_json::to_string(&Event {
662 timestamp: now(),
663 author: alice(),
664 action: Action::Merge,
665 clock: 2,
666 })
667 .unwrap();
668 let merge_blob = repo.blob(merge_json.as_bytes()).unwrap();
669 let manifest_blob3 = repo
670 .blob(br#"{"version":1,"format":"git-collab"}"#)
671 .unwrap();
672 let mut tb = repo.treebuilder(None).unwrap();
673 tb.insert("event.json", merge_blob, 0o100644).unwrap();
674 tb.insert("manifest.json", manifest_blob3, 0o100644).unwrap();
675 let tree_oid = tb.write().unwrap();
676 let tree = repo.find_tree(tree_oid).unwrap();
677
678 let valid_commit = repo.find_commit(valid_oid).unwrap();
679 let corrupted_commit = repo.find_commit(corrupted_oid).unwrap();
680 let merge_oid = repo
681 .commit(
682 None,
683 &sig,
684 &sig,
685 "merge commit",
686 &tree,
687 &[&valid_commit, &corrupted_commit],
688 )
689 .unwrap();
690
691 let ref_name = format!("refs/collab/issues/{}", merge_oid);
692 repo.reference(&ref_name, merge_oid, false, "test").unwrap();
693
694 let result = dag::walk_events(&repo, &ref_name);
695 assert!(
696 result.is_err(),
697 "merge with one corrupted parent should return Err"
698 );
699 }
tests/collab_test.rs
Old New
@@ -5,7 +5,7 @@ use tempfile::TempDir;
5 use git_collab::dag; 5 use git_collab::dag;
6 use git_collab::error::Error; 6 use git_collab::error::Error;
7 use git_collab::event::{Action, Author, Event, ReviewVerdict}; 7 use git_collab::event::{Action, Author, Event, ReviewVerdict};
8 use git_collab::signing::{self, SignedEvent, VerifyStatus}; 8 use git_collab::signing::{self, DetachedSignature, VerifyStatus};
9 use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; 9 use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
10 10
11 use common::{ 11 use common::{
@@ -95,6 +95,7 @@ fn test_issue_edit_updates_title_and_body() {
95 title: Some("New title".to_string()), 95 title: Some("New title".to_string()),
96 body: Some("New body".to_string()), 96 body: Some("New body".to_string()),
97 }, 97 },
98 clock: 0,
98 }; 99 };
99 dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); 100 dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
100 101
@@ -119,6 +120,7 @@ fn test_issue_edit_partial_update() {
119 title: None, 120 title: None,
120 body: Some("Added body".to_string()), 121 body: Some("Added body".to_string()),
121 }, 122 },
123 clock: 0,
122 }; 124 };
123 dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); 125 dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
124 126
@@ -329,6 +331,7 @@ fn test_patch_review_workflow() {
329 action: Action::PatchRevise { 331 action: Action::PatchRevise {
330 body: Some("Updated implementation".to_string()), 332 body: Some("Updated implementation".to_string()),
331 }, 333 },
334 clock: 0,
332 }; 335 };
333 dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); 336 dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
334 337
@@ -504,26 +507,47 @@ fn test_signed_event_in_dag() {
504 title: "Signed issue".to_string(), 507 title: "Signed issue".to_string(),
505 body: "".to_string(), 508 body: "".to_string(),
506 }, 509 },
510 clock: 0,
507 }; 511 };
508 let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); 512 let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
509 let id = oid.to_string(); 513 let id = oid.to_string();
510 let ref_name = format!("refs/collab/issues/{}", id); 514 let ref_name = format!("refs/collab/issues/{}", id);
511 repo.reference(&ref_name, oid, false, "test open").unwrap(); 515 repo.reference(&ref_name, oid, false, "test open").unwrap();
512 516
513 // Read the raw blob from the commit and deserialize as SignedEvent 517 // Read the raw blobs from the commit tree
514 let tip = repo.refname_to_id(&ref_name).unwrap(); 518 let tip = repo.refname_to_id(&ref_name).unwrap();
515 let commit = repo.find_commit(tip).unwrap(); 519 let commit = repo.find_commit(tip).unwrap();
516 let tree = commit.tree().unwrap(); 520 let tree = commit.tree().unwrap();
517 let entry = tree.get_name("event.json").unwrap();
518 let blob = repo.find_blob(entry.id()).unwrap();
519 let signed: SignedEvent = serde_json::from_slice(blob.content()).unwrap();
520 521
521 // Assert signature and pubkey are present 522 // event.json should be a plain Event (no signature fields)
522 assert!(!signed.signature.is_empty(), "signature should be present"); 523 let event_entry = tree.get_name("event.json").unwrap();
523 assert!(!signed.pubkey.is_empty(), "pubkey should be present"); 524 let event_blob = repo.find_blob(event_entry.id()).unwrap();
524 525 let event: Event = serde_json::from_slice(event_blob.content()).unwrap();
525 // Verify the signature 526 assert!(matches!(event.action, Action::IssueOpen { .. }));
526 let status = signing::verify_signed_event(&signed).unwrap(); 527
528 // signature and pubkey should be separate blobs
529 let sig_entry = tree.get_name("signature").expect("signature blob should exist");
530 let pk_entry = tree.get_name("pubkey").expect("pubkey blob should exist");
531 let sig_blob = repo.find_blob(sig_entry.id()).unwrap();
532 let pk_blob = repo.find_blob(pk_entry.id()).unwrap();
533 let sig_str = std::str::from_utf8(sig_blob.content()).unwrap();
534 let pk_str = std::str::from_utf8(pk_blob.content()).unwrap();
535 assert!(!sig_str.is_empty(), "signature should be present");
536 assert!(!pk_str.is_empty(), "pubkey should be present");
537
538 // manifest.json should exist
539 let manifest_entry = tree.get_name("manifest.json").expect("manifest.json should exist");
540 let manifest_blob = repo.find_blob(manifest_entry.id()).unwrap();
541 let manifest_str = std::str::from_utf8(manifest_blob.content()).unwrap();
542 assert!(manifest_str.contains("\"version\":1"), "manifest should contain version");
543 assert!(manifest_str.contains("\"format\":\"git-collab\""), "manifest should contain format");
544
545 // Verify the detached signature
546 let detached = DetachedSignature {
547 signature: sig_str.to_string(),
548 pubkey: pk_str.to_string(),
549 };
550 let status = signing::verify_detached(&event, &detached).unwrap();
527 assert_eq!(status, VerifyStatus::Valid, "signature should verify as valid"); 551 assert_eq!(status, VerifyStatus::Valid, "signature should verify as valid");
528 552
529 // walk_events should still extract the Event correctly 553 // walk_events should still extract the Event correctly
@@ -609,6 +633,7 @@ fn create_branch_patch(
609 branch: branch.to_string(), 633 branch: branch.to_string(),
610 fixes: None, 634 fixes: None,
611 }, 635 },
636 clock: 0,
612 }; 637 };
613 let oid = dag::create_root_event(repo, &event, &sk).unwrap(); 638 let oid = dag::create_root_event(repo, &event, &sk).unwrap();
614 let id = oid.to_string(); 639 let id = oid.to_string();
tests/common/mod.rs
Old New
@@ -66,6 +66,7 @@ pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, S
66 title: title.to_string(), 66 title: title.to_string(),
67 body: "".to_string(), 67 body: "".to_string(),
68 }, 68 },
69 clock: 0,
69 }; 70 };
70 let oid = dag::create_root_event(repo, &event, &sk).unwrap(); 71 let oid = dag::create_root_event(repo, &event, &sk).unwrap();
71 let id = oid.to_string(); 72 let id = oid.to_string();
@@ -83,6 +84,7 @@ pub fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &st
83 action: Action::IssueComment { 84 action: Action::IssueComment {
84 body: body.to_string(), 85 body: body.to_string(),
85 }, 86 },
87 clock: 0,
86 }; 88 };
87 dag::append_event(repo, ref_name, &event, &sk).unwrap(); 89 dag::append_event(repo, ref_name, &event, &sk).unwrap();
88 } 90 }
@@ -94,6 +96,7 @@ pub fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
94 timestamp: now(), 96 timestamp: now(),
95 author: author.clone(), 97 author: author.clone(),
96 action: Action::IssueClose { reason: None }, 98 action: Action::IssueClose { reason: None },
99 clock: 0,
97 }; 100 };
98 dag::append_event(repo, ref_name, &event, &sk).unwrap(); 101 dag::append_event(repo, ref_name, &event, &sk).unwrap();
99 } 102 }
@@ -105,6 +108,7 @@ pub fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) {
105 timestamp: now(), 108 timestamp: now(),
106 author: author.clone(), 109 author: author.clone(),
107 action: Action::IssueReopen, 110 action: Action::IssueReopen,
111 clock: 0,
108 }; 112 };
109 dag::append_event(repo, ref_name, &event, &sk).unwrap(); 113 dag::append_event(repo, ref_name, &event, &sk).unwrap();
110 } 114 }
@@ -122,6 +126,7 @@ pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String,
122 branch: "test-branch".to_string(), 126 branch: "test-branch".to_string(),
123 fixes: None, 127 fixes: None,
124 }, 128 },
129 clock: 0,
125 }; 130 };
126 let oid = dag::create_root_event(repo, &event, &sk).unwrap(); 131 let oid = dag::create_root_event(repo, &event, &sk).unwrap();
127 let id = oid.to_string(); 132 let id = oid.to_string();
@@ -140,6 +145,7 @@ pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: R
140 verdict, 145 verdict,
141 body: "review comment".to_string(), 146 body: "review comment".to_string(),
142 }, 147 },
148 clock: 0,
143 }; 149 };
144 dag::append_event(repo, ref_name, &event, &sk).unwrap(); 150 dag::append_event(repo, ref_name, &event, &sk).unwrap();
145 } 151 }
@@ -272,14 +278,18 @@ impl TestRepo {
272 } 278 }
273 } 279 }
274 280
275 /// Create an unsigned event commit (plain Event JSON, no signature fields). 281 /// Create an unsigned event commit (plain Event JSON, no signature/pubkey blobs).
276 /// Returns the commit OID. 282 /// Returns the commit OID.
277 pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { 283 pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid {
278 let json = serde_json::to_vec_pretty(event).unwrap(); 284 let json = serde_json::to_vec_pretty(event).unwrap();
279 let blob_oid = repo.blob(&json).unwrap(); 285 let blob_oid = repo.blob(&json).unwrap();
286 let manifest = br#"{"version":1,"format":"git-collab"}"#;
287 let manifest_blob = repo.blob(manifest).unwrap();
280 288
281 let mut tb = repo.treebuilder(None).unwrap(); 289 let mut tb = repo.treebuilder(None).unwrap();
282 tb.insert("event.json", blob_oid, 0o100644).unwrap(); 290 tb.insert("event.json", blob_oid, 0o100644).unwrap();
291 tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
292 // No signature or pubkey blobs — this is an unsigned event
283 let tree_oid = tb.write().unwrap(); 293 let tree_oid = tb.write().unwrap();
284 let tree = repo.find_tree(tree_oid).unwrap(); 294 let tree = repo.find_tree(tree_oid).unwrap();
285 295
@@ -288,18 +298,27 @@ pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid {
288 .unwrap() 298 .unwrap()
289 } 299 }
290 300
291 /// Create a tampered event commit: sign the event, then modify the body but keep 301 /// Create a tampered event commit: sign the event, then modify the event.json but keep
292 /// the original signature. Returns the commit OID. 302 /// the original signature. Returns the commit OID.
293 pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid { 303 pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid {
294 let sk = test_signing_key(); 304 let sk = test_signing_key();
295 let mut signed = signing::sign_event(event, &sk).unwrap(); 305 let detached = signing::sign_event(event, &sk).unwrap();
306
296 // Tamper with the event content while keeping the original signature 307 // Tamper with the event content while keeping the original signature
297 signed.event.timestamp = "2099-01-01T00:00:00Z".to_string(); 308 let mut tampered_event = event.clone();
298 let json = serde_json::to_vec_pretty(&signed).unwrap(); 309 tampered_event.timestamp = "2099-01-01T00:00:00Z".to_string();
299 let blob_oid = repo.blob(&json).unwrap(); 310 let json = serde_json::to_vec_pretty(&tampered_event).unwrap();
311 let event_blob = repo.blob(&json).unwrap();
312 let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap();
313 let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap();
314 let manifest = br#"{"version":1,"format":"git-collab"}"#;
315 let manifest_blob = repo.blob(manifest).unwrap();
300 316
301 let mut tb = repo.treebuilder(None).unwrap(); 317 let mut tb = repo.treebuilder(None).unwrap();
302 tb.insert("event.json", blob_oid, 0o100644).unwrap(); 318 tb.insert("event.json", event_blob, 0o100644).unwrap();
319 tb.insert("signature", sig_blob, 0o100644).unwrap();
320 tb.insert("pubkey", pubkey_blob, 0o100644).unwrap();
321 tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
303 let tree_oid = tb.write().unwrap(); 322 let tree_oid = tb.write().unwrap();
304 let tree = repo.find_tree(tree_oid).unwrap(); 323 let tree = repo.find_tree(tree_oid).unwrap();
305 324
tests/crdt_test.rs
Old New
@@ -0,0 +1,576 @@
1 mod common;
2
3 use ed25519_dalek::SigningKey;
4 use git2::{Oid, Repository};
5 use rand_core::OsRng;
6 use tempfile::TempDir;
7
8 use git_collab::dag;
9 use git_collab::event::{Action, Author, Event};
10 use git_collab::state::{IssueState, IssueStatus, PatchState, PatchStatus};
11
12 fn alice() -> Author {
13 Author {
14 name: "Alice".to_string(),
15 email: "alice@example.com".to_string(),
16 }
17 }
18
19 fn bob() -> Author {
20 Author {
21 name: "Bob".to_string(),
22 email: "bob@example.com".to_string(),
23 }
24 }
25
26 fn test_sk() -> SigningKey {
27 SigningKey::generate(&mut OsRng)
28 }
29
30 fn init_repo(dir: &std::path::Path) -> Repository {
31 let repo = Repository::init(dir).unwrap();
32 {
33 let mut config = repo.config().unwrap();
34 config.set_str("user.name", "Test").unwrap();
35 config.set_str("user.email", "test@test.com").unwrap();
36 }
37 repo
38 }
39
40 fn read_event_clock(repo: &Repository, oid: Oid) -> u64 {
41 let commit = repo.find_commit(oid).unwrap();
42 let tree = commit.tree().unwrap();
43 let entry = tree.get_name("event.json").unwrap();
44 let blob = repo.find_blob(entry.id()).unwrap();
45 let event: Event = serde_json::from_slice(blob.content()).unwrap();
46 event.clock
47 }
48
49 // ── Phase 2: Clock propagation tests ─────────────────────────────
50
51 #[test]
52 fn create_root_event_sets_clock_to_1() {
53 let dir = TempDir::new().unwrap();
54 let repo = init_repo(dir.path());
55 let sk = test_sk();
56
57 let event = Event {
58 timestamp: "2026-01-01T00:00:00Z".to_string(),
59 author: alice(),
60 action: Action::IssueOpen {
61 title: "test".to_string(),
62 body: "body".to_string(),
63 },
64 clock: 0, // caller passes 0, DAG should override to 1
65 };
66
67 let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
68 assert_eq!(read_event_clock(&repo, oid), 1);
69 }
70
71 #[test]
72 fn append_event_increments_clock() {
73 let dir = TempDir::new().unwrap();
74 let repo = init_repo(dir.path());
75 let sk = test_sk();
76
77 let open_event = Event {
78 timestamp: "2026-01-01T00:00:00Z".to_string(),
79 author: alice(),
80 action: Action::IssueOpen {
81 title: "test".to_string(),
82 body: "body".to_string(),
83 },
84 clock: 0,
85 };
86
87 let root_oid = dag::create_root_event(&repo, &open_event, &sk).unwrap();
88 let ref_name = format!("refs/collab/issues/{}", root_oid);
89 repo.reference(&ref_name, root_oid, false, "test").unwrap();
90
91 // First append should get clock=2
92 let comment_event = Event {
93 timestamp: "2026-01-02T00:00:00Z".to_string(),
94 author: alice(),
95 action: Action::IssueComment {
96 body: "comment 1".to_string(),
97 },
98 clock: 0,
99 };
100 let oid2 = dag::append_event(&repo, &ref_name, &comment_event, &sk).unwrap();
101 assert_eq!(read_event_clock(&repo, oid2), 2);
102
103 // Second append should get clock=3
104 let comment_event2 = Event {
105 timestamp: "2026-01-03T00:00:00Z".to_string(),
106 author: alice(),
107 action: Action::IssueComment {
108 body: "comment 2".to_string(),
109 },
110 clock: 0,
111 };
112 let oid3 = dag::append_event(&repo, &ref_name, &comment_event2, &sk).unwrap();
113 assert_eq!(read_event_clock(&repo, oid3), 3);
114 }
115
116 #[test]
117 fn max_clock_returns_highest_clock_in_dag() {
118 let dir = TempDir::new().unwrap();
119 let repo = init_repo(dir.path());
120 let sk = test_sk();
121
122 let event = Event {
123 timestamp: "2026-01-01T00:00:00Z".to_string(),
124 author: alice(),
125 action: Action::IssueOpen {
126 title: "test".to_string(),
127 body: "body".to_string(),
128 },
129 clock: 0,
130 };
131
132 let root_oid = dag::create_root_event(&repo, &event, &sk).unwrap();
133 let ref_name = format!("refs/collab/issues/{}", root_oid);
134 repo.reference(&ref_name, root_oid, false, "test").unwrap();
135
136 assert_eq!(dag::max_clock(&repo, root_oid).unwrap(), 1);
137
138 let comment = Event {
139 timestamp: "2026-01-02T00:00:00Z".to_string(),
140 author: alice(),
141 action: Action::IssueComment {
142 body: "comment".to_string(),
143 },
144 clock: 0,
145 };
146 let tip = dag::append_event(&repo, &ref_name, &comment, &sk).unwrap();
147 assert_eq!(dag::max_clock(&repo, tip).unwrap(), 2);
148 }
149
150 // ── Phase 3: Reconcile merge clock test ──────────────────────────
151
152 #[test]
153 fn reconcile_merge_clock_is_max_of_both_branches_plus_one() {
154 let dir = TempDir::new().unwrap();
155 let repo = init_repo(dir.path());
156 let sk = test_sk();
157
158 // Create root event (clock=1)
159 let open = Event {
160 timestamp: "2026-01-01T00:00:00Z".to_string(),
161 author: alice(),
162 action: Action::IssueOpen {
163 title: "test".to_string(),
164 body: "body".to_string(),
165 },
166 clock: 0,
167 };
168 let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap();
169 let local_ref = "refs/collab/issues/test-reconcile";
170 let remote_ref = "refs/collab/sync/issues/test-reconcile";
171 repo.reference(local_ref, root_oid, false, "test").unwrap();
172 repo.reference(remote_ref, root_oid, false, "test").unwrap();
173
174 // Append 2 events on local (clock=2,3)
175 let comment1 = Event {
176 timestamp: "2026-01-02T00:00:00Z".to_string(),
177 author: alice(),
178 action: Action::IssueComment {
179 body: "local 1".to_string(),
180 },
181 clock: 0,
182 };
183 dag::append_event(&repo, local_ref, &comment1, &sk).unwrap();
184 let comment2 = Event {
185 timestamp: "2026-01-03T00:00:00Z".to_string(),
186 author: alice(),
187 action: Action::IssueComment {
188 body: "local 2".to_string(),
189 },
190 clock: 0,
191 };
192 dag::append_event(&repo, local_ref, &comment2, &sk).unwrap();
193
194 // Append 4 events on remote (clock=2,3,4,5)
195 for i in 1..=4 {
196 let comment = Event {
197 timestamp: format!("2026-02-{:02}T00:00:00Z", i),
198 author: bob(),
199 action: Action::IssueComment {
200 body: format!("remote {}", i),
201 },
202 clock: 0,
203 };
204 dag::append_event(&repo, remote_ref, &comment, &sk).unwrap();
205 }
206
207 let merge_oid = dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap();
208 // Remote max is 5, local max is 3, so merge should be 6
209 assert_eq!(read_event_clock(&repo, merge_oid), 6);
210 }
211
212 // ── Phase 5: Concurrent status resolution using clock+OID ───────
213
214 #[test]
215 fn concurrent_issue_close_reopen_higher_clock_wins() {
216 // When one branch closes and another reopens at the same clock,
217 // the OID tiebreaker should produce a deterministic result.
218 // But when clocks differ, the higher clock always wins.
219 let dir = TempDir::new().unwrap();
220 let repo = init_repo(dir.path());
221 let sk = test_sk();
222
223 // Create root issue (clock=1)
224 let open = Event {
225 timestamp: "2026-01-01T00:00:00Z".to_string(),
226 author: alice(),
227 action: Action::IssueOpen {
228 title: "test".to_string(),
229 body: "body".to_string(),
230 },
231 clock: 0,
232 };
233 let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap();
234 let local_ref = "refs/collab/issues/test-concurrent";
235 let remote_ref = "refs/collab/sync/issues/test-concurrent";
236 repo.reference(local_ref, root_oid, false, "test").unwrap();
237 repo.reference(remote_ref, root_oid, false, "test").unwrap();
238
239 // Local: close (clock=2)
240 let close = Event {
241 timestamp: "2026-01-02T00:00:00Z".to_string(),
242 author: alice(),
243 action: Action::IssueClose { reason: None },
244 clock: 0,
245 };
246 dag::append_event(&repo, local_ref, &close, &sk).unwrap();
247
248 // Remote: add comment (clock=2) then reopen (clock=3)
249 let comment = Event {
250 timestamp: "2026-01-02T00:00:00Z".to_string(),
251 author: bob(),
252 action: Action::IssueComment {
253 body: "comment".to_string(),
254 },
255 clock: 0,
256 };
257 dag::append_event(&repo, remote_ref, &comment, &sk).unwrap();
258 let reopen = Event {
259 timestamp: "2026-01-03T00:00:00Z".to_string(),
260 author: bob(),
261 action: Action::IssueReopen,
262 clock: 0,
263 };
264 dag::append_event(&repo, remote_ref, &reopen, &sk).unwrap();
265
266 // Reconcile
267 dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap();
268
269 // After reconcile: close had clock=2, reopen had clock=3, so reopen wins
270 let state = IssueState::from_ref(&repo, local_ref, "test-concurrent").unwrap();
271 assert_eq!(state.status, IssueStatus::Open);
272 }
273
274 #[test]
275 fn concurrent_issue_same_clock_oid_breaks_tie() {
276 // When two status changes have the same clock, higher OID (lexicographic) wins.
277 let dir = TempDir::new().unwrap();
278 let repo = init_repo(dir.path());
279 let sk = test_sk();
280
281 // Create root issue (clock=1)
282 let open = Event {
283 timestamp: "2026-01-01T00:00:00Z".to_string(),
284 author: alice(),
285 action: Action::IssueOpen {
286 title: "test".to_string(),
287 body: "body".to_string(),
288 },
289 clock: 0,
290 };
291 let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap();
292 let local_ref = "refs/collab/issues/test-tie";
293 let remote_ref = "refs/collab/sync/issues/test-tie";
294 repo.reference(local_ref, root_oid, false, "test").unwrap();
295 repo.reference(remote_ref, root_oid, false, "test").unwrap();
296
297 // Both branches: close at clock=2 (both directly from root)
298 let close1 = Event {
299 timestamp: "2026-01-02T00:00:00Z".to_string(),
300 author: alice(),
301 action: Action::IssueClose {
302 reason: Some("alice close".to_string()),
303 },
304 clock: 0,
305 };
306 dag::append_event(&repo, local_ref, &close1, &sk).unwrap();
307
308 let close2 = Event {
309 timestamp: "2026-01-02T00:00:00Z".to_string(),
310 author: bob(),
311 action: Action::IssueClose {
312 reason: Some("bob close".to_string()),
313 },
314 clock: 0,
315 };
316 dag::append_event(&repo, remote_ref, &close2, &sk).unwrap();
317
318 // Reconcile
319 dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap();
320
321 // Both are closes with clock=2, so the issue should be closed.
322 // The one with the higher OID wins and determines the close reason.
323 let state = IssueState::from_ref(&repo, local_ref, "test-tie").unwrap();
324 assert_eq!(state.status, IssueStatus::Closed);
325 // We can't predict which OID wins, but one of the reasons should be present
326 assert!(
327 state.close_reason == Some("alice close".to_string())
328 || state.close_reason == Some("bob close".to_string())
329 );
330 }
331
332 #[test]
333 fn concurrent_patch_close_merge_higher_clock_wins() {
334 let dir = TempDir::new().unwrap();
335 let repo = init_repo(dir.path());
336 let sk = test_sk();
337
338 // Need a branch for the patch
339 let sig = git2::Signature::now("Test", "test@test.com").unwrap();
340 let tree_oid = repo.treebuilder(None).unwrap().write().unwrap();
341 let tree = repo.find_tree(tree_oid).unwrap();
342 let initial = repo
343 .commit(Some("refs/heads/main"), &sig, &sig, "initial", &tree, &[])
344 .unwrap();
345 let initial_commit = repo.find_commit(initial).unwrap();
346 repo.branch("test-branch", &initial_commit, false).unwrap();
347
348 // Create root patch (clock=1)
349 let create = Event {
350 timestamp: "2026-01-01T00:00:00Z".to_string(),
351 author: alice(),
352 action: Action::PatchCreate {
353 title: "test patch".to_string(),
354 body: "body".to_string(),
355 base_ref: "main".to_string(),
356 branch: "test-branch".to_string(),
357 fixes: None,
358 },
359 clock: 0,
360 };
361 let root_oid = dag::create_root_event(&repo, &create, &sk).unwrap();
362 let local_ref = "refs/collab/patches/test-patch";
363 let remote_ref = "refs/collab/sync/patches/test-patch";
364 repo.reference(local_ref, root_oid, false, "test").unwrap();
365 repo.reference(remote_ref, root_oid, false, "test")
366 .unwrap();
367
368 // Local: close (clock=2)
369 let close = Event {
370 timestamp: "2026-01-02T00:00:00Z".to_string(),
371 author: alice(),
372 action: Action::PatchClose { reason: None },
373 clock: 0,
374 };
375 dag::append_event(&repo, local_ref, &close, &sk).unwrap();
376
377 // Remote: comment (clock=2), then merge (clock=3)
378 let comment = Event {
379 timestamp: "2026-01-02T00:00:00Z".to_string(),
380 author: bob(),
381 action: Action::PatchComment {
382 body: "lgtm".to_string(),
383 },
384 clock: 0,
385 };
386 dag::append_event(&repo, remote_ref, &comment, &sk).unwrap();
387 let merge = Event {
388 timestamp: "2026-01-03T00:00:00Z".to_string(),
389 author: bob(),
390 action: Action::PatchMerge,
391 clock: 0,
392 };
393 dag::append_event(&repo, remote_ref, &merge, &sk).unwrap();
394
395 // Reconcile
396 dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap();
397
398 // Merge at clock=3 should win over close at clock=2
399 let state = PatchState::from_ref(&repo, local_ref, "test-patch").unwrap();
400 assert_eq!(state.status, PatchStatus::Merged);
401 }
402
403 // ── Phase 7: Migration tests ────────────────────────────────────
404
405 #[test]
406 fn migrate_clocks_assigns_sequential_clocks() {
407 let dir = TempDir::new().unwrap();
408 let repo = init_repo(dir.path());
409 let sk = test_sk();
410
411 // Create a DAG with events that have clock=0 (simulating pre-migration data)
412 // We need to create commits directly to simulate old format
413 let open = Event {
414 timestamp: "2026-01-01T00:00:00Z".to_string(),
415 author: alice(),
416 action: Action::IssueOpen {
417 title: "test".to_string(),
418 body: "body".to_string(),
419 },
420 clock: 0,
421 };
422 let root_oid = dag::create_root_event(&repo, &open, &sk).unwrap();
423 let ref_name = "refs/collab/issues/test-migrate";
424 repo.reference(ref_name, root_oid, false, "test").unwrap();
425
426 // Append a couple events
427 let comment = Event {
428 timestamp: "2026-01-02T00:00:00Z".to_string(),
429 author: alice(),
430 action: Action::IssueComment {
431 body: "comment".to_string(),
432 },
433 clock: 0,
434 };
435 dag::append_event(&repo, ref_name, &comment, &sk).unwrap();
436
437 // After creation through the DAG functions, clocks should already be set
438 // Let's verify the walk returns correct clocks
439 let events = dag::walk_events(&repo, ref_name).unwrap();
440 assert_eq!(events.len(), 2);
441 assert_eq!(events[0].1.clock, 1);
442 assert_eq!(events[1].1.clock, 2);
443
444 // Now test migrate_clocks on a ref that has correct clocks (should be a no-op effectively)
445 dag::migrate_clocks(&repo, ref_name, &sk).unwrap();
446 let events_after = dag::walk_events(&repo, ref_name).unwrap();
447 assert_eq!(events_after.len(), 2);
448 assert!(events_after[0].1.clock >= 1);
449 assert!(events_after[1].1.clock >= 2);
450 }
451
452 #[test]
453 fn migrate_clocks_on_zero_clock_events() {
454 // Build a DAG with clock=0 events by writing directly (bypassing DAG functions)
455 let dir = TempDir::new().unwrap();
456 let repo = init_repo(dir.path());
457 let sk = test_sk();
458
459 let event1 = Event {
460 timestamp: "2026-01-01T00:00:00Z".to_string(),
461 author: alice(),
462 action: Action::IssueOpen {
463 title: "test".to_string(),
464 body: "body".to_string(),
465 },
466 clock: 0,
467 };
468
469 // Write directly with clock=0 (simulating pre-CRDT data)
470 let oid1 = write_raw_event(&repo, &event1, &sk, &[]);
471 let ref_name = "refs/collab/issues/test-migrate-zero";
472 repo.reference(ref_name, oid1, false, "test").unwrap();
473
474 let event2 = Event {
475 timestamp: "2026-01-02T00:00:00Z".to_string(),
476 author: alice(),
477 action: Action::IssueComment {
478 body: "comment".to_string(),
479 },
480 clock: 0,
481 };
482 let parent = repo.find_commit(oid1).unwrap();
483 let oid2 = write_raw_event(&repo, &event2, &sk, &[&parent]);
484 repo.reference(ref_name, oid2, true, "test append").unwrap();
485
486 // Verify clocks are 0
487 assert_eq!(read_event_clock(&repo, oid1), 0);
488 assert_eq!(read_event_clock(&repo, oid2), 0);
489
490 // Migrate
491 dag::migrate_clocks(&repo, ref_name, &sk).unwrap();
492
493 // After migration, clocks should be sequential (1, 2)
494 let events = dag::walk_events(&repo, ref_name).unwrap();
495 assert_eq!(events.len(), 2);
496 assert_eq!(events[0].1.clock, 1);
497 assert_eq!(events[1].1.clock, 2);
498 }
499
500 // ── Phase 8: Serialization preservation test ─────────────────────
501
502 #[test]
503 fn clock_field_survives_serialization_round_trip() {
504 let event = Event {
505 timestamp: "2026-01-01T00:00:00Z".to_string(),
506 author: alice(),
507 action: Action::IssueOpen {
508 title: "test".to_string(),
509 body: "body".to_string(),
510 },
511 clock: 42,
512 };
513
514 let json = serde_json::to_string(&event).unwrap();
515 assert!(json.contains("\"clock\":42"));
516
517 let deserialized: Event = serde_json::from_str(&json).unwrap();
518 assert_eq!(deserialized.clock, 42);
519 }
520
521 #[test]
522 fn clock_field_in_dag_round_trip() {
523 let dir = TempDir::new().unwrap();
524 let repo = init_repo(dir.path());
525 let sk = test_sk();
526
527 let event = Event {
528 timestamp: "2026-01-01T00:00:00Z".to_string(),
529 author: alice(),
530 action: Action::IssueOpen {
531 title: "test".to_string(),
532 body: "body".to_string(),
533 },
534 clock: 0, // Caller passes 0
535 };
536
537 let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
538 let ref_name = format!("refs/collab/issues/{}", oid);
539 repo.reference(&ref_name, oid, false, "test").unwrap();
540
541 // Read back and verify clock was set to 1 and persisted
542 let events = dag::walk_events(&repo, &ref_name).unwrap();
543 assert_eq!(events.len(), 1);
544 assert_eq!(events[0].1.clock, 1);
545 }
546
547 // ── Helper: write a raw event commit bypassing DAG clock logic ───
548
549 fn write_raw_event(
550 repo: &Repository,
551 event: &Event,
552 sk: &SigningKey,
553 parents: &[&git2::Commit],
554 ) -> Oid {
555 use git_collab::signing::sign_event;
556
557 let detached = sign_event(event, sk).unwrap();
558 let json = serde_json::to_vec_pretty(event).unwrap();
559 let event_blob = repo.blob(&json).unwrap();
560 let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap();
561 let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap();
562 let manifest = br#"{"version":1,"format":"git-collab"}"#;
563 let manifest_blob = repo.blob(manifest).unwrap();
564
565 let mut tb = repo.treebuilder(None).unwrap();
566 tb.insert("event.json", event_blob, 0o100644).unwrap();
567 tb.insert("signature", sig_blob, 0o100644).unwrap();
568 tb.insert("pubkey", pubkey_blob, 0o100644).unwrap();
569 tb.insert("manifest.json", manifest_blob, 0o100644).unwrap();
570 let tree_oid = tb.write().unwrap();
571 let tree = repo.find_tree(tree_oid).unwrap();
572
573 let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap();
574 repo.commit(None, &sig, &sig, "raw event", &tree, parents)
575 .unwrap()
576 }
tests/signing_test.rs
Old New
@@ -1,7 +1,7 @@
1 use git_collab::event::{Action, Author, Event}; 1 use git_collab::event::{Action, Author, Event};
2 use git_collab::signing::{ 2 use git_collab::signing::{
3 canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event, 3 canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event,
4 verify_signed_event, SignedEvent, VerifyStatus, 4 verify_detached, DetachedSignature, VerifyStatus,
5 }; 5 };
6 use tempfile::tempdir; 6 use tempfile::tempdir;
7 7
@@ -16,10 +16,11 @@ fn make_event() -> Event {
16 title: "Test issue".to_string(), 16 title: "Test issue".to_string(),
17 body: "This is a test".to_string(), 17 body: "This is a test".to_string(),
18 }, 18 },
19 clock: 0,
19 } 20 }
20 } 21 }
21 22
22 // ── T004: Key generation and storage ── 23 // -- T004: Key generation and storage --
23 24
24 #[test] 25 #[test]
25 fn generate_keypair_creates_key_files() { 26 fn generate_keypair_creates_key_files() {
@@ -93,7 +94,7 @@ fn load_verifying_key_missing_returns_error() {
93 ); 94 );
94 } 95 }
95 96
96 // ── T005: Sign/verify round-trip ── 97 // -- T005: Sign/verify round-trip --
97 98
98 #[test] 99 #[test]
99 fn sign_event_produces_nonempty_signature_and_pubkey() { 100 fn sign_event_produces_nonempty_signature_and_pubkey() {
@@ -103,33 +104,33 @@ fn sign_event_produces_nonempty_signature_and_pubkey() {
103 let sk = load_signing_key(&config).unwrap(); 104 let sk = load_signing_key(&config).unwrap();
104 105
105 let event = make_event(); 106 let event = make_event();
106 let signed = sign_event(&event, &sk).unwrap(); 107 let detached = sign_event(&event, &sk).unwrap();
107 108
108 assert!(!signed.signature.is_empty(), "signature should not be empty"); 109 assert!(!detached.signature.is_empty(), "signature should not be empty");
109 assert!(!signed.pubkey.is_empty(), "pubkey should not be empty"); 110 assert!(!detached.pubkey.is_empty(), "pubkey should not be empty");
110 111
111 // Verify they are valid base64 112 // Verify they are valid base64
112 use base64::engine::general_purpose::STANDARD; 113 use base64::engine::general_purpose::STANDARD;
113 use base64::Engine; 114 use base64::Engine;
114 STANDARD 115 STANDARD
115 .decode(&signed.signature) 116 .decode(&detached.signature)
116 .expect("signature should be valid base64"); 117 .expect("signature should be valid base64");
117 STANDARD 118 STANDARD
118 .decode(&signed.pubkey) 119 .decode(&detached.pubkey)
119 .expect("pubkey should be valid base64"); 120 .expect("pubkey should be valid base64");
120 } 121 }
121 122
122 #[test] 123 #[test]
123 fn verify_valid_signed_event_returns_valid() { 124 fn verify_valid_detached_signature_returns_valid() {
124 let dir = tempdir().unwrap(); 125 let dir = tempdir().unwrap();
125 let config = dir.path().join("git-collab"); 126 let config = dir.path().join("git-collab");
126 generate_keypair(&config).unwrap(); 127 generate_keypair(&config).unwrap();
127 let sk = load_signing_key(&config).unwrap(); 128 let sk = load_signing_key(&config).unwrap();
128 129
129 let event = make_event(); 130 let event = make_event();
130 let signed = sign_event(&event, &sk).unwrap(); 131 let detached = sign_event(&event, &sk).unwrap();
131 132
132 let status = verify_signed_event(&signed).unwrap(); 133 let status = verify_detached(&event, &detached).unwrap();
133 assert_eq!(status, VerifyStatus::Valid); 134 assert_eq!(status, VerifyStatus::Valid);
134 } 135 }
135 136
@@ -141,29 +142,29 @@ fn verify_tampered_event_returns_invalid() {
141 let sk = load_signing_key(&config).unwrap(); 142 let sk = load_signing_key(&config).unwrap();
142 143
143 let event = make_event(); 144 let event = make_event();
144 let mut signed = sign_event(&event, &sk).unwrap(); 145 let detached = sign_event(&event, &sk).unwrap();
145 146
146 // Tamper with the event 147 // Tamper with the event
147 signed.event.author.name = "Mallory".to_string(); 148 let mut tampered = event.clone();
149 tampered.author.name = "Mallory".to_string();
148 150
149 let status = verify_signed_event(&signed).unwrap(); 151 let status = verify_detached(&tampered, &detached).unwrap();
150 assert_eq!(status, VerifyStatus::Invalid); 152 assert_eq!(status, VerifyStatus::Invalid);
151 } 153 }
152 154
153 #[test] 155 #[test]
154 fn verify_missing_signature_returns_missing() { 156 fn verify_missing_signature_returns_missing() {
155 let event = make_event(); 157 let event = make_event();
156 let signed = SignedEvent { 158 let detached = DetachedSignature {
157 event,
158 signature: String::new(), 159 signature: String::new(),
159 pubkey: String::new(), 160 pubkey: String::new(),
160 }; 161 };
161 162
162 let status = verify_signed_event(&signed).unwrap(); 163 let status = verify_detached(&event, &detached).unwrap();
163 assert_eq!(status, VerifyStatus::Missing); 164 assert_eq!(status, VerifyStatus::Missing);
164 } 165 }
165 166
166 // ── T006: Canonical serialization ── 167 // -- T006: Canonical serialization --
167 168
168 #[test] 169 #[test]
169 fn canonical_json_deterministic() { 170 fn canonical_json_deterministic() {
@@ -176,31 +177,26 @@ fn canonical_json_deterministic() {
176 } 177 }
177 178
178 #[test] 179 #[test]
179 fn signed_event_json_contains_all_fields() { 180 fn detached_signature_fields_are_valid() {
180 let dir = tempdir().unwrap(); 181 let dir = tempdir().unwrap();
181 let config = dir.path().join("git-collab"); 182 let config = dir.path().join("git-collab");
182 generate_keypair(&config).unwrap(); 183 generate_keypair(&config).unwrap();
183 let sk = load_signing_key(&config).unwrap(); 184 let sk = load_signing_key(&config).unwrap();
184 185
185 let event = make_event(); 186 let event = make_event();
186 let signed = sign_event(&event, &sk).unwrap(); 187 let detached = sign_event(&event, &sk).unwrap();
187 188
188 let json = serde_json::to_string(&signed).unwrap(); 189 // Verify signature and pubkey are valid base64 strings
189 let value: serde_json::Value = serde_json::from_str(&json).unwrap(); 190 use base64::engine::general_purpose::STANDARD;
190 let obj = value.as_object().unwrap(); 191 use base64::Engine;
191 192 let sig_bytes = STANDARD.decode(&detached.signature).unwrap();
192 // Flattened event fields 193 let pk_bytes = STANDARD.decode(&detached.pubkey).unwrap();
193 assert!(obj.contains_key("timestamp"), "missing timestamp"); 194 assert_eq!(sig_bytes.len(), 64, "Ed25519 signature should be 64 bytes");
194 assert!(obj.contains_key("author"), "missing author"); 195 assert_eq!(pk_bytes.len(), 32, "Ed25519 public key should be 32 bytes");
195 assert!(obj.contains_key("action") || obj.contains_key("type"), "missing action/type");
196
197 // Signature fields
198 assert!(obj.contains_key("signature"), "missing signature");
199 assert!(obj.contains_key("pubkey"), "missing pubkey");
200 } 196 }
201 197
202 #[test] 198 #[test]
203 fn signed_event_flatten_round_trip_with_tagged_enum() { 199 fn event_json_uses_namespaced_action_types() {
204 let event = Event { 200 let event = Event {
205 timestamp: "2026-03-21T12:00:00Z".to_string(), 201 timestamp: "2026-03-21T12:00:00Z".to_string(),
206 author: Author { 202 author: Author {
@@ -214,25 +210,16 @@ fn signed_event_flatten_round_trip_with_tagged_enum() {
214 branch: "feature/fix-bug".to_string(), 210 branch: "feature/fix-bug".to_string(),
215 fixes: Some("deadbeef".to_string()), 211 fixes: Some("deadbeef".to_string()),
216 }, 212 },
213 clock: 0,
217 }; 214 };
218 215
219 let signed = SignedEvent { 216 let json = serde_json::to_string(&event).unwrap();
220 event, 217 assert!(json.contains("\"type\":\"patch.create\""), "action type should be namespaced: {}", json);
221 signature: "dGVzdHNpZw==".to_string(),
222 pubkey: "dGVzdGtleQ==".to_string(),
223 };
224 218
225 let json = serde_json::to_string_pretty(&signed).unwrap(); 219 // Round-trip
226 let deserialized: SignedEvent = serde_json::from_str(&json).unwrap(); 220 let deserialized: Event = serde_json::from_str(&json).unwrap();
227 221 match deserialized.action {
228 assert_eq!(deserialized.signature, signed.signature); 222 Action::PatchCreate { ref title, ref fixes, .. } => {
229 assert_eq!(deserialized.pubkey, signed.pubkey);
230 match deserialized.event.action {
231 Action::PatchCreate {
232 ref title,
233 ref fixes,
234 ..
235 } => {
236 assert_eq!(title, "Fix bug"); 223 assert_eq!(title, "Fix bug");
237 assert_eq!(fixes.as_deref(), Some("deadbeef")); 224 assert_eq!(fixes.as_deref(), Some("deadbeef"));
238 } 225 }
tests/sync_lock_test.rs
Old New
@@ -0,0 +1,256 @@
1 mod common;
2
3 use std::fs;
4 use std::path::{Path, PathBuf};
5
6 use tempfile::TempDir;
7
8 use git_collab::error::Error;
9 use git_collab::sync_lock::{SyncLock, SyncLockInfo};
10
11 // ===========================================================================
12 // Test helpers
13 // ===========================================================================
14
15 /// Return the `.git/collab/` path for a temp repo, creating it if needed.
16 fn collab_dir(repo_path: &Path) -> PathBuf {
17 let dir = repo_path.join(".git").join("collab");
18 fs::create_dir_all(&dir).unwrap();
19 dir
20 }
21
22 /// Write a lockfile with the given PID and timestamp.
23 fn write_lockfile(collab_dir: &Path, pid: u32, timestamp: &str) {
24 let info = SyncLockInfo {
25 pid,
26 timestamp: timestamp.to_string(),
27 };
28 let lock_path = collab_dir.join("sync.lock");
29 fs::write(&lock_path, info.to_json()).unwrap();
30 }
31
32 // ===========================================================================
33 // Phase 2: Foundational tests — serialization
34 // ===========================================================================
35
36 #[test]
37 fn test_sync_lock_info_roundtrip() {
38 let info = SyncLockInfo {
39 pid: 12345,
40 timestamp: "2026-03-21T10:30:00+00:00".to_string(),
41 };
42 let json = info.to_json();
43 let parsed = SyncLockInfo::from_json(&json).unwrap();
44 assert_eq!(parsed.pid, 12345);
45 assert_eq!(parsed.timestamp, "2026-03-21T10:30:00+00:00");
46 }
47
48 #[test]
49 fn test_sync_lock_info_from_invalid_json() {
50 let result = SyncLockInfo::from_json("not json");
51 assert!(result.is_err());
52 }
53
54 // ===========================================================================
55 // Phase 3: User Story 1 — Prevent Concurrent Sync Corruption
56 // ===========================================================================
57
58 // T006: Test that SyncLock::acquire creates lockfile with correct PID and timestamp
59 #[test]
60 fn test_acquire_creates_lockfile() {
61 let dir = TempDir::new().unwrap();
62 let repo = git2::Repository::init(dir.path()).unwrap();
63
64 let lock = SyncLock::acquire(&repo).unwrap();
65
66 assert!(lock.lock_path.exists(), "lockfile should exist after acquire");
67
68 let content = fs::read_to_string(&lock.lock_path).unwrap();
69 let info = SyncLockInfo::from_json(&content).unwrap();
70 assert_eq!(info.pid, std::process::id());
71 // Timestamp should be a valid RFC 3339 string
72 chrono::DateTime::parse_from_rfc3339(&info.timestamp)
73 .expect("timestamp should be valid RFC 3339");
74 }
75
76 // T007: Test that SyncLock::acquire returns SyncLocked when lockfile exists with live PID
77 #[test]
78 fn test_acquire_returns_sync_locked_when_lock_held() {
79 let dir = TempDir::new().unwrap();
80 let repo = git2::Repository::init(dir.path()).unwrap();
81
82 // Write a lockfile with our own PID (guaranteed alive)
83 let collab = collab_dir(dir.path());
84 let current_pid = std::process::id();
85 write_lockfile(&collab, current_pid, &chrono::Utc::now().to_rfc3339());
86
87 let result = SyncLock::acquire(&repo);
88 assert!(result.is_err(), "acquire should fail when lock is held");
89
90 match result.unwrap_err() {
91 Error::SyncLocked { pid, since } => {
92 assert_eq!(pid, current_pid);
93 assert!(!since.is_empty());
94 }
95 other => panic!("expected SyncLocked error, got: {}", other),
96 }
97 }
98
99 // T008: Test that dropping SyncLock deletes the lockfile
100 #[test]
101 fn test_drop_deletes_lockfile() {
102 let dir = TempDir::new().unwrap();
103 let repo = git2::Repository::init(dir.path()).unwrap();
104
105 let lock = SyncLock::acquire(&repo).unwrap();
106 let lock_path = lock.lock_path.clone();
107 assert!(lock_path.exists());
108
109 drop(lock);
110 assert!(!lock_path.exists(), "lockfile should be deleted after drop");
111 }
112
113 // ===========================================================================
114 // Phase 4: User Story 2 — Clear Error Message
115 // ===========================================================================
116
117 // T015: Test that SyncLocked error message includes PID and human-readable lock age
118 #[test]
119 fn test_sync_locked_error_message_includes_pid_and_age() {
120 let dir = TempDir::new().unwrap();
121 let repo = git2::Repository::init(dir.path()).unwrap();
122
123 let collab = collab_dir(dir.path());
124 let current_pid = std::process::id();
125 // Lock from 3 seconds ago
126 let ts = (chrono::Utc::now() - chrono::Duration::seconds(3)).to_rfc3339();
127 write_lockfile(&collab, current_pid, &ts);
128
129 let err = SyncLock::acquire(&repo).unwrap_err();
130 let msg = err.to_string();
131
132 assert!(
133 msg.contains(&current_pid.to_string()),
134 "error should contain PID, got: {}",
135 msg
136 );
137 // Should contain human-readable age
138 assert!(
139 msg.contains("ago"),
140 "error should contain human-readable age, got: {}",
141 msg
142 );
143 }
144
145 // T016: Test that error message suggests waiting or checking process
146 #[test]
147 fn test_sync_locked_error_message_suggests_action() {
148 let dir = TempDir::new().unwrap();
149 let repo = git2::Repository::init(dir.path()).unwrap();
150
151 let collab = collab_dir(dir.path());
152 let current_pid = std::process::id();
153 write_lockfile(&collab, current_pid, &chrono::Utc::now().to_rfc3339());
154
155 let err = SyncLock::acquire(&repo).unwrap_err();
156 let msg = err.to_string();
157
158 assert!(
159 msg.contains("wait") || msg.contains("remove"),
160 "error should suggest action, got: {}",
161 msg
162 );
163 }
164
165 // ===========================================================================
166 // Phase 5: User Story 3 — Stale Lock Recovery
167 // ===========================================================================
168
169 // T019: Test that acquire succeeds when lockfile exists with a dead PID
170 #[test]
171 fn test_acquire_succeeds_with_dead_pid() {
172 let dir = TempDir::new().unwrap();
173 let repo = git2::Repository::init(dir.path()).unwrap();
174
175 let collab = collab_dir(dir.path());
176 // PID 999999 is almost certainly not running
177 write_lockfile(&collab, 999999, &chrono::Utc::now().to_rfc3339());
178
179 let lock = SyncLock::acquire(&repo);
180 assert!(lock.is_ok(), "acquire should succeed with dead PID: {:?}", lock.err());
181
182 // The new lock should be ours
183 let lock = lock.unwrap();
184 let content = fs::read_to_string(&lock.lock_path).unwrap();
185 let info = SyncLockInfo::from_json(&content).unwrap();
186 assert_eq!(info.pid, std::process::id());
187 }
188
189 // T020: Test that acquire succeeds when lockfile is older than 10 minutes
190 #[test]
191 fn test_acquire_succeeds_with_old_lock() {
192 let dir = TempDir::new().unwrap();
193 let repo = git2::Repository::init(dir.path()).unwrap();
194
195 let collab = collab_dir(dir.path());
196 // Lock from 11 minutes ago with our own PID (alive but stale by age)
197 let old_ts = (chrono::Utc::now() - chrono::Duration::minutes(11)).to_rfc3339();
198 write_lockfile(&collab, std::process::id(), &old_ts);
199
200 let lock = SyncLock::acquire(&repo);
201 assert!(
202 lock.is_ok(),
203 "acquire should succeed with stale (old) lock: {:?}",
204 lock.err()
205 );
206 }
207
208 // T021: Test that acquire succeeds when lockfile contains invalid JSON
209 #[test]
210 fn test_acquire_succeeds_with_corrupted_lockfile() {
211 let dir = TempDir::new().unwrap();
212 let repo = git2::Repository::init(dir.path()).unwrap();
213
214 let collab = collab_dir(dir.path());
215 let lock_path = collab.join("sync.lock");
216 fs::write(&lock_path, "this is not json at all").unwrap();
217
218 let lock = SyncLock::acquire(&repo);
219 assert!(
220 lock.is_ok(),
221 "acquire should succeed with corrupted lockfile: {:?}",
222 lock.err()
223 );
224 }
225
226 // T022: Test format_lock_age helper
227 #[test]
228 fn test_format_lock_age() {
229 use git_collab::sync_lock::format_lock_age;
230
231 let now = chrono::Utc::now();
232 let three_sec_ago = (now - chrono::Duration::seconds(3)).to_rfc3339();
233 let result = format_lock_age(&three_sec_ago);
234 assert!(result.contains("second"), "expected 'second' in: {}", result);
235 assert!(result.contains("ago"), "expected 'ago' in: {}", result);
236
237 let two_min_ago = (now - chrono::Duration::minutes(2)).to_rfc3339();
238 let result = format_lock_age(&two_min_ago);
239 assert!(result.contains("minute"), "expected 'minute' in: {}", result);
240
241 // Invalid timestamp should fall back gracefully
242 let result = format_lock_age("not-a-timestamp");
243 assert!(!result.is_empty());
244 }
245
246 // T023: Test is_process_alive
247 #[test]
248 fn test_is_process_alive() {
249 use git_collab::sync_lock::is_process_alive;
250
251 // Our own process should be alive
252 assert!(is_process_alive(std::process::id()));
253
254 // PID 999999 should not be alive (almost certainly)
255 assert!(!is_process_alive(999999));
256 }
tests/sync_test.rs
Old New
@@ -11,6 +11,7 @@ use tempfile::TempDir;
11 11
12 use git2::Repository; 12 use git2::Repository;
13 use git_collab::dag; 13 use git_collab::dag;
14 use git_collab::error;
14 use git_collab::event::{Action, Event, ReviewVerdict}; 15 use git_collab::event::{Action, Event, ReviewVerdict};
15 use git_collab::signing; 16 use git_collab::signing;
16 use git_collab::state::{self, IssueState, IssueStatus, PatchState}; 17 use git_collab::state::{self, IssueState, IssueStatus, PatchState};
@@ -26,7 +27,7 @@ use common::{
26 // --------------------------------------------------------------------------- 27 // ---------------------------------------------------------------------------
27 28
28 struct TestCluster { 29 struct TestCluster {
29 _bare_dir: TempDir, 30 bare_dir: TempDir,
30 alice_dir: TempDir, 31 alice_dir: TempDir,
31 bob_dir: TempDir, 32 bob_dir: TempDir,
32 _key_setup: (), // signing key created in default config dir 33 _key_setup: (), // signing key created in default config dir
@@ -80,7 +81,7 @@ impl TestCluster {
80 } 81 }
81 82
82 TestCluster { 83 TestCluster {
83 _bare_dir: bare_dir, 84 bare_dir,
84 alice_dir, 85 alice_dir,
85 bob_dir, 86 bob_dir,
86 _key_setup: (), 87 _key_setup: (),
@@ -94,6 +95,11 @@ impl TestCluster {
94 fn bob_repo(&self) -> Repository { 95 fn bob_repo(&self) -> Repository {
95 Repository::open(self.bob_dir.path()).unwrap() 96 Repository::open(self.bob_dir.path()).unwrap()
96 } 97 }
98
99 /// Return the path to the bare remote directory.
100 fn bare_dir(&self) -> &std::path::Path {
101 self.bare_dir.path()
102 }
97 } 103 }
98 104
99 // --------------------------------------------------------------------------- 105 // ---------------------------------------------------------------------------
@@ -286,6 +292,7 @@ fn test_patch_review_across_repos() {
286 branch: "feature/x".to_string(), 292 branch: "feature/x".to_string(),
287 fixes: None, 293 fixes: None,
288 }, 294 },
295 clock: 0,
289 }; 296 };
290 let sk = test_signing_key(); 297 let sk = test_signing_key();
291 let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); 298 let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap();
@@ -306,6 +313,7 @@ fn test_patch_review_across_repos() {
306 verdict: ReviewVerdict::Approve, 313 verdict: ReviewVerdict::Approve,
307 body: "LGTM!".to_string(), 314 body: "LGTM!".to_string(),
308 }, 315 },
316 clock: 0,
309 }; 317 };
310 dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap(); 318 dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap();
311 sync::sync(&bob_repo, "origin").unwrap(); 319 sync::sync(&bob_repo, "origin").unwrap();
@@ -333,6 +341,7 @@ fn test_concurrent_review_and_revise() {
333 branch: "feature/wip".to_string(), 341 branch: "feature/wip".to_string(),
334 fixes: None, 342 fixes: None,
335 }, 343 },
344 clock: 0,
336 }; 345 };
337 let sk = test_signing_key(); 346 let sk = test_signing_key();
338 let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); 347 let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap();
@@ -350,6 +359,7 @@ fn test_concurrent_review_and_revise() {
350 action: Action::PatchRevise { 359 action: Action::PatchRevise {
351 body: Some("Updated description".to_string()), 360 body: Some("Updated description".to_string()),
352 }, 361 },
362 clock: 0,
353 }; 363 };
354 dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap(); 364 dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap();
355 365
@@ -361,6 +371,7 @@ fn test_concurrent_review_and_revise() {
361 verdict: ReviewVerdict::RequestChanges, 371 verdict: ReviewVerdict::RequestChanges,
362 body: "Needs work".to_string(), 372 body: "Needs work".to_string(),
363 }, 373 },
374 clock: 0,
364 }; 375 };
365 dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap(); 376 dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap();
366 377
@@ -464,6 +475,7 @@ fn test_unsigned_event_sync_rejected() {
464 title: "Unsigned issue".to_string(), 475 title: "Unsigned issue".to_string(),
465 body: "No signature".to_string(), 476 body: "No signature".to_string(),
466 }, 477 },
478 clock: 0,
467 }; 479 };
468 let oid = create_unsigned_event(&alice_repo, &event); 480 let oid = create_unsigned_event(&alice_repo, &event);
469 let id = oid.to_string(); 481 let id = oid.to_string();
@@ -511,6 +523,7 @@ fn test_tampered_event_sync_rejected() {
511 title: "Tampered issue".to_string(), 523 title: "Tampered issue".to_string(),
512 body: "Will be tampered".to_string(), 524 body: "Will be tampered".to_string(),
513 }, 525 },
526 clock: 0,
514 }; 527 };
515 let oid = create_tampered_event(&repo, &event); 528 let oid = create_tampered_event(&repo, &event);
516 let id = oid.to_string(); 529 let id = oid.to_string();
@@ -609,25 +622,450 @@ fn test_reconciliation_merge_commit_is_signed() {
609 let commit = alice_repo.find_commit(tip).unwrap(); 622 let commit = alice_repo.find_commit(tip).unwrap();
610 // The tip should be the merge commit (it's the most recent) 623 // The tip should be the merge commit (it's the most recent)
611 let tree = commit.tree().unwrap(); 624 let tree = commit.tree().unwrap();
612 let entry = tree.get_name("event.json").unwrap(); 625
613 let blob = alice_repo.find_blob(entry.id()).unwrap(); 626 // Read event.json as a plain Event
614 let signed: signing::SignedEvent = serde_json::from_slice(blob.content()).unwrap(); 627 let event_entry = tree.get_name("event.json").unwrap();
628 let event_blob = alice_repo.find_blob(event_entry.id()).unwrap();
629 let event: git_collab::event::Event = serde_json::from_slice(event_blob.content()).unwrap();
615 630
616 assert!( 631 assert!(
617 matches!(signed.event.action, Action::Merge), 632 matches!(event.action, Action::Merge),
618 "Expected tip commit to be a Merge event, got {:?}", 633 "Expected tip commit to be a Merge event, got {:?}",
619 signed.event.action 634 event.action
620 ); 635 );
636
637 // Read pubkey from separate blob
638 let pk_entry = tree.get_name("pubkey").expect("pubkey blob should exist");
639 let pk_blob = alice_repo.find_blob(pk_entry.id()).unwrap();
640 let commit_pubkey = std::str::from_utf8(pk_blob.content()).unwrap().trim().to_string();
641
621 assert_eq!( 642 assert_eq!(
622 signed.pubkey, syncing_pubkey, 643 commit_pubkey, syncing_pubkey,
623 "Merge commit should be signed by the syncing user's key" 644 "Merge commit should be signed by the syncing user's key"
624 ); 645 );
625 646
626 // Verify the signature is cryptographically valid 647 // Read signature from separate blob and verify
627 let status = signing::verify_signed_event(&signed).unwrap(); 648 let sig_entry = tree.get_name("signature").expect("signature blob should exist");
649 let sig_blob = alice_repo.find_blob(sig_entry.id()).unwrap();
650 let sig_str = std::str::from_utf8(sig_blob.content()).unwrap().trim().to_string();
651
652 let detached = signing::DetachedSignature {
653 signature: sig_str,
654 pubkey: commit_pubkey,
655 };
656 let status = signing::verify_detached(&event, &detached).unwrap();
628 assert_eq!( 657 assert_eq!(
629 status, 658 status,
630 signing::VerifyStatus::Valid, 659 signing::VerifyStatus::Valid,
631 "Merge commit signature must be valid" 660 "Merge commit signature must be valid"
632 ); 661 );
633 } 662 }
663
664 // ---------------------------------------------------------------------------
665 // Sync Recovery Tests
666 // ---------------------------------------------------------------------------
667
668 /// Install a pre-receive hook in the bare repo that rejects refs matching a pattern.
669 fn install_reject_hook(bare_dir: &std::path::Path, reject_pattern: &str) {
670 let hooks_dir = bare_dir.join("hooks");
671 std::fs::create_dir_all(&hooks_dir).unwrap();
672 let hook_path = hooks_dir.join("pre-receive");
673 let script = format!(
674 r#"#!/bin/sh
675 while read oldrev newrev refname; do
676 case "$refname" in
677 *{}*)
678 echo "REJECT: $refname matches reject pattern" >&2
679 exit 1
680 ;;
681 esac
682 done
683 exit 0
684 "#,
685 reject_pattern
686 );
687 std::fs::write(&hook_path, script).unwrap();
688 #[cfg(unix)]
689 {
690 use std::os::unix::fs::PermissionsExt;
691 std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).unwrap();
692 }
693 }
694
695 /// Remove the pre-receive hook from the bare repo.
696 fn remove_reject_hook(bare_dir: &std::path::Path) {
697 let hook_path = bare_dir.join("hooks").join("pre-receive");
698 let _ = std::fs::remove_file(&hook_path);
699 }
700
701 // T005 [US4]: Per-ref push isolates failures
702 #[test]
703 fn test_per_ref_push_isolates_failures() {
704 let cluster = TestCluster::new();
705 let alice_repo = cluster.alice_repo();
706
707 // Create two issues
708 let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue one");
709 let (_ref2, id2) = open_issue(&alice_repo, &alice(), "Issue two");
710
711 // Install hook that rejects one specific issue
712 install_reject_hook(cluster.bare_dir(), &id1[..8]);
713
714 // Sync should fail with PartialSync
715 let result = sync::sync(&alice_repo, "origin");
716 assert!(result.is_err(), "sync should fail when a ref is rejected");
717 let err = result.unwrap_err();
718 match &err {
719 git_collab::error::Error::PartialSync { succeeded, total } => {
720 assert_eq!(*succeeded, 1, "one ref should succeed");
721 assert_eq!(*total, 2, "two refs total");
722 }
723 other => panic!("expected PartialSync error, got: {:?}", other),
724 }
725
726 // Verify the non-rejected ref was pushed to the bare remote
727 let bare_repo = Repository::open_bare(cluster.bare_dir()).unwrap();
728 let pushed_ref = format!("refs/collab/issues/{}", id2);
729 assert!(
730 bare_repo.refname_to_id(&pushed_ref).is_ok(),
731 "non-rejected ref should be on remote"
732 );
733
734 // The rejected ref should NOT be on the remote
735 let rejected_ref = format!("refs/collab/issues/{}", id1);
736 assert!(
737 bare_repo.refname_to_id(&rejected_ref).is_err(),
738 "rejected ref should NOT be on remote"
739 );
740 }
741
742 // T006 [US4]: All refs push successfully — happy path unchanged
743 #[test]
744 fn test_all_refs_push_succeeds_unchanged_behavior() {
745 let cluster = TestCluster::new();
746 let alice_repo = cluster.alice_repo();
747
748 let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Happy issue");
749
750 // Sync should succeed
751 let result = sync::sync(&alice_repo, "origin");
752 assert!(result.is_ok(), "sync should succeed: {:?}", result.err());
753 }
754
755 // T010 [US1]: Partial failure reports failed refs
756 #[test]
757 fn test_partial_failure_reports_failed_refs() {
758 let cluster = TestCluster::new();
759 let alice_repo = cluster.alice_repo();
760
761 let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will fail");
762 let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Will succeed");
763
764 install_reject_hook(cluster.bare_dir(), &id1[..8]);
765
766 let result = sync::sync(&alice_repo, "origin");
767 assert!(result.is_err());
768
769 // Verify sync state was saved with the failed ref
770 let state = sync::SyncState::load(&alice_repo);
771 assert!(state.is_some(), "sync state should be saved");
772 let state = state.unwrap();
773 assert_eq!(state.remote, "origin");
774 assert_eq!(state.pending_refs.len(), 1, "one ref should be pending");
775 assert!(
776 state.pending_refs[0].0.contains(&id1),
777 "pending ref should be the rejected one"
778 );
779 assert!(
780 !state.pending_refs[0].1.is_empty(),
781 "error message should be recorded"
782 );
783 }
784
785 // T011 [US1]: Total failure reports all failed
786 #[test]
787 fn test_total_failure_reports_all_failed() {
788 let cluster = TestCluster::new();
789 let alice_repo = cluster.alice_repo();
790
791 let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Fail one");
792 let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Fail two");
793
794 // Reject ALL collab refs
795 install_reject_hook(cluster.bare_dir(), "refs/collab/");
796
797 let result = sync::sync(&alice_repo, "origin");
798 assert!(result.is_err());
799 match result.unwrap_err() {
800 git_collab::error::Error::PartialSync { succeeded, total } => {
801 assert_eq!(succeeded, 0, "no refs should succeed");
802 assert_eq!(total, 2, "two refs total");
803 }
804 other => panic!("expected PartialSync, got: {:?}", other),
805 }
806
807 let state = sync::SyncState::load(&alice_repo).unwrap();
808 assert_eq!(state.pending_refs.len(), 2, "all refs should be pending");
809 }
810
811 // T014 [US2]: Resume retries only failed refs
812 #[test]
813 fn test_resume_retries_only_failed_refs() {
814 let cluster = TestCluster::new();
815 let alice_repo = cluster.alice_repo();
816
817 let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will fail first time");
818 let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Will succeed first time");
819
820 // First sync: reject id1
821 install_reject_hook(cluster.bare_dir(), &id1[..8]);
822 let _ = sync::sync(&alice_repo, "origin");
823
824 // Verify state was saved
825 let state = sync::SyncState::load(&alice_repo);
826 assert!(state.is_some());
827 assert_eq!(state.unwrap().pending_refs.len(), 1);
828
829 // Remove the hook so all refs can push
830 remove_reject_hook(cluster.bare_dir());
831
832 // Resume sync should succeed
833 let result = sync::sync(&alice_repo, "origin");
834 assert!(result.is_ok(), "resume sync should succeed: {:?}", result.err());
835
836 // State should be cleared
837 assert!(
838 sync::SyncState::load(&alice_repo).is_none(),
839 "sync state should be cleared after successful resume"
840 );
841 }
842
843 // T015 [US2]: Resume clears state on full success
844 #[test]
845 fn test_resume_clears_state_on_full_success() {
846 let cluster = TestCluster::new();
847 let alice_repo = cluster.alice_repo();
848
849 let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for resume");
850
851 // Manually create sync state
852 let state = sync::SyncState {
853 remote: "origin".to_string(),
854 pending_refs: vec![(
855 format!("refs/collab/issues/{}", id1),
856 "previous error".to_string(),
857 )],
858 timestamp: chrono::Utc::now().to_rfc3339(),
859 };
860 state.save(&alice_repo).unwrap();
861
862 // Sync in resume mode
863 let result = sync::sync(&alice_repo, "origin");
864 assert!(result.is_ok(), "resume should succeed: {:?}", result.err());
865
866 // Verify state file is gone
867 let state_path = alice_repo.path().join("collab").join("sync-state.json");
868 assert!(
869 !state_path.exists(),
870 "sync-state.json should be deleted after successful resume"
871 );
872 }
873
874 // T016 [US2]: Resume updates state on continued failure
875 #[test]
876 fn test_resume_updates_state_on_continued_failure() {
877 let cluster = TestCluster::new();
878 let alice_repo = cluster.alice_repo();
879
880 let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will keep failing");
881 let (_ref2, id2) = open_issue(&alice_repo, &alice(), "Will succeed on retry");
882
883 // Manually create sync state with both refs pending
884 let state = sync::SyncState {
885 remote: "origin".to_string(),
886 pending_refs: vec![
887 (
888 format!("refs/collab/issues/{}", id1),
889 "previous error".to_string(),
890 ),
891 (
892 format!("refs/collab/issues/{}", id2),
893 "previous error".to_string(),
894 ),
895 ],
896 timestamp: chrono::Utc::now().to_rfc3339(),
897 };
898 state.save(&alice_repo).unwrap();
899
900 // Install hook that only rejects id1
901 install_reject_hook(cluster.bare_dir(), &id1[..8]);
902
903 // Resume should partially fail
904 let result = sync::sync(&alice_repo, "origin");
905 assert!(result.is_err());
906
907 // State should be updated with only the still-failing ref
908 let new_state = sync::SyncState::load(&alice_repo).unwrap();
909 assert_eq!(
910 new_state.pending_refs.len(),
911 1,
912 "only the still-failing ref should remain"
913 );
914 assert!(
915 new_state.pending_refs[0].0.contains(&id1),
916 "the still-failing ref should be id1"
917 );
918 }
919
920 // T017 [US2]: No resume without state file
921 #[test]
922 fn test_no_resume_without_state_file() {
923 let cluster = TestCluster::new();
924 let alice_repo = cluster.alice_repo();
925
926 let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Normal sync");
927
928 // No sync state file — should run full flow
929 let result = sync::sync(&alice_repo, "origin");
930 assert!(result.is_ok(), "normal sync should succeed: {:?}", result.err());
931 }
932
933 // T018b [US2]: Resume cleans up stale sync refs
934 #[test]
935 fn test_resume_cleans_up_stale_sync_refs() {
936 let cluster = TestCluster::new();
937 let alice_repo = cluster.alice_repo();
938
939 let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for stale ref test");
940
941 // Push the issue first (normal sync)
942 sync::sync(&alice_repo, "origin").unwrap();
943
944 // Now manually create a stale sync ref
945 let tip = alice_repo
946 .refname_to_id(&format!("refs/collab/issues/{}", id1))
947 .unwrap();
948 alice_repo
949 .reference(
950 "refs/collab/sync/origin/issues/stale_ref",
951 tip,
952 true,
953 "create stale sync ref",
954 )
955 .unwrap();
956
957 // Create sync state to trigger resume mode
958 let state = sync::SyncState {
959 remote: "origin".to_string(),
960 pending_refs: vec![(
961 format!("refs/collab/issues/{}", id1),
962 "previous error".to_string(),
963 )],
964 timestamp: chrono::Utc::now().to_rfc3339(),
965 };
966 state.save(&alice_repo).unwrap();
967
968 // Resume sync
969 let result = sync::sync(&alice_repo, "origin");
970 assert!(result.is_ok(), "resume should succeed: {:?}", result.err());
971
972 // Verify stale sync ref was cleaned up
973 let stale_ref = alice_repo.refname_to_id("refs/collab/sync/origin/issues/stale_ref");
974 assert!(
975 stale_ref.is_err(),
976 "stale sync ref should be cleaned up during resume"
977 );
978 }
979
980 // T021 [US3]: Stale state detected and cleared when refs are already up to date
981 #[test]
982 fn test_stale_state_detected_and_cleared() {
983 let cluster = TestCluster::new();
984 let alice_repo = cluster.alice_repo();
985
986 let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Already pushed issue");
987
988 // Push the issue normally first
989 sync::sync(&alice_repo, "origin").unwrap();
990
991 // Now create sync state as if the push had failed — but the refs are actually pushed
992 let state = sync::SyncState {
993 remote: "origin".to_string(),
994 pending_refs: vec![(
995 format!("refs/collab/issues/{}", id1),
996 "connection timed out".to_string(),
997 )],
998 timestamp: chrono::Utc::now().to_rfc3339(),
999 };
1000 state.save(&alice_repo).unwrap();
1001
1002 // Resume sync — refs are already up to date
1003 let result = sync::sync(&alice_repo, "origin");
1004 assert!(
1005 result.is_ok(),
1006 "sync should succeed when refs already up to date: {:?}",
1007 result.err()
1008 );
1009
1010 // State should be cleared
1011 assert!(
1012 sync::SyncState::load(&alice_repo).is_none(),
1013 "stale sync state should be cleared"
1014 );
1015 }
1016
1017 // T022 [US3]: State for different remote is ignored
1018 #[test]
1019 fn test_state_for_different_remote_ignored() {
1020 let cluster = TestCluster::new();
1021 let alice_repo = cluster.alice_repo();
1022
1023 let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for remote test");
1024
1025 // Create sync state for a different remote
1026 let state = sync::SyncState {
1027 remote: "upstream".to_string(),
1028 pending_refs: vec![(
1029 format!("refs/collab/issues/{}", id1),
1030 "some error".to_string(),
1031 )],
1032 timestamp: chrono::Utc::now().to_rfc3339(),
1033 };
1034 state.save(&alice_repo).unwrap();
1035
1036 // Sync against origin — should run full flow, ignoring the upstream state
1037 let result = sync::sync(&alice_repo, "origin");
1038 assert!(
1039 result.is_ok(),
1040 "sync to origin should succeed ignoring upstream state: {:?}",
1041 result.err()
1042 );
1043 }
1044
1045 // T026: Corrupted state file handled gracefully
1046 #[test]
1047 fn test_corrupted_state_file_handled_gracefully() {
1048 let cluster = TestCluster::new();
1049 let alice_repo = cluster.alice_repo();
1050
1051 let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Issue for corruption test");
1052
1053 // Write invalid JSON to sync-state.json
1054 let collab_dir = alice_repo.path().join("collab");
1055 std::fs::create_dir_all(&collab_dir).unwrap();
1056 std::fs::write(collab_dir.join("sync-state.json"), "not valid json{{{").unwrap();
1057
1058 // Sync should proceed normally (treating as no state)
1059 let result = sync::sync(&alice_repo, "origin");
1060 assert!(
1061 result.is_ok(),
1062 "sync should succeed despite corrupted state: {:?}",
1063 result.err()
1064 );
1065
1066 // State file should be cleaned up
1067 assert!(
1068 !collab_dir.join("sync-state.json").exists(),
1069 "corrupted state file should be deleted"
1070 );
1071 }