From 989342ec9f1da73f18a8028eb82c03431635420b Mon Sep 17 00:00:00 2001 From: javanhut Date: Tue, 9 Jun 2026 00:38:52 -0400 Subject: [PATCH 1/3] fix: fixed bugs within ivaldi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — TUI empty merge base (src/tui/views/fuse.rs) do_fuse now resolves both head indices via get_timeline_head, computes the real LCA with repo.merge_base, and loads that tree as the base (empty only for genuinely unrelated histories) — mirroring the CLI. No more spurious conflicts on unrelated changes. Bug 2A — union truly concatenates (src/fuse.rs, callers, docs/fuse.md) Added a shared concat_blobs primitive and a MergeDecision::Concat variant. The two genuine-conflict arms of merge_file_union now combine ours-then-theirs bytes instead of silently dropping a side; fuse() takes a &FsStore (infallible, falls back to theirs only on CAS error). All five callers (CLI, TUI, review, sync, tests) updated; docs corrected. Bug 2B — TUI resolver wired up (src/tui/resolver.rs, src/tui/views/fuse.rs) Slimmed resolver.rs to shared types and converted the resolver into a view-owned modal (matching the rest of the TUI) rather than a nested terminal sub-loop. On conflict, do_fuse opens the modal; apply_resolutions builds the final tree with Ours/Theirs/Both(→concat)/Skip semantics. Skip leaves the file unresolved and does not commit — no silent side-picking. Bug 3 — file-mode fidelity (src/fsmerkle.rs, src/git_remote.rs, src/git_export.rs, src/workspace.rs) Added MODE_EXEC/MODE_SYMLINK, relaxed validation, fixed git import/export to map 100644/100755/120000 faithfully (restoring byte-exact tree SHA-1), and made materialize create real symlinks + set the exec bit (#[cfg(unix)]). No tree-hash format change — the format already encoded entry.mode. --- docs/fuse.md | 6 +- src/cli/commands.rs | 2 +- src/fsmerkle.rs | 16 +- src/fuse.rs | 179 ++++++++++++--- src/git_export.rs | 62 ++++- src/git_remote.rs | 12 +- src/review.rs | 2 +- src/sync.rs | 1 + src/tui/resolver.rs | 192 +--------------- src/tui/views/fuse.rs | 517 +++++++++++++++++++++++++++++++++++++----- src/workspace.rs | 170 ++++++++++++-- 11 files changed, 853 insertions(+), 306 deletions(-) diff --git a/docs/fuse.md b/docs/fuse.md index d2b8ed3..0d2160a 100644 --- a/docs/fuse.md +++ b/docs/fuse.md @@ -15,7 +15,7 @@ The fuse engine merges file sets from two divergent timelines using a common anc | `Auto` | Intelligent three-way merge (default). Auto-resolves non-conflicting changes. Only flags truly conflicting files. | | `Ours` | Keep all target timeline (left) versions. No conflicts possible. | | `Theirs` | Accept all source timeline (right) versions. No conflicts possible. | -| `Union` | Combine both versions. For tie-breaking, prefers theirs. No conflicts. | +| `Union` | Combine both versions: clean changes auto-resolve; a genuine conflict concatenates ours then theirs into one blob. No conflicts surfaced. | | `Base` | Revert to common ancestor. Discards all changes. | ## Usage @@ -73,4 +73,6 @@ A fast-forward occurs when the target timeline hasn't changed since the divergen - **No conflict markers**: Conflicts are tracked as data structures, never written to files. - **Strategy as parameter**: Same engine handles all strategies, simplifying the API. - **BTreeMap for file sets**: Deterministic ordering, efficient lookup. -- **Union tie-breaking**: Prefers `theirs` to match the "accept incoming" mental model. +- **Union concatenation**: On a genuine conflict, union combines both versions by + concatenating ours then theirs (deterministic, no separator) into a single blob, + so no side is silently dropped. Intended for append-only files. diff --git a/src/cli/commands.rs b/src/cli/commands.rs index e720d60..f9131ec 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1342,7 +1342,7 @@ fn cmd_fuse(args: FuseArgs, quiet: bool) -> Result<(), String> { collect_blob_hashes(&store, target_leaf.tree_root, "", &mut ours_files)?; collect_blob_hashes(&store, source_leaf.tree_root, "", &mut theirs_files)?; - let result = FuseEngine::fuse(&base_files, &ours_files, &theirs_files, strategy); + let result = FuseEngine::fuse(&store, &base_files, &ours_files, &theirs_files, strategy); if result.success { // Build merged tree (blobs already in CAS, just build tree structure) diff --git a/src/fsmerkle.rs b/src/fsmerkle.rs index fefb9c9..a599b65 100644 --- a/src/fsmerkle.rs +++ b/src/fsmerkle.rs @@ -36,6 +36,8 @@ impl fmt::Display for NodeKind { /// File mode constants. pub const MODE_FILE: u32 = 0o100644; +pub const MODE_EXEC: u32 = 0o100755; +pub const MODE_SYMLINK: u32 = 0o120000; pub const MODE_DIR: u32 = 0o040000; /// A single entry in a directory tree. @@ -570,11 +572,15 @@ fn validate_name(name: &str) -> Result<(), FsMerkleError> { fn validate_mode(mode: u32, kind: NodeKind) -> Result<(), FsMerkleError> { match kind { - NodeKind::Blob if mode != MODE_FILE => Err(FsMerkleError::InvalidMode { - mode, - kind, - expected: MODE_FILE, - }), + NodeKind::Blob + if mode != MODE_FILE && mode != MODE_EXEC && mode != MODE_SYMLINK => + { + Err(FsMerkleError::InvalidMode { + mode, + kind, + expected: MODE_FILE, + }) + } NodeKind::Tree if mode != MODE_DIR => Err(FsMerkleError::InvalidMode { mode, kind, diff --git a/src/fuse.rs b/src/fuse.rs index d0c0a97..2ac01d5 100644 --- a/src/fuse.rs +++ b/src/fuse.rs @@ -10,6 +10,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; +use crate::fsmerkle::{FsMerkleError, FsStore}; use crate::hash::B3Hash; /// Merge strategy type. @@ -88,10 +89,13 @@ pub struct FuseEngine; impl FuseEngine { /// Perform a three-way merge with the given strategy. /// + /// - `store`: blob store, used by the `Union` strategy to materialize + /// concatenated blobs for genuine conflicts /// - `base`: common ancestor file set /// - `ours`: target timeline (left) file set /// - `theirs`: source timeline (right) file set pub fn fuse( + store: &FsStore<'_>, base: &BTreeMap, ours: &BTreeMap, theirs: &BTreeMap, @@ -130,6 +134,10 @@ impl FuseEngine { theirs: t.copied(), }); } + // Auto never concatenates; it surfaces conflicts instead. + MergeDecision::Concat(..) => unreachable!( + "auto strategy does not produce Concat decisions" + ), } } Strategy::Ours => { @@ -149,8 +157,15 @@ impl FuseEngine { merged.insert(path.to_string(), hash); } MergeDecision::Delete => {} + MergeDecision::Concat(o_h, t_h) => { + // Genuine conflict: combine both versions (ours then + // theirs) into a single blob. Fall back to theirs + // only if the blobs can't be read (CAS corruption). + let hash = concat_blobs(store, &o_h, &t_h).unwrap_or(t_h); + merged.insert(path.to_string(), hash); + } MergeDecision::Conflict => { - // Union shouldn't produce conflicts — prefer theirs for tie-breaking + // Union shouldn't produce bare conflicts — prefer theirs. if let Some(&hash) = t { merged.insert(path.to_string(), hash); } else if let Some(&hash) = o { @@ -191,10 +206,27 @@ enum MergeDecision { Take(B3Hash), /// Delete the file. Delete, + /// Combine both versions: concatenate ours (first) then theirs (second) + /// into a new blob. Used by the union strategy on genuine conflicts. + Concat(B3Hash, B3Hash), /// Conflict — cannot auto-resolve. Conflict, } +/// Concatenate two blobs (ours first, then theirs, no separator) into a new +/// blob and return its hash. Deterministic and order-fixed so the result is +/// reproducible. Shared by the union strategy and the TUI "Keep BOTH" resolver. +pub(crate) fn concat_blobs( + store: &FsStore<'_>, + ours: &B3Hash, + theirs: &B3Hash, +) -> Result { + let (_, mut combined) = store.load_blob(*ours)?; + let (_, theirs_bytes) = store.load_blob(*theirs)?; + combined.extend_from_slice(&theirs_bytes); + Ok(store.put_blob(&combined)?.0) +} + /// Three-way merge logic for a single file (auto strategy). fn merge_file_auto( base: Option<&B3Hash>, @@ -267,7 +299,7 @@ fn merge_file_union( if o == t { MergeDecision::Take(o) } else { - MergeDecision::Take(t) // Union prefers theirs for new files + MergeDecision::Concat(o, t) // Both added differently: combine } } (Some(_), None, None) => MergeDecision::Delete, @@ -277,11 +309,11 @@ fn merge_file_union( if o == t { MergeDecision::Take(o) } else if b == o { - MergeDecision::Take(t) + MergeDecision::Take(t) // Only theirs changed } else if b == t { - MergeDecision::Take(o) + MergeDecision::Take(o) // Only ours changed } else { - MergeDecision::Take(t) // Union prefers theirs for tie-breaking + MergeDecision::Concat(o, t) // Both changed differently: combine } } } @@ -312,6 +344,25 @@ mod tests { B3Hash::digest(s.as_bytes()) } + fn tmp_cas() -> (tempfile::TempDir, crate::cas::FileCas) { + let dir = tempfile::tempdir().unwrap(); + let cas = crate::cas::FileCas::new(dir.path().join("objects")).unwrap(); + (dir, cas) + } + + /// Run a fuse against a throwaway store. Adequate for strategies that never + /// load blob content (auto/ours/theirs/base, and union without conflicts). + fn fuse_t( + base: &BTreeMap, + ours: &BTreeMap, + theirs: &BTreeMap, + strategy: Strategy, + ) -> FuseResult { + let (_dir, cas) = tmp_cas(); + let store = FsStore::new(&cas); + FuseEngine::fuse(&store, base, ours, theirs, strategy) + } + // ---- Auto strategy ---- #[test] @@ -320,7 +371,7 @@ mod tests { let ours = files(&[("a.txt", "hello")]); let theirs = files(&[("a.txt", "hello")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert_eq!(result.merged_files.len(), 1); assert_eq!(result.merged_files["a.txt"], hash("hello")); @@ -332,7 +383,7 @@ mod tests { let ours = files(&[("a.txt", "modified")]); let theirs = files(&[("a.txt", "base")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert_eq!(result.merged_files["a.txt"], hash("modified")); } @@ -343,7 +394,7 @@ mod tests { let ours = files(&[("a.txt", "base")]); let theirs = files(&[("a.txt", "modified")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert_eq!(result.merged_files["a.txt"], hash("modified")); } @@ -354,7 +405,7 @@ mod tests { let ours = files(&[("a.txt", "same")]); let theirs = files(&[("a.txt", "same")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert_eq!(result.merged_files["a.txt"], hash("same")); } @@ -365,7 +416,7 @@ mod tests { let ours = files(&[("a.txt", "our change")]); let theirs = files(&[("a.txt", "their change")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(!result.success); assert_eq!(result.conflicts.len(), 1); assert_eq!(result.conflicts[0].path, "a.txt"); @@ -377,7 +428,7 @@ mod tests { let ours = files(&[("new.txt", "content")]); let theirs = files(&[]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert_eq!(result.merged_files["new.txt"], hash("content")); } @@ -388,7 +439,7 @@ mod tests { let ours = files(&[]); let theirs = files(&[("new.txt", "content")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert_eq!(result.merged_files["new.txt"], hash("content")); } @@ -399,7 +450,7 @@ mod tests { let ours = files(&[("new.txt", "same")]); let theirs = files(&[("new.txt", "same")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert_eq!(result.merged_files.len(), 1); } @@ -410,7 +461,7 @@ mod tests { let ours = files(&[("new.txt", "our version")]); let theirs = files(&[("new.txt", "their version")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(!result.success); assert_eq!(result.conflicts.len(), 1); } @@ -421,7 +472,7 @@ mod tests { let ours = files(&[]); let theirs = files(&[]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert!(result.merged_files.is_empty()); } @@ -432,7 +483,7 @@ mod tests { let ours = files(&[]); // deleted let theirs = files(&[("a.txt", "content")]); // unchanged - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert!(result.merged_files.is_empty()); // accept deletion } @@ -443,7 +494,7 @@ mod tests { let ours = files(&[]); // deleted let theirs = files(&[("a.txt", "modified")]); // modified - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(!result.success); assert_eq!(result.conflicts.len(), 1); } @@ -467,7 +518,7 @@ mod tests { ("new.txt", "added"), ]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert_eq!(result.merged_files.len(), 3); // keep + modify(changed) + new assert_eq!(result.merged_files["modify.txt"], hash("changed")); @@ -483,7 +534,7 @@ mod tests { let ours = files(&[("a.txt", "our version")]); let theirs = files(&[("a.txt", "their version")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Ours); + let result = fuse_t(&base, &ours, &theirs, Strategy::Ours); assert!(result.success); assert_eq!(result.merged_files["a.txt"], hash("our version")); } @@ -494,7 +545,7 @@ mod tests { let ours = files(&[]); let theirs = files(&[("a.txt", "modified")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Ours); + let result = fuse_t(&base, &ours, &theirs, Strategy::Ours); assert!(result.success); assert!(result.merged_files.is_empty()); } @@ -507,23 +558,85 @@ mod tests { let ours = files(&[("a.txt", "our version")]); let theirs = files(&[("a.txt", "their version")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Theirs); + let result = fuse_t(&base, &ours, &theirs, Strategy::Theirs); assert!(result.success); assert_eq!(result.merged_files["a.txt"], hash("their version")); } // ---- Union strategy ---- + /// Build a `path -> hash` map by storing each content in `store`, so the + /// union strategy can actually load the blobs to concatenate them. + fn stored( + store: &FsStore<'_>, + entries: &[(&str, &[u8])], + ) -> BTreeMap { + entries + .iter() + .map(|(path, content)| { + let (h, _) = store.put_blob(content).unwrap(); + (path.to_string(), h) + }) + .collect() + } + #[test] - fn union_no_conflicts() { - let base = files(&[("a.txt", "base")]); - let ours = files(&[("a.txt", "our change")]); - let theirs = files(&[("a.txt", "their change")]); + fn union_concatenates_all_three_differ() { + let (_dir, cas) = tmp_cas(); + let store = FsStore::new(&cas); + let base = stored(&store, &[("a.txt", b"BASE")]); + let ours = stored(&store, &[("a.txt", b"AAA")]); + let theirs = stored(&store, &[("a.txt", b"BBB")]); + + let result = FuseEngine::fuse(&store, &base, &ours, &theirs, Strategy::Union); + assert!(result.success); + let (_, bytes) = store.load_blob(result.merged_files["a.txt"]).unwrap(); + assert_eq!(bytes, b"AAABBB", "ours then theirs, no separator"); + } - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Union); + #[test] + fn union_concatenates_both_added_no_base() { + let (_dir, cas) = tmp_cas(); + let store = FsStore::new(&cas); + let base = BTreeMap::new(); + let ours = stored(&store, &[("a.txt", b"AAA")]); + let theirs = stored(&store, &[("a.txt", b"BBB")]); + + let result = FuseEngine::fuse(&store, &base, &ours, &theirs, Strategy::Union); assert!(result.success); - // Union takes theirs for tie-breaking - assert!(result.merged_files.contains_key("a.txt")); + let (_, bytes) = store.load_blob(result.merged_files["a.txt"]).unwrap(); + assert_eq!(bytes, b"AAABBB"); + } + + #[test] + fn union_clean_resolves_do_not_concat() { + let (_dir, cas) = tmp_cas(); + let store = FsStore::new(&cas); + let base = stored(&store, &[("only_ours.txt", b"O0"), ("only_theirs.txt", b"T0")]); + // only_ours changed on our side; only_theirs changed on theirs. + let ours = stored(&store, &[("only_ours.txt", b"O1"), ("only_theirs.txt", b"T0")]); + let theirs = stored(&store, &[("only_ours.txt", b"O0"), ("only_theirs.txt", b"T1")]); + + let result = FuseEngine::fuse(&store, &base, &ours, &theirs, Strategy::Union); + assert!(result.success); + // Single-sided changes take the changed version verbatim, NOT a concat. + let (_, a) = store.load_blob(result.merged_files["only_ours.txt"]).unwrap(); + let (_, b) = store.load_blob(result.merged_files["only_theirs.txt"]).unwrap(); + assert_eq!(a, b"O1"); + assert_eq!(b, b"T1"); + } + + #[test] + fn union_concat_order_is_ours_then_theirs() { + let (_dir, cas) = tmp_cas(); + let store = FsStore::new(&cas); + let base = stored(&store, &[("a.txt", b"x")]); + let ours = stored(&store, &[("a.txt", b"<>")]); + let theirs = stored(&store, &[("a.txt", b"<>")]); + + let result = FuseEngine::fuse(&store, &base, &ours, &theirs, Strategy::Union); + let (_, bytes) = store.load_blob(result.merged_files["a.txt"]).unwrap(); + assert_eq!(bytes, b"<><>"); } #[test] @@ -532,7 +645,7 @@ mod tests { let ours = files(&[("a.txt", "modified")]); let theirs = files(&[]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Union); + let result = fuse_t(&base, &ours, &theirs, Strategy::Union); assert!(result.success); assert_eq!(result.merged_files["a.txt"], hash("modified")); } @@ -545,7 +658,7 @@ mod tests { let ours = files(&[("a.txt", "our change")]); let theirs = files(&[("a.txt", "their change")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Base); + let result = fuse_t(&base, &ours, &theirs, Strategy::Base); assert!(result.success); assert_eq!(result.merged_files["a.txt"], hash("original")); } @@ -556,7 +669,7 @@ mod tests { let ours = files(&[("new.txt", "added")]); let theirs = files(&[("other.txt", "also added")]); - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Base); + let result = fuse_t(&base, &ours, &theirs, Strategy::Base); assert!(result.success); assert!(result.merged_files.is_empty()); } @@ -604,7 +717,7 @@ mod tests { #[test] fn empty_merge() { let empty = BTreeMap::new(); - let result = FuseEngine::fuse(&empty, &empty, &empty, Strategy::Auto); + let result = fuse_t(&empty, &empty, &empty, Strategy::Auto); assert!(result.success); assert!(result.merged_files.is_empty()); assert!(result.conflicts.is_empty()); @@ -637,7 +750,7 @@ mod tests { theirs.insert(path, hash(&format!("their change {}", i))); } - let result = FuseEngine::fuse(&base, &ours, &theirs, Strategy::Auto); + let result = fuse_t(&base, &ours, &theirs, Strategy::Auto); assert!(result.success); assert_eq!(result.merged_files.len(), 100); } diff --git a/src/git_export.rs b/src/git_export.rs index 38c3759..7e02dc2 100644 --- a/src/git_export.rs +++ b/src/git_export.rs @@ -228,12 +228,13 @@ fn translate_tree( for entry in &entries { let (mode_str, child_sha1) = match entry.kind { NodeKind::Blob => { - let mode = if entry.mode == fsmerkle::MODE_FILE { - "100644" - } else { - // Ivaldi only carries MODE_FILE / MODE_DIR — preserve as - // 100644 for any other blob mode. - "100644" + // Emit the git mode that matches the stored Ivaldi mode so the + // round-trip preserves executable bits and symlinks (which are + // part of the git tree object and therefore its SHA-1). + let mode = match entry.mode { + fsmerkle::MODE_EXEC => "100755", + fsmerkle::MODE_SYMLINK => "120000", + _ => "100644", }; let sha = translate_blob(store, entry.hash, objects)?; (mode, sha) @@ -484,4 +485,53 @@ mod tests { let nul = tree_body.iter().position(|&b| b == 0).unwrap(); assert_eq!(tree_body[nul + 1..nul + 21], blob_obj.sha1); } + + #[test] + fn translate_tree_preserves_exec_and_symlink_modes() { + // Executable bit and symlinks are part of the git tree object (and thus + // its SHA-1). Export must emit 100755 / 120000, not collapse to 100644. + let dir = tempfile::tempdir().unwrap(); + let cas = FileCas::new(dir.path().join("objects")).unwrap(); + let store = FsStore::new(&cas); + + let (regular, _) = store.put_blob(b"plain").unwrap(); + let (script, _) = store.put_blob(b"#!/bin/sh\n").unwrap(); + // A symlink blob's content is the link target path. + let (link, _) = store.put_blob(b"regular.txt").unwrap(); + + let tree_hash = store + .put_tree(vec![ + fsmerkle::Entry { + name: "link".into(), + mode: fsmerkle::MODE_SYMLINK, + kind: NodeKind::Blob, + hash: link, + }, + fsmerkle::Entry { + name: "regular.txt".into(), + mode: fsmerkle::MODE_FILE, + kind: NodeKind::Blob, + hash: regular, + }, + fsmerkle::Entry { + name: "run.sh".into(), + mode: fsmerkle::MODE_EXEC, + kind: NodeKind::Blob, + hash: script, + }, + ]) + .unwrap(); + + let mut cache = BTreeMap::new(); + let mut objects: BTreeMap<[u8; 20], GitObject> = BTreeMap::new(); + let tree_sha = translate_tree(&store, tree_hash, &mut cache, &mut objects).unwrap(); + let body = &objects.get(&tree_sha).unwrap().body; + + let contains = |needle: &[u8]| { + body.windows(needle.len()).any(|w| w == needle) + }; + assert!(contains(b"100755 run.sh\0"), "executable mode preserved"); + assert!(contains(b"120000 link\0"), "symlink mode preserved"); + assert!(contains(b"100644 regular.txt\0"), "regular mode preserved"); + } } diff --git a/src/git_remote.rs b/src/git_remote.rs index 125cdf6..b5dc260 100644 --- a/src/git_remote.rs +++ b/src/git_remote.rs @@ -1265,7 +1265,7 @@ fn import_tree( tree_cache: &mut HashMap, submodules_skipped: &mut std::collections::BTreeSet, ) -> Result { - use crate::fsmerkle::{Entry, MODE_DIR, MODE_FILE, NodeKind}; + use crate::fsmerkle::{Entry, MODE_DIR, MODE_EXEC, MODE_FILE, MODE_SYMLINK, NodeKind}; if let Some(hash) = tree_cache.get(sha).copied() { return Ok(hash); @@ -1350,9 +1350,17 @@ fn import_tree( ivaldi_entries.retain(|e| e.name != out_name); } seen_names.insert(out_name.clone()); + // Preserve the original git file mode so the round-trip stays + // byte-for-byte: executable bit (100755) and symlinks (120000) + // must not collapse to a plain file (100644). + let mode = match entry.mode.as_str() { + "100755" => MODE_EXEC, + "120000" => MODE_SYMLINK, + _ => MODE_FILE, + }; ivaldi_entries.push(Entry { name: out_name, - mode: MODE_FILE, + mode, kind: NodeKind::Blob, hash, }); diff --git a/src/review.rs b/src/review.rs index bbeb45a..c5817c1 100644 --- a/src/review.rs +++ b/src/review.rs @@ -325,7 +325,7 @@ pub fn merge_review(repo: &mut Repo, review_id: u64) -> Result = result.conflicts.iter().map(|c| c.path.clone()).collect(); diff --git a/src/sync.rs b/src/sync.rs index 7d217a4..cfc9db3 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1585,6 +1585,7 @@ pub fn sync_timeline( // Auto-fuse let fuse_result = crate::fuse::FuseEngine::fuse( + &store, &base_files, &our_files, &their_files, diff --git a/src/tui/resolver.rs b/src/tui/resolver.rs index 1162903..f3641a3 100644 --- a/src/tui/resolver.rs +++ b/src/tui/resolver.rs @@ -1,15 +1,9 @@ -//! Interactive conflict resolver for fuse operations. +//! Shared types for per-file conflict resolution during fuse. //! -//! Presents each conflicted file and lets the user choose: -//! 1. Keep ours (target timeline) -//! 2. Keep theirs (source timeline) -//! 3. Keep both -//! 4. Skip -//! 5. Abort merge - -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; -use ratatui::prelude::*; -use ratatui::widgets::*; +//! The interactive flow itself lives in the Fuse tab view +//! (`crate::tui::views::fuse`), which drives selection through the app's +//! event loop as a modal rather than a separate blocking sub-loop. These +//! types are the contract between that view and the apply logic. /// A conflict to resolve. #[derive(Debug, Clone)] @@ -18,185 +12,23 @@ pub struct ConflictItem { pub description: String, } -/// Resolution choice for a single file. +/// Resolution choice for a single conflicted file. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Resolution { + /// Keep the target timeline's version. Ours, + /// Keep the source timeline's version. Theirs, + /// Concatenate both versions (ours then theirs) into one blob. Both, + /// Leave this file unresolved (the merge will not commit). Skip, } -/// Result of the resolver. -#[derive(Debug)] -pub enum ResolverResult { - /// All conflicts resolved. - Resolved(Vec<(String, Resolution)>), - /// User aborted. - Aborted, -} - -struct ResolverState { - conflicts: Vec, - current: usize, - resolutions: Vec>, - cursor: usize, -} - -const CHOICES: &[(&str, Resolution)] = &[ +/// The selectable resolutions, in display order. The index+1 is the hotkey. +pub const CHOICES: &[(&str, Resolution)] = &[ ("Keep OURS (target timeline)", Resolution::Ours), ("Keep THEIRS (source timeline)", Resolution::Theirs), ("Keep BOTH (concatenate)", Resolution::Both), ("Skip this file", Resolution::Skip), ]; - -/// Run the interactive conflict resolver. -pub fn run_resolver(conflicts: Vec) -> std::io::Result { - if conflicts.is_empty() { - return Ok(ResolverResult::Resolved(Vec::new())); - } - - let count = conflicts.len(); - let mut terminal = super::init_terminal()?; - let mut state = ResolverState { - resolutions: vec![None; count], - conflicts, - current: 0, - cursor: 0, - }; - - let result = loop { - terminal.draw(|frame| draw_resolver(frame, &state))?; - - if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { - continue; - } - match key.code { - KeyCode::Char('q') | KeyCode::Esc => break ResolverResult::Aborted, - KeyCode::Up => { - if state.cursor > 0 { - state.cursor -= 1; - } - } - KeyCode::Down => { - if state.cursor + 1 < CHOICES.len() { - state.cursor += 1; - } - } - KeyCode::Char('1') => { - resolve_current(&mut state, Resolution::Ours); - } - KeyCode::Char('2') => { - resolve_current(&mut state, Resolution::Theirs); - } - KeyCode::Char('3') => { - resolve_current(&mut state, Resolution::Both); - } - KeyCode::Char('4') => { - resolve_current(&mut state, Resolution::Skip); - } - KeyCode::Char('a') => break ResolverResult::Aborted, - KeyCode::Enter => { - let choice = CHOICES[state.cursor].1; - resolve_current(&mut state, choice); - } - _ => {} - } - - // Check if all resolved - if state.resolutions.iter().all(|r| r.is_some()) { - let resolved: Vec<(String, Resolution)> = state - .conflicts - .iter() - .zip(state.resolutions.iter()) - .filter_map(|(c, r)| r.map(|res| (c.path.clone(), res))) - .collect(); - break ResolverResult::Resolved(resolved); - } - } - }; - - super::restore_terminal()?; - Ok(result) -} - -fn resolve_current(state: &mut ResolverState, resolution: Resolution) { - state.resolutions[state.current] = Some(resolution); - // Advance to next unresolved - for i in 0..state.conflicts.len() { - let idx = (state.current + 1 + i) % state.conflicts.len(); - if state.resolutions[idx].is_none() { - state.current = idx; - state.cursor = 0; - return; - } - } -} - -fn draw_resolver(frame: &mut Frame, state: &ResolverState) { - let area = frame.area(); - - let header_area = Rect { height: 3, ..area }; - let conflict_area = Rect { - y: 3, - height: 4, - ..area - }; - let choices_area = Rect { - y: 7, - height: area.height.saturating_sub(10), - ..area - }; - let footer_area = Rect { - y: area.height.saturating_sub(3), - height: 3, - ..area - }; - - let resolved_count = state.resolutions.iter().filter(|r| r.is_some()).count(); - let total = state.conflicts.len(); - - let header = Paragraph::new(format!( - " Resolving conflicts: {}/{} done", - resolved_count, total, - )) - .block(Block::bordered().title(" Fuse Resolver ")); - frame.render_widget(header, header_area); - - let conflict = &state.conflicts[state.current]; - let status = match state.resolutions[state.current] { - Some(Resolution::Ours) => " [→ OURS]", - Some(Resolution::Theirs) => " [→ THEIRS]", - Some(Resolution::Both) => " [→ BOTH]", - Some(Resolution::Skip) => " [→ SKIPPED]", - None => "", - }; - - let conflict_text = Paragraph::new(format!( - " Conflict {} of {}: {}{}\n {}", - state.current + 1, - total, - conflict.path, - status, - conflict.description, - )) - .block(Block::bordered()); - frame.render_widget(conflict_text, conflict_area); - - let items: Vec = CHOICES - .iter() - .enumerate() - .map(|(i, (label, _))| { - let marker = if i == state.cursor { "→" } else { " " }; - ListItem::new(format!("{} [{}] {}", marker, i + 1, label)) - }) - .collect(); - - let list = List::new(items).block(Block::bordered().title(" Choose resolution ")); - frame.render_widget(list, choices_area); - - let footer = Paragraph::new(" ↑/↓ or 1-4 choose • Enter confirm • a abort • q quit") - .block(Block::bordered()); - frame.render_widget(footer, footer_area); -} diff --git a/src/tui/views/fuse.rs b/src/tui/views/fuse.rs index f3ea5c1..781e3f3 100644 --- a/src/tui/views/fuse.rs +++ b/src/tui/views/fuse.rs @@ -1,10 +1,14 @@ //! Fuse (merge) tab — merge timelines together. +use std::collections::BTreeMap; + use crossterm::event::{KeyCode, KeyEvent}; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; use crate::fuse::Strategy; +use crate::hash::B3Hash; +use crate::tui::resolver::{ConflictItem, Resolution, CHOICES}; use crate::tui::theme::Theme; use crate::tui::types::{Action, AppContext}; use crate::tui::views::TabView; @@ -17,6 +21,23 @@ const STRATEGIES: [Strategy; 5] = [ Strategy::Base, ]; +/// In-flight interactive resolution state, held while the resolver modal is up. +struct PendingFuse { + source_name: String, + target_name: String, + ours: BTreeMap, + theirs: BTreeMap, + /// Auto-resolved (non-conflicting) files. + merged_files: BTreeMap, + conflicts: Vec, + /// Parallel to `conflicts`. + resolutions: Vec>, + /// Conflict currently being decided. + current: usize, + /// Highlighted choice within CHOICES. + cursor: usize, +} + pub struct FuseView { timelines: Vec<(String, bool)>, // name, is_current — excludes current cursor: usize, @@ -24,6 +45,7 @@ pub struct FuseView { merge_in_progress: bool, merge_conflicts: Vec, confirm_abort: bool, + pending: Option, } impl FuseView { @@ -35,6 +57,7 @@ impl FuseView { merge_in_progress: false, merge_conflicts: Vec::new(), confirm_abort: false, + pending: None, } } @@ -53,95 +76,248 @@ impl FuseView { Err(e) => return Action::Error(format!("Failed: {}", e)), }; - // Get tree hashes for both timelines - let our_tree = match self.get_head_tree(ctx, ¤t) { - Some(h) => h, - None => return Action::Error("Current timeline has no commits".into()), + // Resolve head indices for both timelines. + let our_idx = match ctx.repo.get_timeline_head(¤t) { + Ok(Some(idx)) => idx, + Ok(None) => return Action::Error("Current timeline has no commits".into()), + Err(e) => return Action::Error(format!("Failed: {}", e)), + }; + let their_idx = match ctx.repo.get_timeline_head(&source_name) { + Ok(Some(idx)) => idx, + Ok(None) => { + return Action::Error(format!("Timeline '{}' has no commits", source_name)); + } + Err(e) => return Action::Error(format!("Failed: {}", e)), + }; + + let our_tree = match ctx.repo.get_leaf(our_idx) { + Ok(Some(l)) => l.tree_root, + _ => return Action::Error("Current timeline has no commits".into()), }; - let their_tree = match self.get_head_tree(ctx, &source_name) { - Some(h) => h, - None => return Action::Error(format!("Timeline '{}' has no commits", source_name)), + let their_tree = match ctx.repo.get_leaf(their_idx) { + Ok(Some(l)) => l.tree_root, + _ => return Action::Error(format!("Timeline '{}' has no commits", source_name)), }; - // Find common base (simplified: use empty tree as base) - let base = std::collections::BTreeMap::new(); + // Real LCA-based merge base (empty only for unrelated histories), + // mirroring the CLI. Using an empty base here would flag every + // differing file as a spurious conflict. + let base = match ctx.repo.merge_base(our_idx, their_idx) { + Ok(Some(base_idx)) => match ctx.repo.get_leaf(base_idx) { + Ok(Some(l)) => self.load_tree_map(ctx, l.tree_root), + _ => BTreeMap::new(), + }, + Ok(None) => BTreeMap::new(), + Err(e) => return Action::Error(format!("Merge base failed: {}", e)), + }; - // Load tree contents let ours = self.load_tree_map(ctx, our_tree); let theirs = self.load_tree_map(ctx, their_tree); - let result = crate::fuse::FuseEngine::fuse(&base, &ours, &theirs, self.current_strategy()); + let store = crate::fsmerkle::FsStore::new(&ctx.repo.cas); + let result = crate::fuse::FuseEngine::fuse( + &store, + &base, + &ours, + &theirs, + self.current_strategy(), + ); if result.success { - // Apply merged files - let config = crate::config::load_config(&ctx.ivaldi_dir); - let author = config - .author() - .unwrap_or_else(|| "unknown ".into()); - let msg = format!("Fuse {} into {}", source_name, current); - - // Build tree from merged file hashes and commit - let store = crate::fsmerkle::FsStore::new(&ctx.repo.cas); - - match store.build_tree_from_hash_map(&result.merged_files) { - Ok(tree_root) => match ctx.repo.commit(tree_root, &author, &msg) { - Ok(cr) => { - let _ = ctx.repo.clear_merge_state(); - Action::Success(format!("Fuse complete: {}", cr.seal_name)) + return self.commit_merged(ctx, &result.merged_files, &source_name, ¤t); + } + + // Conflicts: open the interactive resolver modal. + let conflicts: Vec = result + .conflicts + .iter() + .map(|c| ConflictItem { + path: c.path.clone(), + description: conflict_description(c), + }) + .collect(); + let n = conflicts.len(); + self.pending = Some(PendingFuse { + source_name, + target_name: current, + ours, + theirs, + merged_files: result.merged_files, + conflicts, + resolutions: vec![None; n], + current: 0, + cursor: 0, + }); + Action::Consumed + } + + /// Build a tree from `merged` and commit it as a fuse seal. + fn commit_merged( + &mut self, + ctx: &mut AppContext, + merged: &BTreeMap, + source: &str, + target: &str, + ) -> Action { + let config = crate::config::load_config(&ctx.ivaldi_dir); + let author = config + .author() + .unwrap_or_else(|| "unknown ".into()); + let msg = format!("Fuse {} into {}", source, target); + + let store = crate::fsmerkle::FsStore::new(&ctx.repo.cas); + match store.build_tree_from_hash_map(merged) { + Ok(tree_root) => match ctx.repo.commit(tree_root, &author, &msg) { + Ok(cr) => { + let _ = ctx.repo.clear_merge_state(); + self.merge_in_progress = false; + self.merge_conflicts.clear(); + Action::Success(format!("Fuse complete: {}", cr.seal_name)) + } + Err(e) => Action::Error(format!("Commit failed: {}", e)), + }, + Err(e) => Action::Error(format!("Tree build failed: {}", e)), + } + } + + // --- resolver modal ------------------------------------------------- + + fn handle_resolver_event(&mut self, event: &KeyEvent, ctx: &mut AppContext) -> Action { + match event.code { + KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('a') => { + return self.abort_resolver(ctx); + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(p) = self.pending.as_mut() { + if p.cursor > 0 { + p.cursor -= 1; + } + } + return Action::Consumed; + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(p) = self.pending.as_mut() { + if p.cursor + 1 < CHOICES.len() { + p.cursor += 1; } - Err(e) => Action::Error(format!("Commit failed: {}", e)), - }, - Err(e) => Action::Error(format!("Tree build failed: {}", e)), + } + return Action::Consumed; } - } else { - // Save merge state with conflicts - let conflicts: Vec = result.conflicts.iter().map(|c| c.path.clone()).collect(); - let state = crate::repo::MergeState { - source_timeline: source_name, - target_timeline: current, - strategy: self.current_strategy().to_string(), - conflicts: conflicts.clone(), - }; - match ctx.repo.save_merge_state(&state) { - Ok(()) => { - self.merge_in_progress = true; - self.merge_conflicts = conflicts; - Action::Error(format!( - "{} conflict(s) found. Resolve and continue, or abort.", - result.conflicts.len() - )) + KeyCode::Char('1') => self.resolve_current(Resolution::Ours), + KeyCode::Char('2') => self.resolve_current(Resolution::Theirs), + KeyCode::Char('3') => self.resolve_current(Resolution::Both), + KeyCode::Char('4') => self.resolve_current(Resolution::Skip), + KeyCode::Enter => { + if let Some(choice) = self.pending.as_ref().map(|p| CHOICES[p.cursor].1) { + self.resolve_current(choice); } - Err(e) => Action::Error(format!("Failed to save merge state: {}", e)), } + _ => return Action::Consumed, } + + // Apply once every conflict has a decision. + let all_done = self + .pending + .as_ref() + .map(|p| p.resolutions.iter().all(|r| r.is_some())) + .unwrap_or(false); + if all_done { + return self.apply_pending(ctx); + } + Action::Consumed } - fn get_head_tree(&self, ctx: &AppContext, timeline: &str) -> Option { - ctx.repo.walk_history(timeline).ok()?.first().and_then(|e| { - ctx.repo - .get_leaf(e.index) - .ok() - .flatten() - .map(|l| l.tree_root) - }) + fn resolve_current(&mut self, res: Resolution) { + if let Some(p) = self.pending.as_mut() { + if p.conflicts.is_empty() { + return; + } + p.resolutions[p.current] = Some(res); + // Advance to the next undecided conflict. + for i in 0..p.conflicts.len() { + let idx = (p.current + 1 + i) % p.conflicts.len(); + if p.resolutions[idx].is_none() { + p.current = idx; + p.cursor = 0; + return; + } + } + } + } + + fn abort_resolver(&mut self, ctx: &mut AppContext) -> Action { + let Some(p) = self.pending.take() else { + return Action::Consumed; + }; + let conflicts: Vec = p.conflicts.iter().map(|c| c.path.clone()).collect(); + let state = crate::repo::MergeState { + source_timeline: p.source_name, + target_timeline: p.target_name, + strategy: self.current_strategy().to_string(), + conflicts: conflicts.clone(), + }; + match ctx.repo.save_merge_state(&state) { + Ok(()) => { + self.merge_in_progress = true; + self.merge_conflicts = conflicts; + Action::Error("Resolution cancelled; merge left in progress.".into()) + } + Err(e) => Action::Error(format!("Failed to save merge state: {}", e)), + } + } + + fn apply_pending(&mut self, ctx: &mut AppContext) -> Action { + let Some(p) = self.pending.take() else { + return Action::Consumed; + }; + let resolutions: Vec<(String, Resolution)> = p + .conflicts + .iter() + .zip(p.resolutions.iter()) + .filter_map(|(c, r)| r.map(|res| (c.path.clone(), res))) + .collect(); + + let store = crate::fsmerkle::FsStore::new(&ctx.repo.cas); + let (final_map, skipped) = + apply_resolutions(&store, &p.merged_files, &p.ours, &p.theirs, &resolutions); + + if !skipped.is_empty() { + // Skipped files stay unresolved — do not commit; keep the merge + // in progress with only those paths outstanding. + let state = crate::repo::MergeState { + source_timeline: p.source_name.clone(), + target_timeline: p.target_name.clone(), + strategy: self.current_strategy().to_string(), + conflicts: skipped.clone(), + }; + let _ = ctx.repo.save_merge_state(&state); + self.merge_in_progress = true; + self.merge_conflicts = skipped.clone(); + return Action::Error(format!( + "{} file(s) skipped; merge left in progress.", + skipped.len() + )); + } + + self.commit_merged(ctx, &final_map, &p.source_name, &p.target_name) } fn load_tree_map( &self, ctx: &AppContext, - tree_hash: crate::hash::B3Hash, - ) -> std::collections::BTreeMap { + tree_hash: B3Hash, + ) -> BTreeMap { let store = crate::fsmerkle::FsStore::new(&ctx.repo.cas); - let mut map = std::collections::BTreeMap::new(); + let mut map = BTreeMap::new(); let _ = Self::collect_tree(&store, tree_hash, "", &mut map); map } fn collect_tree( store: &crate::fsmerkle::FsStore<'_>, - hash: crate::hash::B3Hash, + hash: B3Hash, prefix: &str, - map: &mut std::collections::BTreeMap, + map: &mut BTreeMap, ) -> Result<(), String> { let tree = store.load_tree(hash).map_err(|e| e.to_string())?; for entry in &tree.entries { @@ -158,10 +334,163 @@ impl FuseView { } Ok(()) } + + fn render_resolver(&self, frame: &mut Frame, area: Rect, theme: &Theme, p: &PendingFuse) { + let resolved = p.resolutions.iter().filter(|r| r.is_some()).count(); + let total = p.conflicts.len(); + + // Header. + let header = Paragraph::new(Span::styled( + format!(" Resolve conflicts — {}/{} decided", resolved, total), + theme.title, + )) + .block(Block::default().borders(Borders::ALL).title(" Fuse Resolver ")); + let header_area = Rect { height: 3.min(area.height), ..area }; + frame.render_widget(header, header_area); + + if let Some(conflict) = p.conflicts.get(p.current) { + let status = match p.resolutions[p.current] { + Some(Resolution::Ours) => " [→ OURS]", + Some(Resolution::Theirs) => " [→ THEIRS]", + Some(Resolution::Both) => " [→ BOTH]", + Some(Resolution::Skip) => " [→ SKIPPED]", + None => "", + }; + let conflict_area = Rect { + x: area.x, + y: area.y + 3, + width: area.width, + height: 3.min(area.height.saturating_sub(3)), + }; + let text = Paragraph::new(vec![ + Line::from(Span::styled( + format!( + " Conflict {} of {}: {}{}", + p.current + 1, + total, + conflict.path, + status + ), + theme.warning, + )), + Line::from(Span::styled(format!(" {}", conflict.description), theme.dim)), + ]) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(text, conflict_area); + } + + // Choices. + let choices_y = area.y + 6; + let choices_area = Rect { + x: area.x, + y: choices_y, + width: area.width, + height: area.height.saturating_sub(9), + }; + let items: Vec = CHOICES + .iter() + .enumerate() + .map(|(i, (label, _))| { + let marker = if i == p.cursor { "→" } else { " " }; + let text = format!("{} [{}] {}", marker, i + 1, label); + let style = if i == p.cursor { + theme.cursor + } else { + Style::default().fg(Color::White) + }; + ListItem::new(Span::styled(text, style)) + }) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Choose resolution ")); + frame.render_widget(list, choices_area); + + // Footer help. + if area.height > 1 { + let help_area = Rect { + x: area.x, + y: area.y + area.height - 1, + width: area.width, + height: 1, + }; + let help = Paragraph::new(Span::styled( + " ↑/↓ choose • 1-4 pick • Enter confirm • a/q abort", + theme.dim, + )); + frame.render_widget(help, help_area); + } + } +} + +/// Apply per-file resolutions on top of the auto-merged map. +/// +/// Returns the final `path → hash` map and the list of paths the user chose to +/// skip (left unresolved). Skipped paths are omitted from the map and signal the +/// caller not to commit. +pub(crate) fn apply_resolutions( + store: &crate::fsmerkle::FsStore<'_>, + merged: &BTreeMap, + ours: &BTreeMap, + theirs: &BTreeMap, + resolutions: &[(String, Resolution)], +) -> (BTreeMap, Vec) { + let mut final_map = merged.clone(); + let mut skipped = Vec::new(); + + for (path, res) in resolutions { + match res { + Resolution::Ours => match ours.get(path) { + Some(h) => { + final_map.insert(path.clone(), *h); + } + None => { + final_map.remove(path); + } + }, + Resolution::Theirs => match theirs.get(path) { + Some(h) => { + final_map.insert(path.clone(), *h); + } + None => { + final_map.remove(path); + } + }, + Resolution::Both => match (ours.get(path), theirs.get(path)) { + (Some(o), Some(t)) => { + let h = crate::fuse::concat_blobs(store, o, t).unwrap_or(*o); + final_map.insert(path.clone(), h); + } + (Some(h), None) | (None, Some(h)) => { + final_map.insert(path.clone(), *h); + } + (None, None) => { + final_map.remove(path); + } + }, + Resolution::Skip => skipped.push(path.clone()), + } + } + + (final_map, skipped) +} + +/// Human-readable summary of a file conflict. +fn conflict_description(c: &crate::fuse::Conflict) -> String { + match (c.base.is_some(), c.ours.is_some(), c.theirs.is_some()) { + (_, true, true) => "modified on both sides".into(), + (true, true, false) => "modified here, deleted on the other side".into(), + (true, false, true) => "deleted here, modified on the other side".into(), + _ => "conflicting change".into(), + } } impl TabView for FuseView { fn handle_event(&mut self, event: &KeyEvent, ctx: &mut AppContext) -> Action { + // Interactive resolver modal takes precedence. + if self.pending.is_some() { + return self.handle_resolver_event(event, ctx); + } + // Abort confirmation if self.confirm_abort { match event.code { @@ -213,6 +542,12 @@ impl TabView for FuseView { } fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + // Resolver modal replaces the normal view. + if let Some(p) = self.pending.as_ref() { + self.render_resolver(frame, area, theme, p); + return; + } + // Merge in progress banner if self.merge_in_progress { let banner_area = Rect { @@ -352,6 +687,66 @@ impl TabView for FuseView { } fn has_active_input(&self) -> bool { - self.confirm_abort + self.confirm_abort || self.pending.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_store() -> (tempfile::TempDir, crate::cas::FileCas) { + let dir = tempfile::tempdir().unwrap(); + let cas = crate::cas::FileCas::new(dir.path().join("objects")).unwrap(); + (dir, cas) + } + + #[test] + fn apply_resolutions_covers_all_choices() { + let (_dir, cas) = tmp_store(); + let store = crate::fsmerkle::FsStore::new(&cas); + + // Auto-merged, non-conflicting file carries through untouched. + let (clean, _) = store.put_blob(b"clean").unwrap(); + let merged: BTreeMap = [("clean.txt".to_string(), clean)].into(); + + let put = |c: &[u8]| store.put_blob(c).unwrap().0; + let ours: BTreeMap = [ + ("a.txt".to_string(), put(b"OURS_A")), + ("b.txt".to_string(), put(b"OURS_B")), + ("c.txt".to_string(), put(b"OURS_C")), + ("d.txt".to_string(), put(b"OURS_D")), + ] + .into(); + let theirs: BTreeMap = [ + ("a.txt".to_string(), put(b"THEIRS_A")), + ("b.txt".to_string(), put(b"THEIRS_B")), + ("c.txt".to_string(), put(b"THEIRS_C")), + ("d.txt".to_string(), put(b"THEIRS_D")), + ] + .into(); + + let resolutions = vec![ + ("a.txt".to_string(), Resolution::Ours), + ("b.txt".to_string(), Resolution::Theirs), + ("c.txt".to_string(), Resolution::Both), + ("d.txt".to_string(), Resolution::Skip), + ]; + + let (final_map, skipped) = + apply_resolutions(&store, &merged, &ours, &theirs, &resolutions); + + // Skip leaves the file unresolved and out of the committed map. + assert_eq!(skipped, vec!["d.txt".to_string()]); + assert!(!final_map.contains_key("d.txt")); + + // Clean auto-merge survives. + assert_eq!(final_map["clean.txt"], clean); + + let load = |h: B3Hash| store.load_blob(h).unwrap().1; + assert_eq!(load(final_map["a.txt"]), b"OURS_A"); + assert_eq!(load(final_map["b.txt"]), b"THEIRS_B"); + // Both concatenates ours then theirs. + assert_eq!(load(final_map["c.txt"]), b"OURS_CTHEIRS_C"); } } diff --git a/src/workspace.rs b/src/workspace.rs index 16a5f24..4424d8c 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -548,8 +548,8 @@ impl<'a> Workspace<'a> { ignore: &PatternCache, ) -> Result<(), WorkspaceError> { let store = FsStore::new(self.cas); - let mut target_files = BTreeMap::new(); - self.collect_tree_files(&store, tree_hash, "", &mut target_files)?; + let mut target_files: BTreeMap = BTreeMap::new(); + self.collect_tree_blobs(&store, tree_hash, "", &mut target_files)?; // Scan the working dir respecting ignores; any file not in this set // is either ignored or absent — either way we won't delete it below. @@ -557,25 +557,17 @@ impl<'a> Workspace<'a> { let current_set: BTreeSet = current_files.into_iter().collect(); // Write/update files - for (path, blob_hash) in &target_files { + for (path, (blob_hash, mode)) in &target_files { let full_path = self.work_dir.join(path); let (_, content) = store .load_blob(*blob_hash) .map_err(WorkspaceError::FsMerkle)?; - let should_write = if full_path.exists() { - let existing = fs::read(&full_path).map_err(WorkspaceError::Io)?; - existing != content - } else { - true - }; - - if should_write { - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent).map_err(WorkspaceError::Io)?; - } - fs::write(&full_path, &content).map_err(WorkspaceError::Io)?; + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).map_err(WorkspaceError::Io)?; } + + self.write_entry(&full_path, &content, *mode)?; } // Remove non-ignored files not in target tree @@ -622,6 +614,99 @@ impl<'a> Workspace<'a> { Ok(()) } + /// Collect all blob file paths with their hash and mode, recursively. + fn collect_tree_blobs( + &self, + store: &FsStore<'_>, + tree_hash: B3Hash, + prefix: &str, + files: &mut BTreeMap, + ) -> Result<(), WorkspaceError> { + let tree = store + .load_tree(tree_hash) + .map_err(WorkspaceError::FsMerkle)?; + + for entry in &tree.entries { + let path = if prefix.is_empty() { + entry.name.clone() + } else { + format!("{}/{}", prefix, entry.name) + }; + + match entry.kind { + NodeKind::Blob => { + files.insert(path, (entry.hash, entry.mode)); + } + NodeKind::Tree => { + self.collect_tree_blobs(store, entry.hash, &path, files)?; + } + } + } + + Ok(()) + } + + /// Write a single materialized entry to disk, honoring its file mode: + /// symlinks become real symlinks, executables get the exec bit set. + /// On non-unix platforms the content is written as a plain file. + fn write_entry( + &self, + full_path: &std::path::Path, + content: &[u8], + mode: u32, + ) -> Result<(), WorkspaceError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + if mode == crate::fsmerkle::MODE_SYMLINK { + // Blob content is the link target. Recreate unconditionally so a + // changed target is reflected (fs::read would follow the link). + let target = String::from_utf8_lossy(content).into_owned(); + if full_path.symlink_metadata().is_ok() { + let _ = fs::remove_file(full_path); + } + std::os::unix::fs::symlink(&target, full_path).map_err(WorkspaceError::Io)?; + return Ok(()); + } + + // Regular or executable file: skip the write if content already matches. + let needs_write = match fs::read(full_path) { + Ok(existing) => existing != content, + Err(_) => true, + }; + if needs_write { + // If a symlink currently occupies this path, drop it first. + if full_path.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false) + { + let _ = fs::remove_file(full_path); + } + fs::write(full_path, content).map_err(WorkspaceError::Io)?; + } + let perm = if mode == crate::fsmerkle::MODE_EXEC { + 0o755 + } else { + 0o644 + }; + fs::set_permissions(full_path, fs::Permissions::from_mode(perm)) + .map_err(WorkspaceError::Io)?; + Ok(()) + } + + #[cfg(not(unix))] + { + let _ = mode; + let needs_write = match fs::read(full_path) { + Ok(existing) => existing != content, + Err(_) => true, + }; + if needs_write { + fs::write(full_path, content).map_err(WorkspaceError::Io)?; + } + Ok(()) + } + } + /// Save workspace state to disk. pub fn save(&self) -> Result<(), WorkspaceError> { self.staging @@ -1104,6 +1189,61 @@ mod tests { assert_eq!(map.len(), 2); } + #[cfg(unix)] + #[test] + fn materialize_applies_exec_bit_and_symlink() { + use std::os::unix::fs::PermissionsExt; + + let (dir, cas) = setup_workspace(); + let store = FsStore::new(&cas); + let (regular, _) = store.put_blob(b"plain\n").unwrap(); + let (script, _) = store.put_blob(b"#!/bin/sh\n").unwrap(); + let (link, _) = store.put_blob(b"regular.txt").unwrap(); + + let tree = store + .put_tree(vec![ + Entry { + name: "regular.txt".into(), + mode: MODE_FILE, + kind: NodeKind::Blob, + hash: regular, + }, + Entry { + name: "run.sh".into(), + mode: crate::fsmerkle::MODE_EXEC, + kind: NodeKind::Blob, + hash: script, + }, + Entry { + name: "link".into(), + mode: crate::fsmerkle::MODE_SYMLINK, + kind: NodeKind::Blob, + hash: link, + }, + ]) + .unwrap(); + + let ws = Workspace::new(&cas, dir.path(), dir.path().join(".ivaldi")); + ws.materialize_with_ignore(tree, &PatternCache::new(&[])) + .unwrap(); + + // Executable bit set on the script. + let exec = fs::metadata(dir.path().join("run.sh")).unwrap(); + assert!(exec.permissions().mode() & 0o111 != 0, "exec bit set"); + + // Symlink created pointing at its target (not a regular file). + let lmeta = fs::symlink_metadata(dir.path().join("link")).unwrap(); + assert!(lmeta.file_type().is_symlink(), "link is a real symlink"); + assert_eq!( + fs::read_link(dir.path().join("link")).unwrap().to_str().unwrap(), + "regular.txt" + ); + + // Regular file is not executable. + let reg = fs::metadata(dir.path().join("regular.txt")).unwrap(); + assert!(reg.permissions().mode() & 0o111 == 0, "regular not exec"); + } + #[test] fn status_untracked() { let (dir, cas) = setup_workspace(); From 808de38d501bebe15521974cf35bac3b40f2759b Mon Sep 17 00:00:00 2001 From: javanhut Date: Wed, 10 Jun 2026 00:43:12 -0400 Subject: [PATCH 2/3] feat: did crash hardening and function updating --- .github/workflows/ci.yml | 40 + Cargo.lock | 28 + Cargo.toml | 3 + Makefile | 17 +- README.md | 19 +- docs/atomic_io.md | 40 + docs/cas.md | 8 + docs/cli.md | 47 +- docs/config.md | 23 +- docs/ignore.md | 19 + docs/lock.md | 37 + docs/oauth_device.md | 38 + docs/pick.md | 40 + docs/rosetta.md | 13 +- docs/switch_journal.md | 47 + docs/sync.md | 17 +- docs/timeline.md | 5 + docs/undo.md | 68 + docs/workspace.md | 4 + src/atomic_io.rs | 109 ++ src/auth.rs | 49 +- src/butterfly.rs | 12 +- src/cas.rs | 90 +- src/cli/commands.rs | 2025 ++++++++++++++++++---- src/cli/json.rs | 128 ++ src/cli/mod.rs | 303 +++- src/color.rs | 5 + src/config.rs | 11 +- src/diff.rs | 243 ++- src/filechunk.rs | 2 +- src/forge.rs | 11 +- src/fsmerkle.rs | 4 +- src/fuse.rs | 64 +- src/gc.rs | 10 +- src/git_export.rs | 70 +- src/git_pack_writer.rs | 2 +- src/git_remote.rs | 149 +- src/github.rs | 133 +- src/gitlab.rs | 165 +- src/identity.rs | 4 +- src/known_peers.rs | 23 +- src/lib.rs | 5 + src/lock.rs | 105 ++ src/mmr.rs | 2 +- src/oauth_device.rs | 287 +++ src/p2p.rs | 109 +- src/pack.rs | 160 +- src/peers.rs | 19 +- src/pick.rs | 295 ++++ src/portal.rs | 4 +- src/remote.rs | 10 +- src/repo.rs | 329 +++- src/review.rs | 49 +- src/seal.rs | 2 +- src/shelf.rs | 66 +- src/ssh_transport.rs | 43 +- src/store.rs | 12 +- src/submodule.rs | 20 +- src/switch_journal.rs | 89 + src/sync.rs | 2872 ------------------------------- src/sync/import.rs | 504 ++++++ src/sync/mod.rs | 1512 ++++++++++++++++ src/sync/timeline_sync.rs | 491 ++++++ src/sync/upload.rs | 626 +++++++ src/tui/app.rs | 142 +- src/tui/components/diff_view.rs | 6 + src/tui/components/file_list.rs | 6 + src/tui/config_form.rs | 308 ++-- src/tui/input.rs | 6 + src/tui/launcher.rs | 9 +- src/tui/shift.rs | 13 +- src/tui/travel.rs | 114 +- src/tui/views/diff.rs | 25 +- src/tui/views/fuse.rs | 65 +- src/tui/views/log.rs | 8 +- src/tui/views/remote.rs | 36 +- src/tui/views/review.rs | 24 +- src/tui/views/shelves.rs | 20 +- src/tui/views/status.rs | 26 +- src/tui/views/timeline.rs | 19 +- src/workspace.rs | 167 +- 81 files changed, 8327 insertions(+), 4403 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/atomic_io.md create mode 100644 docs/lock.md create mode 100644 docs/oauth_device.md create mode 100644 docs/pick.md create mode 100644 docs/switch_journal.md create mode 100644 docs/undo.md create mode 100644 src/atomic_io.rs create mode 100644 src/cli/json.rs create mode 100644 src/lock.rs create mode 100644 src/oauth_device.rs create mode 100644 src/pick.rs create mode 100644 src/switch_journal.rs delete mode 100644 src/sync.rs create mode 100644 src/sync/import.rs create mode 100644 src/sync/mod.rs create mode 100644 src/sync/timeline_sync.rs create mode 100644 src/sync/upload.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa44471 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets -- -D warnings + + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --all-targets diff --git a/Cargo.lock b/Cargo.lock index de9db0c..a7f09cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -312,6 +312,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.1" @@ -330,6 +339,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1101,6 +1120,8 @@ dependencies = [ "base64", "blake3", "clap", + "clap_complete", + "clap_mangen", "crossterm", "flate2", "hex", @@ -1109,6 +1130,7 @@ dependencies = [ "ratatui", "rayon", "redb", + "rustix", "serde", "serde_json", "sha1", @@ -1758,6 +1780,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 80b89b8..5fd44bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" clap = { version = "4", features = ["derive"] } +clap_complete = "4" +clap_mangen = "0.2" redb = "4.1.0" ureq = { version = "3.3.0", features = ["json"] } base64 = "0.22" @@ -22,6 +24,7 @@ flate2 = "1" sha1 = "0.10" snow = "0.9" rand_core = { version = "0.6", features = ["getrandom"] } +rustix = { version = "1", features = ["fs", "std"] } [dev-dependencies] tempfile = "3" diff --git a/Makefile b/Makefile index a70204e..1369ae1 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BINDIR = $(PREFIX)/bin BINARY = ivaldi TARGET = target/release/$(BINARY) -.PHONY: build test install uninstall clean help +.PHONY: build test install install-extras uninstall clean help ## Build release binary build: @@ -21,6 +21,20 @@ install: build @echo "Installed $(BINARY) to $(BINDIR)/$(BINARY)" @echo "Run 'ivaldi --version' to verify." +## Install man pages and shell completions (bash/zsh/fish) +install-extras: build + @echo "Installing man pages to $(PREFIX)/share/man/man1..." + @install -d $(PREFIX)/share/man/man1 + @$(TARGET) man --out $(PREFIX)/share/man/man1 + @echo "Installing shell completions..." + @install -d $(PREFIX)/share/bash-completion/completions + @$(TARGET) completions bash > $(PREFIX)/share/bash-completion/completions/ivaldi + @install -d $(PREFIX)/share/zsh/site-functions + @$(TARGET) completions zsh > $(PREFIX)/share/zsh/site-functions/_ivaldi + @install -d $(PREFIX)/share/fish/vendor_completions.d + @$(TARGET) completions fish > $(PREFIX)/share/fish/vendor_completions.d/ivaldi.fish + @echo "Installed man pages and completions under $(PREFIX)/share" + ## Uninstall ivaldi from $(PREFIX)/bin uninstall: @echo "Removing $(BINDIR)/$(BINARY)..." @@ -38,6 +52,7 @@ help: @echo " make build Build release binary" @echo " make test Run all tests" @echo " make install Install to $(BINDIR) (may need sudo)" + @echo " make install-extras Install man pages and bash/zsh/fish completions (may need sudo)" @echo " make uninstall Remove from $(BINDIR) (may need sudo)" @echo " make clean Clean build artifacts" @echo "" diff --git a/README.md b/README.md index 0e5dcb8..dfa8627 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,18 @@ ivaldi config --set user.email "you@example.com" # Daily workflow ivaldi gather . # Stage all files +ivaldi gather -p src/main.rs # Stage only some hunks (interactive) ivaldi seal "Add new feature" # Commit +ivaldi reseal "Better message" # Redo the last seal (message and/or staged changes) ivaldi status # Check workspace state ivaldi log --oneline # View history +# Going back (history is never rewritten — old seals stay recoverable) +ivaldi undo swift-eagle # New seal that removes swift-eagle's changes +ivaldi pluck gentle-otter # New seal that applies gentle-otter's changes +ivaldi rewind calm-river # Move the head back; your files stay as-is +ivaldi rewind calm-river --discard # Move the head back AND rewrite the files + # Timelines (branches) ivaldi timeline create feature # Create timeline ivaldi timeline switch feature # Switch (auto-shelves changes) @@ -74,7 +82,11 @@ ivaldi peer known list # Servers we trust (TOFU known_peers) | `log` | | View commit history | | `whodidit ` | `blame` | Show which seal last touched each line of a file | | `diff` | | Compare changes | -| `reset` | | Unstage files or hard reset | +| `reseal` | | Redo the most recent seal (new message and/or staged changes) | +| `reset` | | Unstage files or discard local changes | +| `rewind ` | | Move the timeline head back (`--discard` to also rewrite files) | +| `undo ` | | New seal that removes an earlier seal's changes | +| `pluck ` | `cherry-pick` | New seal that applies another seal's changes | | `timeline` | `tl` | Manage timelines (create/switch/list/rename/remove) | | `butterfly` | `tl bf` | Experimental sandbox timelines | | `fuse` | | Merge timelines (auto strategy uses MMR-based merge base) | @@ -91,6 +103,11 @@ ivaldi peer known list # Servers we trust (TOFU known_peers) | `sync` | | Pull remote changes (delta only) | | `serve` | | Run an `ivaldi://` peer server for trusted users | | `peer` | | Manage trusted peers + known servers (`trust` / `list` / `forget` / `whoami` / `known`) | +| `completions ` | | Print a shell completion script (bash/zsh/fish/powershell/elvish) | + +`status`, `timeline list`, and `portal list` accept `--json`, and +`log --format json` emits machine-readable history — handy for scripts and CI. +`make install-extras` installs man pages and shell completions. ## Ivaldi vs Git diff --git a/docs/atomic_io.md b/docs/atomic_io.md new file mode 100644 index 0000000..08a674d --- /dev/null +++ b/docs/atomic_io.md @@ -0,0 +1,40 @@ +# Atomic I/O Module (`atomic_io.rs`) + +Atomic file replacement for repository metadata files. + +## Overview + +A plain `fs::write` can leave a truncated file behind if the process +crashes mid-write. Every metadata file under `.ivaldi/` goes through +`atomic_write` instead: bytes are written to a unique temp file in the +same directory, fsynced, then renamed over the destination, and the +parent directory is fsynced best-effort. Readers observe either the old +contents or the new contents — never a partial file. + +```rust +pub fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> +``` + +## Call sites + +| File | Writer | +|------|--------| +| `.ivaldi/stage/files` | `StagingArea::save` (workspace.rs) | +| `.ivaldi/HEAD` | `forge::write_head` | +| `.ivaldi/shelves/.shelf` | `ShelfManager::save_shelf` | +| `.ivaldi/MERGE_STATE` | `Repo::save_merge_state` | +| `.ivaldi/reviews/.json` | `Repo::save_review` | +| `.ivaldi/dotfile-allowlist` | `DotfileAllowlist::save` | +| `.ivaldi/config` | `Config::save` | +| `.ivaldi/SWITCH_IN_PROGRESS` | `switch_journal::write` | + +Working-tree writes (materialize/apply_changes) intentionally do NOT use +this — those are user files where plain writes are correct. + +## Notes + +- Temp names are `{name}.tmp.{pid}.{counter}`, mirroring `FileCas::put`, + so concurrent processes can't collide; failures clean up the temp file. +- The rename requires the parent directory to already exist. +- macOS note: `sync_all` issues `F_FULLFSYNC`; these are sub-KB files + written once per command, so the cost is negligible. diff --git a/docs/cas.md b/docs/cas.md index 6b37c53..d3e9181 100644 --- a/docs/cas.md +++ b/docs/cas.md @@ -67,6 +67,14 @@ let cas = FileCas::new(".ivaldi/objects")?; **Idempotent:** Writing the same content twice is a no-op (skips if file exists). +**Durability — `flush()`:** `put` skips fsync on the hot path for speed. +`FileCas` tracks which shard directories were written since the last +flush, and `flush()` fsyncs exactly those (a no-op when nothing was +written). Callers flush at the points where the CAS holds the only copy +of data before a commit record references it: after building a seal tree +(`seal`/`reseal`), after the shelf capture during a timeline switch, and +at the end of bulk imports (`git_remote`, `p2p`) and `gather --patch`. + ## Error Types ```rust diff --git a/docs/cli.md b/docs/cli.md index 5735e9c..2370ce0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -7,13 +7,18 @@ Command-line interface for Ivaldi VCS, built with `clap`. | Command | Alias | Description | |---------|-------|-------------| | `forge` | | Initialize repository | -| `gather [files]` | | Stage files for next seal | +| `gather [files] [-p]` | | Stage files for next seal (`-p`/`--patch` picks hunks interactively) | | `seal "msg"` | | Create sealed commit | -| `status` | | Show repository status | +| `reseal [msg]` | | Redo the most recent seal — folds in staged changes and/or a new message | +| `status [--json]` | | Show repository status | | `whereami` | `wai` | Show current position | -| `log` | | View commit history | +| `log [--format short\|medium\|full\|json]` | | View commit history | +| `whodidit [--summary]` | `blame` | Line-by-line seal attribution | | `diff` | | Compare changes | -| `reset [files]` | | Unstage files | +| `reset [files]` / `reset --hard` | | Unstage files / discard local changes | +| `rewind [--discard]` | | Move the timeline head back to an earlier seal (`--discard` also rewrites files) | +| `undo ` | | New seal that removes an earlier seal's changes | +| `pluck ` | `cherry-pick` | New seal that applies another seal's changes | | `timeline create/switch/list/rename/remove` | `tl` | Manage timelines | | `timeline butterfly create/up/down/rm` | `tl bf` | Butterfly timelines | | `fuse to ` | | Merge timelines (auto strategy uses MMR-based merge base) | @@ -31,6 +36,11 @@ Command-line interface for Ivaldi VCS, built with `clap`. | `serve [--bind addr:port]` | | Run an `ivaldi://` peer server | | `peer trust/list/forget/whoami/known` | | Manage peer pubkey allowlists + TOFU known servers | | `review create/list/show/diff/comment/approve/request-changes/merge/close/reopen` | `rv` | Local code review system | +| `completions ` | | Print a bash/zsh/fish/powershell/elvish completion script | +| `man [--out dir]` | | Generate man pages (used by `make install-extras`) | + +`timeline list`, `portal list`, and `status` accept `--json` for scripting; +`log --format json` does the same for history. ## Global Flags @@ -55,6 +65,24 @@ ivaldi gather . ivaldi seal "Add feature" ivaldi status +# Fixing up the most recent seal +ivaldi gather forgotten.txt +ivaldi reseal # fold staged changes in, keep the message +ivaldi reseal "better message" # and/or replace the message + +# Stage only some hunks of a file +ivaldi gather -p src/main.rs # y/n per hunk; a=rest, d=skip rest, q=quit + +# Going back +ivaldi undo swift-eagle # new seal that removes swift-eagle's changes +ivaldi pluck gentle-otter # new seal that applies gentle-otter's changes +ivaldi rewind calm-river # head moves back; your files stay as-is +ivaldi rewind calm-river --discard # head moves back AND files are rewritten + +# Scripting +ivaldi status --json | jq '.files[].path' +ivaldi log --format json | jq '.[0].seal_name' + # Timelines ivaldi tl create feature ivaldi tl sw feature @@ -120,8 +148,17 @@ ivaldi exclude "*.log" "build/" "node_modules/" | `--global` | Target `~/.ivaldi/config` instead of repo-local | | (no flag) | Launch the interactive ratatui form | +`configure` is an alias for `config`. + +The interactive form's first field is the **scope** — toggle between +repo-local and global with ←/→ or Enter; the form reloads from (and saves +to) whichever config file is selected. Below that it covers `user.name`, +`user.email`, `color.ui`, `core.autoshelf`, and (local scope only) +`portal.default`. Email and repo-spec values are validated as you type. + `ivaldi config` **no longer requires being inside an Ivaldi repo** — outside -a repo it automatically operates on the global config. +a repo it automatically operates on the global config (the scope selector +is locked to global). ## Architecture diff --git a/docs/config.md b/docs/config.md index 49d6acb..c277610 100644 --- a/docs/config.md +++ b/docs/config.md @@ -66,16 +66,20 @@ Pass `--global` explicitly to write globally even when inside a repo. ### Interactive form -Invoking `ivaldi config` without `--list`/`--get`/`--set` opens a ratatui form: +Invoking `ivaldi config` (or its alias `ivaldi configure`) without +`--list`/`--get`/`--set` opens a ratatui form: ``` ┌─ Config ───────────────────────────────────┐ -│ Ivaldi Configuration (repo-local) │ +│ Ivaldi Configuration (local) │ │ /home/alice/project/.ivaldi/config │ └────────────────────────────────────────────┘ ┌────────────────────────────────────────────┐ +│ Scope │ +│ ▸ save to (●) local ( ) global │ +│ │ │ User │ -│ ▸ name [Alice ] │ +│ name [Alice ] │ │ email [alice@example.com] │ │ │ │ Appearance │ @@ -90,13 +94,19 @@ Invoking `ivaldi config` without `--list`/`--get`/`--set` opens a ratatui form: [↑↓] Navigate [Enter] Edit [←→] Toggle [s] Save [q] Quit ``` +The first field selects the **scope**: repo-local (`.ivaldi/config`) or +global (`~/.ivaldi/config`). Toggling it reloads the form from the newly +selected file — and `s` saves to that file. Unsaved edits are discarded on +a scope switch (a notice says so). Outside a repository the selector is +locked to global. Passing `--global` just picks the starting scope. + Controls: | Key | Action | |-----|--------| | ↑/↓ or j/k | Navigate fields | -| Enter | Edit text field (or toggle bool) | -| ←/→ or h/l | Toggle bool fields | +| Enter | Edit text field (or toggle scope/bool) | +| ←/→ or h/l | Toggle scope and bool fields | | Esc | Cancel edit / exit without saving | | `s` | Save and exit | | `q` | Quit (prompts if modified) | @@ -105,7 +115,8 @@ Validation: - `user.email` must match `x@y.z` - `portal.default` must parse as a valid repo spec (see [portal](portal.md)) -The **Remote** section only appears when run inside an Ivaldi repo. +The **Remote** section only appears in local scope (it's a per-repo +setting). ## Library Usage diff --git a/docs/ignore.md b/docs/ignore.md index acd57b2..390b3b2 100644 --- a/docs/ignore.md +++ b/docs/ignore.md @@ -24,6 +24,25 @@ Always excluded regardless of `.ivaldiignore`: - `.env`, `.env.*` — environment variable files - `.venv`, `.venv/` — Python virtual environments +## Dotfiles and Security Blocks + +Dotfile handling is separate from `.ivaldiignore` patterns and often +surprises users coming from git: + +- **Dotfiles are skipped by `gather .`** even when no ignore pattern + matches them. The skipped paths are reported on stderr so they are not + silently lost. +- **Explicitly gathering a dotfile** (`ivaldi gather .editorconfig`) + prompts `y/N` per file. Confirmed paths are remembered in + `.ivaldi/dotfile-allowlist`, so each dotfile is asked about only once. +- **`ivaldi gather --allow-all`** confirms all pending dotfiles in one go. +- **Security-blocked files cannot be staged at all.** `.env`, `.env.*`, + and `.venv` are hard-blocked; neither the allowlist nor `--allow-all` + overrides this. Staging one is an error, not a prompt. +- Adding `.*` to `.ivaldiignore` is unnecessary — the dotfile gate already + covers hidden files; the ignore file is for non-hidden paths like + `build/` or `*.log` (managed with `ivaldi exclude `). + ## Built-In Defaults Always excluded (VCS/tool directories): diff --git a/docs/lock.md b/docs/lock.md new file mode 100644 index 0000000..721bc42 --- /dev/null +++ b/docs/lock.md @@ -0,0 +1,37 @@ +# Lock Module (`lock.rs`) + +Process-level repository lock for mutating commands. + +## Overview + +redb serializes individual store transactions, but multi-step operations +(seal, timeline switch, fuse, …) also touch plain files under `.ivaldi/` +(HEAD, staging, shelves) with no coordination. `RepoLock` gives mutating +commands an exclusive advisory `flock(2)` on `.ivaldi/repo.lock` so two +concurrent ivaldi processes can't interleave. + +```rust +let _lock = RepoLock::acquire(&ivaldi_dir)?; // released on drop / process death +``` + +## Behavior + +- **Non-blocking**: a second mutating command fails immediately with + "another ivaldi process is operating on this repository" instead of + hanging. +- **Crash-safe**: the kernel releases the flock when the holding process + exits, even on a crash — a stale lock file is never a problem (this is + why an `O_CREAT|O_EXCL` sentinel was rejected). +- **Read-only commands take no lock** (`status`, `log`, `diff`, …). They + still serialize against writers via redb's own file lock; that + contention surfaces as a friendly "store is in use" message from + `Store::open`. +- The lock file contains the holder's PID for diagnostics only — it is + never read for correctness. + +## Which commands lock + +See `command_mutates()` in `cli/commands.rs`: gather, seal, reseal, +reset, rewind, undo, pluck, fuse, travel, weld, harvest, sync, upload, +exclude, and the mutating timeline/review subcommands. `download` is +excluded because it may target a fresh clone outside any repository. diff --git a/docs/oauth_device.md b/docs/oauth_device.md new file mode 100644 index 0000000..a91de84 --- /dev/null +++ b/docs/oauth_device.md @@ -0,0 +1,38 @@ +# OAuth Device Flow Module (`oauth_device.rs`) + +Provider-agnostic OAuth 2.0 device flow (RFC 8628), shared by the GitHub +and GitLab clients. + +## Overview + +`github.rs` and `gitlab.rs` used to carry near-identical copies of the +device-code request and token-poll loop. Both are now thin wrappers over +this module; each builds a `DeviceFlowConfig` from its own endpoints and +maps `DeviceFlowError` into its provider error type, so the public +signatures of the provider clients are unchanged. + +```rust +pub struct DeviceFlowConfig { device_code_url, token_url, client_id, scopes } +pub enum DeviceFlowError { Http(String), Expired, Denied, Other(String) } + +pub fn request_device_code(cfg) -> Result +pub fn poll_for_token(cfg, device_code, interval_secs) -> Result +``` + +## Behavior + +- Scopes are URL-encoded in the form body; the device-code response's + HTTP status is checked. +- The poll loop sleeps `interval` seconds; `slow_down` increases the + interval by 5 (RFC 8628), `authorization_pending` continues, + `expired_token` → `Expired`, `access_denied` → `Denied`. +- A success response with an empty/missing token fails loudly instead of + looping forever. +- `DeviceCodeResponse` is the superset of both providers' shapes + (GitLab's `verification_uri_complete` is optional; `browser_url()` + picks the best URL to open). +- The pure `decide_poll_outcome(TokenPollResponse) -> PollOutcome` seam + carries all the branching and is unit-tested without any network. + +Tokens are stored by [auth.md](auth.md)'s `TokenStorage` exactly as +before. diff --git a/docs/pick.md b/docs/pick.md new file mode 100644 index 0000000..a0df8d5 --- /dev/null +++ b/docs/pick.md @@ -0,0 +1,40 @@ +# Pick Module (`pick.rs`) + +Shared three-way "apply a delta as a new seal" engine behind `undo` and +`pluck`. + +## Overview + +Both commands are the same operation with different inputs: run the fuse +engine with ours = the current head tree and a (base, theirs) pair that +encodes the delta, then seal the merged tree as a plain (non-merge) seal. + +| Command | base | theirs | +|---------|-------------------|-------------------| +| undo | the seal's tree | its parent's tree | +| pluck | its parent's tree | the seal's tree | + +```rust +pub enum ApplyOutcome { + Applied(CommitResult), // new seal created + Conflicts(Vec), // refused; conflicting paths listed + NoChanges, // merged tree == head tree; nothing sealed +} + +pub fn three_way_seal(repo, cas, base, theirs, author, message) + -> Result +``` + +## Behavior + +- The fuse engine works at file-hash granularity (no line-level merging), + so any file touched by both the delta and other history conflicts. On + conflict the operation **refuses and commits nothing** — the working + tree is untouched. +- `NoChanges` covers plucking a seal whose changes are already in the + head. +- `tree_files(store, Option)` builds the `path → blob hash` map for + a tree; `None` yields an empty map (the parent of a timeline's first + seal), which is how undoing a first seal deletes its files. + +User-facing documentation: [undo.md](undo.md). diff --git a/docs/rosetta.md b/docs/rosetta.md index fc9c196..c519994 100644 --- a/docs/rosetta.md +++ b/docs/rosetta.md @@ -39,6 +39,10 @@ in Ivaldi the same afternoon. | Diff between two refs | `git diff A B` | `ivaldi diff A B` | | Unstage a file | `git restore --staged f.txt` | `ivaldi reset f.txt` | | Discard local changes | `git reset --hard` | `ivaldi reset --hard` | +| Stage parts of a file | `git add -p` | `ivaldi gather -p` | +| Fix the last commit | `git commit --amend` | `ivaldi reseal` | +| Undo a commit safely | `git revert ` | `ivaldi undo ` | +| Copy one commit over | `git cherry-pick ` | `ivaldi pluck ` (alias `cherry-pick`) | | Who wrote each line? | `git blame f.txt` | `ivaldi whodidit f.txt` (alias `blame`) | | Ignore a path | edit `.gitignore` | `ivaldi exclude pattern` (writes `.ivaldiignore`) | @@ -65,7 +69,9 @@ in Ivaldi the same afternoon. |---|---|---| | Squash last 3 commits | `git rebase -i HEAD~3` | `ivaldi weld --last 3 -m "msg"` | | Squash a range | `git rebase -i ` | `ivaldi weld to -m "msg"` | -| Hard-reset to a commit | `git reset --hard ` | `ivaldi travel` → pick seal → **Overwrite** | +| Amend the head commit | `git commit --amend` | `ivaldi reseal [msg]` | +| Move head, keep your files | `git reset --soft/--mixed ` | `ivaldi rewind ` | +| Hard-reset to a commit | `git reset --hard ` | `ivaldi rewind --discard` (or `ivaldi travel` → **Overwrite**) | | Branch off an old commit | `git switch -c new ` | `ivaldi travel` → pick seal → **Diverge** | | Browse old commits | `git log` then `git checkout ` | `ivaldi travel` (interactive TUI) | | Recover a "lost" commit | `git reflog` | `ivaldi travel --all` (walks every leaf in the MMR) | @@ -128,7 +134,8 @@ There's nothing to stash because nothing is at risk. each doing wildly different things. Ivaldi splits these: - Unstage a file: `ivaldi reset f.txt` - Throw away local changes: `ivaldi reset --hard` -- Move the timeline head: `ivaldi travel` → Overwrite +- Move the timeline head back: `ivaldi rewind ` (add `--discard` to also rewrite your files) +- Redo the last seal: `ivaldi reseal` - Combine commits: `ivaldi weld` **Memorable names.** Every seal gets a deterministic four-word name like @@ -145,7 +152,7 @@ If you reach for one of these, here's the intended Ivaldi answer. | `git worktree` | Use butterflies, or just clone twice. The "two checkouts at once" need is rare enough that a second clone is fine. | | `git stash` | Auto-shelving on timeline switch. | | `git rebase -i` (reorder/edit) | `weld` covers squash; `travel` covers reset. Reordering individual commits is intentionally not in v0.1 — the workflows it enables are usually a smell. | -| `git cherry-pick` | Not yet — planned. For now: branch off, copy changes, seal. | +| `git cherry-pick` | `ivaldi pluck ` (the `cherry-pick` alias also works). See docs/undo.md. | | `git submodule` | Supported (`src/submodule.rs`); same semantics as git. | | `.git/hooks/*` | `.ivaldi/hooks/*` — same shape. | | `git lfs` | `filechunk` handles large files via content-defined chunking; no separate tool. | diff --git a/docs/switch_journal.md b/docs/switch_journal.md new file mode 100644 index 0000000..fbf21e4 --- /dev/null +++ b/docs/switch_journal.md @@ -0,0 +1,47 @@ +# Switch Journal Module (`switch_journal.rs`) + +Crash journal for timeline switches. + +## Overview + +A timeline switch is multi-step: shelve the current timeline's dirty +state, rewrite HEAD, materialize the target tree, restore the target's +shelf. A crash mid-sequence used to leave the working tree half +transitioned with nothing recording that fact. + +The journal file `.ivaldi/SWITCH_IN_PROGRESS` (JSON, written atomically) +is created after the shelve phase — the only non-idempotent part — and +removed when the switch completes: + +```json +{ "from": "main", "to": "feature", "shelf_saved": true, "started_at": 1781000000 } +``` + +## Recovery + +While the journal exists: + +- **Mutating commands refuse to run** with guidance + ("an interrupted timeline switch from 'main' to 'feature' needs recovery…"). + Read-only commands stay usable for orientation. +- `ivaldi timeline switch feature` (the original target) **completes** the + switch: HEAD rewrite, materialize, and shelf restore are all idempotent + and simply replay. +- `ivaldi timeline switch main` (the original source) **rolls back** the + same way. +- Switching to any third timeline is refused until the transition is + resolved. +- The worktree is never re-captured during recovery — it is mid-transition; + the source timeline's dirty state already lives in its shelf. + +A corrupt journal is a hard error naming the file (inspect, and delete if +invalid) rather than being silently ignored. + +## Ordering guarantee + +`do_timeline_switch` (cli/commands.rs) sequences: capture changes → +save shelf (atomic) → flush CAS (the shelf holds the only copies of +uncommitted content) → clear staging → **write journal** → HEAD rewrite → +materialize → restore target shelf → **clear journal**. A crash before the +journal write leaves HEAD and the worktree untouched (at worst a redundant +shelf, which is harmless); a crash after it is recoverable as above. diff --git a/docs/sync.md b/docs/sync.md index 0b90d4a..459edca 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -1,10 +1,25 @@ -# Sync Module (`sync.rs`) +# Sync Module (`sync/`) Download, upload, scout, and harvest operations for Ivaldi VCS. The transport-agnostic orchestration layer; the actual wire work lives in `git_remote` (HTTPS Smart), `ssh_transport` (SSH), or `p2p` (Ivaldi peer-to-peer). +## Module layout + +| File | Contents | +|------|----------| +| `sync/mod.rs` | `SyncError`, `RemoteFetcher`, download/scout/harvest, shared helpers, re-exports | +| `sync/upload.rs` | `upload` and its per-step helpers | +| `sync/import.rs` | `import_full_history(_into)` and its phase helpers | +| `sync/timeline_sync.rs` | `sync_timeline` (fast-forward and diverged-merge paths) | + +All public paths are re-exported from `mod.rs`, so callers still use +`crate::sync::upload(...)` etc. `SyncError` carries typed `#[from]` +variants (`Repo`, `Forge`, `GitRemote`, `Cas`, `FsMerkle`, `Remote`, +`Store`, `Io`, `GitHub`); `Other(String)` is reserved for genuinely +ad-hoc messages. + ## Overview Bridges Ivaldi's BLAKE3-based internal storage with the various wire diff --git a/docs/timeline.md b/docs/timeline.md index 3cc167c..f750c92 100644 --- a/docs/timeline.md +++ b/docs/timeline.md @@ -11,6 +11,11 @@ The `HistoryManager` orchestrates: - Creating/switching/removing timelines - LCA (Lowest Common Ancestor) computation for merges +CLI timeline switches are crash-recoverable: the switch sequence is +journaled to `.ivaldi/SWITCH_IN_PROGRESS` so an interrupted switch can be +completed or rolled back instead of leaving the working tree half +transitioned — see [switch_journal.md](switch_journal.md). + ## Usage ```rust diff --git a/docs/undo.md b/docs/undo.md new file mode 100644 index 0000000..676a36d --- /dev/null +++ b/docs/undo.md @@ -0,0 +1,68 @@ +# Undo and Pluck + +Two commands apply an existing seal's delta to the current timeline head as +a **new** seal — history is never rewritten: + +- `ivaldi undo ` removes what `` changed. +- `ivaldi pluck ` applies what `` changed (git users: this is + `cherry-pick`, and that name works as an alias). + +```bash +# Take back a bad seal without rewriting history +ivaldi undo swift-eagle-flies-high + +# Bring one fix over from another timeline +ivaldi pluck gentle-otter-swims-fast +ivaldi cherry-pick gentle-otter-swims-fast # same thing + +# Custom message +ivaldi undo swift-eagle-flies-high -m "Back out the cache change" +``` + +## How it works + +Both run the same three-way fuse used by `ivaldi fuse`, with the current +head as "ours": + +| Command | base | theirs | +|---------|-------------------|-------------------| +| undo | the seal's tree | its parent's tree | +| pluck | its parent's tree | the seal's tree | + +The merged tree is sealed as a plain (non-merge) seal and materialized to +the working directory. + +- Undoing a timeline's **first** seal deletes the files it introduced. +- Plucking a seal whose changes are already in the head reports + "no changes" and creates nothing. +- The default undo message is `Undo ""` with a + trailer naming the undone seal; pluck keeps the original message plus a + `(plucked from …)` trailer. + +## Conflicts + +The fuse engine works at whole-file granularity. If a file touched by the +seal was also changed by later history (undo) or by your timeline +(pluck), the command **refuses and changes nothing** — it lists the +conflicting paths instead: + +``` +error: undo conflicts with other changes in: + src/cache.rs +Resolve by editing the files manually and sealing, nothing was changed. +``` + +## Restrictions + +- The staging area must be clean (seal or `ivaldi reset` first) so the + materialized result can't clobber staged work. +- No merge may be in progress (`ivaldi fuse --continue` / `--abort` first). +- Merge seals cannot be undone or plucked yet (there is no way to choose + which parent's side to keep). + +## Related + +- `ivaldi reseal` — redo the most recent seal (new message and/or fold in + staged changes). +- `ivaldi rewind ` — move the timeline head back to an earlier seal; + add `--discard` to also rewrite your files. diff --git a/docs/workspace.md b/docs/workspace.md index bdc90e9..af6ecd0 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -55,6 +55,10 @@ ws.gather(&["src/main.rs", "README.md"])?; // Gather everything ws.gather_all(&ignore_cache)?; +// Progress-reporting variants (the CLI uses these to drive its +// spinner/progress bar; the plain forms delegate with a no-op callback) +ws.gather_all_with_progress(&ignore_cache, &mut |path| eprintln!("{path}"))?; + // Build tree from staged files let tree_hash = ws.build_staged_tree()?; diff --git a/src/atomic_io.rs b/src/atomic_io.rs new file mode 100644 index 0000000..2bb7868 --- /dev/null +++ b/src/atomic_io.rs @@ -0,0 +1,109 @@ +//! Atomic file replacement for repository metadata files. +//! +//! A plain `fs::write` can leave a truncated file behind if the process +//! crashes mid-write. Every metadata file under `.ivaldi/` (staging area, +//! HEAD, shelves, merge state, config, ...) goes through [`atomic_write`] +//! instead: the bytes are written to a unique temp file in the same +//! directory, fsynced, then renamed over the destination. Readers observe +//! either the old contents or the new contents, never a partial file. + +use std::fs; +use std::io::Write; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Atomically replace `path` with `bytes`. +/// +/// Writes to a temp file in the same directory (so the rename cannot cross +/// filesystems), fsyncs it, renames it over `path`, then best-effort fsyncs +/// the parent directory. The parent directory must already exist. +pub fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = path + .file_name() + .ok_or_else(|| std::io::Error::other("atomic_write: path has no file name"))?; + + let tmp = parent.join(format!( + "{}.tmp.{}.{}", + file_name.to_string_lossy(), + std::process::id(), + TMP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + + let result = (|| { + let mut f = fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + fs::rename(&tmp, path) + })(); + + if result.is_err() { + let _ = fs::remove_file(&tmp); + return result; + } + + // Make the rename itself durable. Failure here is tolerated (some + // filesystems reject directory fsync); the rename has already happened. + if let Ok(dir) = fs::File::open(parent) { + let _ = dir.sync_all(); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_entries(dir: &Path) -> Vec { + fs::read_dir(dir) + .unwrap() + .filter_map(|e| { + let name = e.unwrap().file_name().to_string_lossy().into_owned(); + name.contains(".tmp.").then_some(name) + }) + .collect() + } + + #[test] + fn write_and_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state"); + + atomic_write(&path, b"first").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"first"); + + atomic_write(&path, b"second").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"second"); + } + + #[test] + fn no_temp_files_left_after_success() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state"); + atomic_write(&path, b"data").unwrap(); + atomic_write(&path, b"data2").unwrap(); + assert!(tmp_entries(dir.path()).is_empty()); + } + + #[test] + fn failure_cleans_up_temp_file() { + let dir = tempfile::tempdir().unwrap(); + // Renaming a file over an existing non-empty directory fails. + let target = dir.path().join("occupied"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("child"), b"x").unwrap(); + + assert!(atomic_write(&target, b"data").is_err()); + assert!(tmp_entries(dir.path()).is_empty()); + } + + #[test] + fn missing_parent_dir_errors() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("no/such/dir/state"); + assert!(atomic_write(&path, b"data").is_err()); + } +} diff --git a/src/auth.rs b/src/auth.rs index dbb709b..27c220c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -31,7 +31,8 @@ pub const GITHUB_SCOPES: &str = "repo,read:user,user:email"; /// `IVALDI_GITLAB_CLIENT_ID`. The default client id below is glab CLI's /// public OAuth application; replace via env if you ship your own. pub const GITLAB_HOST: &str = "https://gitlab.com"; -pub const GITLAB_CLIENT_ID: &str = "41d48f9422ebd655ee3b6e85a7b8f7560bb0b50ad08522bb720e15f93a072039"; // glab CLI public OAuth app +pub const GITLAB_CLIENT_ID: &str = + "41d48f9422ebd655ee3b6e85a7b8f7560bb0b50ad08522bb720e15f93a072039"; // glab CLI public OAuth app pub const GITLAB_DEVICE_AUTH_PATH: &str = "/oauth/authorize_device"; pub const GITLAB_TOKEN_PATH: &str = "/oauth/token"; pub const GITLAB_SCOPES: &str = "read_user read_api write_repository"; @@ -211,23 +212,19 @@ pub struct AuthMethod { /// Checks multiple sources in priority order. pub fn resolve_auth(platform: Platform) -> Option { // 1. Ivaldi OAuth token (highest priority) - if let Ok(store) = TokenStore::new() { - if let Ok(Some(token)) = store.load_token(platform) { - if !token.access_token.is_empty() { - let platform_name = match platform { - Platform::GitHub => "github", - Platform::GitLab => "gitlab", - }; - return Some(AuthMethod { - name: "ivaldi".to_string(), - description: format!( - "Authenticated via 'ivaldi auth login --{}'", - platform_name - ), - token: token.access_token, - }); - } - } + if let Ok(store) = TokenStore::new() + && let Ok(Some(token)) = store.load_token(platform) + && !token.access_token.is_empty() + { + let platform_name = match platform { + Platform::GitHub => "github", + Platform::GitLab => "gitlab", + }; + return Some(AuthMethod { + name: "ivaldi".to_string(), + description: format!("Authenticated via 'ivaldi auth login --{}'", platform_name), + token: token.access_token, + }); } // 2. Environment variable @@ -235,14 +232,14 @@ pub fn resolve_auth(platform: Platform) -> Option { Platform::GitHub => "GITHUB_TOKEN", Platform::GitLab => "GITLAB_TOKEN", }; - if let Ok(token) = std::env::var(env_var) { - if !token.is_empty() { - return Some(AuthMethod { - name: "env".to_string(), - description: format!("Authenticated via {} environment variable", env_var), - token, - }); - } + if let Ok(token) = std::env::var(env_var) + && !token.is_empty() + { + return Some(AuthMethod { + name: "env".to_string(), + description: format!("Authenticated via {} environment variable", env_var), + token, + }); } // 3. .netrc file diff --git a/src/butterfly.rs b/src/butterfly.rs index c5fbcaf..a54f27e 100644 --- a/src/butterfly.rs +++ b/src/butterfly.rs @@ -160,12 +160,12 @@ impl ButterflyManager { } // Remove from parent's children list - if !bf.is_orphaned { - if let Some(siblings) = self.children.get_mut(&bf.parent_name) { - siblings.retain(|c| c != name); - if siblings.is_empty() { - self.children.remove(&bf.parent_name); - } + if !bf.is_orphaned + && let Some(siblings) = self.children.get_mut(&bf.parent_name) + { + siblings.retain(|c| c != name); + if siblings.is_empty() { + self.children.remove(&bf.parent_name); } } diff --git a/src/cas.rs b/src/cas.rs index aa15149..5e30b08 100644 --- a/src/cas.rs +++ b/src/cas.rs @@ -120,6 +120,10 @@ impl Cas for MemoryCas { /// Storage layout: `//` pub struct FileCas { root: PathBuf, + /// Shard directories written to since the last `flush()`. Lets flush + /// fsync only what this process touched instead of all 256 shards + /// (each `sync_all` is an F_FULLFSYNC on macOS). + dirty_shards: std::sync::Mutex>, } impl FileCas { @@ -127,7 +131,10 @@ impl FileCas { pub fn new(root: impl AsRef) -> Result { let root = root.as_ref().to_path_buf(); fs::create_dir_all(&root)?; - Ok(Self { root }) + Ok(Self { + root, + dirty_shards: std::sync::Mutex::new(std::collections::BTreeSet::new()), + }) } /// Get the file path for a given hash. @@ -141,17 +148,22 @@ impl FileCas { static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); impl FileCas { - /// Flush directory metadata to disk for all touched shards. Call once at - /// the end of a bulk import. Per-`put` fsyncs are intentionally skipped to - /// keep the hot path fast — content-addressed blobs are re-fetchable, so - /// losing the tail of an import on crash is harmless. + /// Flush directory metadata to disk for shards written since the last + /// flush. Call after a seal, bulk import, or shelf capture — anywhere the + /// CAS holds the only copy of data before a commit record references it. + /// Per-`put` fsyncs are intentionally skipped to keep the hot path fast. + /// No-op when nothing was written. pub fn flush(&self) -> Result<(), CasError> { - for entry in fs::read_dir(&self.root)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - if let Ok(dir) = fs::File::open(entry.path()) { - let _ = dir.sync_all(); - } + let shards: Vec = { + let mut dirty = self.dirty_shards.lock().unwrap(); + std::mem::take(&mut *dirty).into_iter().collect() + }; + if shards.is_empty() { + return Ok(()); + } + for shard in shards { + if let Ok(dir) = fs::File::open(&shard) { + let _ = dir.sync_all(); } } if let Ok(root) = fs::File::open(&self.root) { @@ -159,6 +171,11 @@ impl FileCas { } Ok(()) } + + #[cfg(test)] + fn dirty_shard_count(&self) -> usize { + self.dirty_shards.lock().unwrap().len() + } } impl Cas for FileCas { @@ -204,6 +221,13 @@ impl Cas for FileCas { } } + if let Some(parent) = path.parent() { + self.dirty_shards + .lock() + .unwrap() + .insert(parent.to_path_buf()); + } + Ok(()) } @@ -364,6 +388,50 @@ mod tests { assert!(cas.has(hash).unwrap()); } + #[test] + fn flush_tracks_dirty_shards() { + let dir = tempfile::tempdir().unwrap(); + let cas = FileCas::new(dir.path()).unwrap(); + + // Fresh CAS: nothing dirty, flush is a no-op. + assert_eq!(cas.dirty_shard_count(), 0); + cas.flush().unwrap(); + + let a = b"shard data a"; + let b = b"shard data b"; + cas.put(B3Hash::digest(a), a).unwrap(); + cas.put(B3Hash::digest(b), b).unwrap(); + assert!(cas.dirty_shard_count() >= 1); + + cas.flush().unwrap(); + assert_eq!(cas.dirty_shard_count(), 0); + + // Re-putting existing content writes nothing → stays clean. + cas.put(B3Hash::digest(a), a).unwrap(); + assert_eq!(cas.dirty_shard_count(), 0); + } + + #[test] + fn concurrent_puts_do_not_deadlock() { + let dir = tempfile::tempdir().unwrap(); + let cas = std::sync::Arc::new(FileCas::new(dir.path()).unwrap()); + + let handles: Vec<_> = (0..8u8) + .map(|i| { + let cas = cas.clone(); + std::thread::spawn(move || { + let data = vec![i; 64]; + cas.put(B3Hash::digest(&data), &data).unwrap(); + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + cas.flush().unwrap(); + assert_eq!(cas.dirty_shard_count(), 0); + } + #[test] fn file_put_idempotent() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/cli/commands.rs b/src/cli/commands.rs index f9131ec..3ea6fac 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -21,18 +21,79 @@ use crate::workspace::{FileState, Workspace}; use super::*; +/// True for commands that mutate repository state and therefore need the +/// exclusive process lock. Read-only commands stay lock-free (they still +/// serialize against writers via redb's own file lock). `Download` is +/// excluded because it may target a fresh clone outside any repository. +fn command_mutates(cmd: &Commands) -> bool { + match cmd { + Commands::Gather(_) + | Commands::Seal(_) + | Commands::Reseal(_) + | Commands::Reset(_) + | Commands::Rewind(_) + | Commands::Undo(_) + | Commands::Pluck(_) + | Commands::Fuse(_) + | Commands::Travel(_) + | Commands::Weld(_) + | Commands::Harvest(_) + | Commands::Sync(_) + | Commands::Upload(_) + | Commands::Exclude(_) => true, + Commands::Timeline(args) => !matches!(args.command, TimelineCommands::List(_)), + Commands::Review(args) => !matches!( + args.command, + ReviewCommands::List(_) | ReviewCommands::Show(_) | ReviewCommands::Diff(_) + ), + _ => false, + } +} + pub fn run_command(cli: Cli) { if let Some(cmd) = cli.command { + // Hold the repo lock across the whole command so two concurrent + // ivaldi processes can't interleave multi-step state changes. While + // locked, also refuse to mutate over an interrupted timeline switch + // (timeline switch itself handles the recovery). + let _lock = if command_mutates(&cmd) { + let setup = find_repo().and_then(|ctx| { + let lock = + crate::lock::RepoLock::acquire(&ctx.ivaldi_dir).map_err(|e| e.to_string())?; + let is_switch = matches!( + &cmd, + Commands::Timeline(args) + if matches!(args.command, TimelineCommands::Switch(_)) + ); + if !is_switch { + ensure_no_interrupted_switch(&ctx.ivaldi_dir)?; + } + Ok(lock) + }); + match setup { + Ok(lock) => Some(lock), + Err(e) => { + eprintln!("{}", color::error(&e)); + process::exit(1); + } + } + } else { + None + }; let result = match cmd { Commands::Forge => cmd_forge(cli.quiet), Commands::Gather(args) => cmd_gather(args, cli.quiet), Commands::Seal(args) => cmd_seal(args, cli.quiet), - Commands::Status => cmd_status(), + Commands::Reseal(args) => cmd_reseal(args, cli.quiet), + Commands::Status(args) => cmd_status(args), Commands::Whereami => cmd_whereami(), Commands::Log(args) => cmd_log(args), Commands::Whodidit(args) => cmd_whodidit(args), Commands::Diff(args) => cmd_diff(args), Commands::Reset(args) => cmd_reset(args, cli.quiet), + Commands::Rewind(args) => cmd_rewind(args, cli.quiet), + Commands::Undo(args) => cmd_undo(args, cli.quiet), + Commands::Pluck(args) => cmd_pluck(args, cli.quiet), Commands::Timeline(args) => cmd_timeline(args, cli.quiet), Commands::Fuse(args) => cmd_fuse(args, cli.quiet), Commands::Travel(args) => cmd_travel(args), @@ -50,13 +111,16 @@ pub fn run_command(cli: Cli) { Commands::Tui => cmd_tui(), Commands::Serve(args) => cmd_serve(args, cli.quiet), Commands::Peer(args) => cmd_peer(args, cli.quiet), + Commands::Completions(args) => cmd_completions(args), + Commands::Man(args) => cmd_man(args, cli.quiet), }; if let Err(e) = result { - eprintln!("Error: {}", e); + eprintln!("{}", color::error(&e)); process::exit(1); } } else { - let _ = Cli::try_parse_from(["ivaldi", "--help"]); + use clap::CommandFactory; + let _ = Cli::command().print_help(); } } @@ -142,16 +206,57 @@ fn cmd_gather(args: GatherArgs, quiet: bool) -> Result<(), String> { } }; + if args.patch { + let staged = run_patch_session( + &mut ws, + &cas, + &parent_tree_files, + &args.files, + &ignore_cache, + &mut std::io::stdin().lock(), + quiet, + )?; + ws.save().map_err(|e| e.to_string())?; + if !quiet { + for path in &staged { + println!(" gathered: {}", path); + } + println!("{} file(s) staged", staged.len()); + } + return Ok(()); + } + let mut all_gathered: Vec; let mut all_deleted: Vec = Vec::new(); if args.files.is_empty() || args.files == ["."] { - let result = ws.gather_all(&ignore_cache).map_err(|e| e.to_string())?; + // Scan first (under a spinner) so we know how many files are about to + // be hashed; the same listing also drives deletion detection below. + let scan_spinner = (!quiet).then(|| crate::progress::spinner("Scanning workspace...")); + let on_disk: std::collections::BTreeSet = ws + .scan(&ignore_cache) + .map_err(|e| e.to_string())? + .into_iter() + .collect(); + if let Some(sp) = scan_spinner { + sp.finish_and_clear(); + } + + let bar = (!quiet && on_disk.len() > 1) + .then(|| crate::progress::file_bar(on_disk.len() as u64, "Gathering")); + let result = ws + .gather_all_with_progress(&ignore_cache, &mut |_path| { + if let Some(b) = &bar { + b.inc(1); + } + }) + .map_err(|e| e.to_string())?; + if let Some(b) = bar { + b.finish_and_clear(); + } all_gathered = result.gathered; // Anything in the parent tree but missing from disk is a deletion. - let on_disk: std::collections::BTreeSet = - ws.scan(&ignore_cache).map_err(|e| e.to_string())?.into_iter().collect(); for path in parent_tree_files.keys() { if !on_disk.contains(path.as_str()) { ws.staging.stage_deletion(path.clone()); @@ -171,21 +276,50 @@ fn cmd_gather(args: GatherArgs, quiet: bool) -> Result<(), String> { } } else { let refs: Vec<&str> = args.files.iter().map(|s| s.as_str()).collect(); - let result = ws.gather(&refs, &allowlist).map_err(|e| e.to_string())?; + let bar = (!quiet && refs.len() > 1) + .then(|| crate::progress::file_bar(refs.len() as u64, "Gathering")); + let result = ws + .gather_with_progress(&refs, &allowlist, &mut |_path| { + if let Some(b) = &bar { + b.inc(1); + } + }) + .map_err(|e| e.to_string())?; + if let Some(b) = bar { + b.finish_and_clear(); + } all_gathered = result.gathered; // For each requested path that wasn't gathered (because it's missing // from disk), record it as a deletion if it was present in the parent - // tree. Paths that aren't in the parent tree and aren't on disk are - // silently skipped, matching the prior behaviour. + // tree; otherwise it matched nothing at all. + let mut unmatched: Vec<&str> = Vec::new(); for path in &refs { if ws.staging.is_staged(path) { continue; } let full_path = ws.work_dir().join(path); - if !full_path.exists() && parent_tree_files.contains_key(*path) { - ws.staging.stage_deletion(path.to_string()); - all_deleted.push(path.to_string()); + if !full_path.exists() { + if parent_tree_files.contains_key(*path) { + ws.staging.stage_deletion(path.to_string()); + all_deleted.push(path.to_string()); + } else { + unmatched.push(path); + } + } + } + if !unmatched.is_empty() { + if all_gathered.is_empty() + && all_deleted.is_empty() + && result.needs_confirmation.is_empty() + { + return Err(format!( + "pathspec '{}' did not match any files", + unmatched.join("', '") + )); + } + for path in &unmatched { + eprintln!("warning: pathspec '{}' did not match any files", path); } } @@ -256,6 +390,189 @@ fn cmd_gather(args: GatherArgs, quiet: bool) -> Result<(), String> { Ok(()) } +/// Answer to a per-hunk or per-file prompt. +#[derive(Clone, Copy, PartialEq)] +enum PatchAnswer { + Yes, + No, + AllRest, + NoneRest, + Quit, +} + +fn read_patch_answer( + prompt: &str, + input: &mut dyn std::io::BufRead, +) -> Result { + loop { + print!("{} ", prompt); + std::io::stdout().flush().ok(); + let mut line = String::new(); + if input.read_line(&mut line).map_err(|e| e.to_string())? == 0 { + return Ok(PatchAnswer::Quit); // EOF + } + match line.trim() { + "y" | "Y" => return Ok(PatchAnswer::Yes), + "n" | "N" => return Ok(PatchAnswer::No), + "a" | "A" => return Ok(PatchAnswer::AllRest), + "d" | "D" => return Ok(PatchAnswer::NoneRest), + "q" | "Q" => return Ok(PatchAnswer::Quit), + "?" => { + println!("y - stage this hunk"); + println!("n - do not stage this hunk"); + println!("a - stage this and all remaining hunks in this file"); + println!("d - skip this and all remaining hunks in this file"); + println!("q - quit; stage nothing further"); + println!("(splitting and editing hunks is not supported yet)"); + } + _ => {} + } + } +} + +/// Interactive hunk staging (`gather --patch`). +/// +/// For each modified file, shows every hunk and asks which to stage. The +/// selected hunks are applied to the parent version in memory, the synthetic +/// blob goes into the CAS, and the staging area records its hash — the +/// working tree is never touched. Untracked and binary files get a single +/// whole-file prompt. Dotfiles keep their separate confirmation flow and are +/// skipped here. Reads answers from `input` so tests can script the session. +/// +/// Known limitation: a partially staged file shows as `Staged` in status; +/// the remaining unstaged delta is not yet reported separately. +#[allow(clippy::too_many_arguments)] +fn run_patch_session( + ws: &mut Workspace<'_>, + cas: &FileCas, + parent_tree_files: &std::collections::BTreeMap, + file_filter: &[String], + ignore_cache: &ignore::PatternCache, + input: &mut dyn std::io::BufRead, + quiet: bool, +) -> Result, String> { + use crate::cas::Cas; + use crate::diff::{LineOp, apply_selected_hunks, compute_hunks, compute_ops}; + use crate::fsmerkle::BlobNode; + + let store = crate::fsmerkle::FsStore::new(cas); + let mut staged_paths: Vec = Vec::new(); + + // Candidates: files on disk that differ from the parent tree. Dotfiles + // keep their dedicated confirmation flow — not part of --patch. + let candidates: Vec = ws + .scan(ignore_cache) + .map_err(|e| e.to_string())? + .into_iter() + .filter(|path| { + let basename = path.rsplit('/').next().unwrap_or(path); + !basename.starts_with('.') || basename == ".ivaldiignore" + }) + .filter(|path| file_filter.is_empty() || file_filter.iter().any(|f| f == path || f == ".")) + .collect(); + + 'files: for path in candidates { + if crate::ignore::is_security_blocked(&path) { + continue; + } + let disk_content = std::fs::read(ws.work_dir().join(&path)).map_err(|e| e.to_string())?; + + let old_content: Option> = match parent_tree_files.get(&path) { + Some(hash) => Some(store.load_blob(*hash).map_err(|e| e.to_string())?.1), + None => None, + }; + + // Unchanged files aren't candidates. + if let Some(old) = &old_content + && *old == disk_content + { + continue; + } + + let whole_file_only = old_content.is_none() + || crate::diff::is_binary(&disk_content) + || old_content.as_deref().is_some_and(crate::diff::is_binary); + + if whole_file_only { + let label = if old_content.is_none() { + "untracked" + } else { + "binary" + }; + match read_patch_answer(&format!("Stage {} ({}) [y,n,q]?", path, label), input)? { + PatchAnswer::Yes | PatchAnswer::AllRest => { + let canonical = BlobNode::canonical_bytes(&disk_content); + let hash = crate::hash::B3Hash::digest(&canonical); + cas.put(hash, &canonical).map_err(|e| e.to_string())?; + ws.staging.stage(&path, hash); + staged_paths.push(path.clone()); + } + PatchAnswer::Quit => break 'files, + _ => {} + } + continue; + } + + let old_text = String::from_utf8_lossy(old_content.as_deref().unwrap()).into_owned(); + let new_text = String::from_utf8_lossy(&disk_content).into_owned(); + let ops = compute_ops(&old_text, &new_text); + let hunks = compute_hunks(&ops, 3); + if hunks.is_empty() { + continue; // e.g. trailing-newline-only difference + } + + if !quiet { + println!("{}", color::bold(&path)); + } + let mut selected = vec![false; hunks.len()]; + let mut blanket: Option = None; + for (h_idx, hunk) in hunks.iter().enumerate() { + if blanket.is_none() { + println!("Hunk {}/{}:", h_idx + 1, hunks.len()); + for op in &ops[hunk.ops_range.clone()] { + match op { + LineOp::Context(s) => println!(" {}", color::dim(&format!(" {}", s))), + LineOp::Add(s) => { + println!(" {}", color::bold_green(&format!("+{}", s))) + } + LineOp::Remove(s) => { + println!(" {}", color::bold_red(&format!("-{}", s))) + } + } + } + } + selected[h_idx] = match blanket { + Some(b) => b, + None => match read_patch_answer("Stage this hunk [y,n,a,d,q,?]?", input)? { + PatchAnswer::Yes => true, + PatchAnswer::No => false, + PatchAnswer::AllRest => { + blanket = Some(true); + true + } + PatchAnswer::NoneRest => { + blanket = Some(false); + false + } + PatchAnswer::Quit => break 'files, + }, + }; + } + + if selected.iter().any(|&s| s) { + let synthetic = apply_selected_hunks(&old_text, &new_text, &ops, &hunks, &selected); + let canonical = BlobNode::canonical_bytes(synthetic.as_bytes()); + let hash = crate::hash::B3Hash::digest(&canonical); + cas.put(hash, &canonical).map_err(|e| e.to_string())?; + ws.staging.stage(&path, hash); + staged_paths.push(path.clone()); + } + } + + cas.flush().map_err(|e| e.to_string())?; + Ok(staged_paths) +} + fn cmd_seal(args: SealArgs, quiet: bool) -> Result<(), String> { let message = args .get_message() @@ -267,7 +584,11 @@ fn cmd_seal(args: SealArgs, quiet: bool) -> Result<(), String> { let ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); if ws.staging.is_empty() { - return Err("no changes staged for seal. Use 'ivaldi gather' to stage files first.".into()); + return Err( + "no changes staged for seal. Use 'ivaldi gather .' to stage files first, \ + or 'ivaldi reseal' to redo the previous seal." + .into(), + ); } // Open persistent repo first so we can resolve the current timeline's @@ -280,9 +601,11 @@ fn cmd_seal(args: SealArgs, quiet: bool) -> Result<(), String> { .and_then(|idx| repo.get_leaf(idx).ok().flatten()) .map(|l| l.tree_root); - let tree_hash = ws - .build_seal_tree(parent_tree) - .map_err(|e| e.to_string())?; + let tree_hash = ws.build_seal_tree(parent_tree).map_err(|e| e.to_string())?; + + // Make the seal's blobs and tree nodes durable before the commit record + // that references them lands in the store. + cas.flush().map_err(|e| e.to_string())?; let cfg = repo.config(); let author = cfg.author() @@ -307,21 +630,104 @@ fn cmd_seal(args: SealArgs, quiet: bool) -> Result<(), String> { Ok(()) } -fn cmd_status() -> Result<(), String> { +/// `reseal`: redo the most recent seal, folding in staged changes (if any) +/// and/or a new message. +fn cmd_reseal(args: ResealArgs, quiet: bool) -> Result<(), String> { + let ctx = find_repo()?; + let mut repo = open_repo()?; + + if repo.has_merge_in_progress() { + return Err( + "cannot reseal during a merge. Finish with 'ivaldi fuse --continue' or \ + 'ivaldi fuse --abort' first." + .into(), + ); + } + + let timeline = repo.current_timeline().map_err(|e| e.to_string())?; + let head_idx = repo + .get_timeline_head(&timeline) + .map_err(|e| e.to_string())? + .ok_or("nothing to reseal: timeline has no seals")?; + let old_leaf = repo + .get_leaf(head_idx) + .map_err(|e| e.to_string())? + .ok_or("corrupt head: leaf missing")?; + + let cas = FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; + let ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + + let message = match args.get_message() { + Some(m) => m.to_string(), + None => { + if ws.staging.is_empty() { + return Err("nothing to reseal: no staged changes and no new message. \ + Use 'ivaldi gather' to stage files or pass a message." + .into()); + } + old_leaf.message.clone() + } + }; + + // Staging was gathered against the current head, so head tree + staged + // delta is exactly the resealed tree. With empty staging this is a + // message-only reseal reusing the old tree. + let tree_hash = if ws.staging.is_empty() { + old_leaf.tree_root + } else { + let tree = ws + .build_seal_tree(Some(old_leaf.tree_root)) + .map_err(|e| e.to_string())?; + cas.flush().map_err(|e| e.to_string())?; + tree + }; + + // Heuristic warning: the mapping may also exist from a download, so this + // is advisory, not a refusal. + if crate::remote::HashMapping::new(&ctx.ivaldi_dir) + .get_sha1(old_leaf.hash()) + .is_some() + { + eprintln!( + "warning: the seal being redone was already uploaded; \ + the next 'ivaldi upload' may need to rewrite the remote timeline." + ); + } + + let cfg = repo.config(); + let author = cfg.author() + .ok_or("user.name and user.email not configured. Run:\n ivaldi config --set user.name \"Your Name\"\n ivaldi config --set user.email \"you@example.com\"")?; + + let result = repo + .reseal_head(tree_hash, &author, &message) + .map_err(|e| e.to_string())?; + + let mut ws_mut = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + ws_mut.staging.clear(); + ws_mut.save().map_err(|e| e.to_string())?; + + if !quiet { + println!("Resealed: {} ({})", result.seal_name, result.hash.short8()); + } + Ok(()) +} + +fn cmd_status(args: StatusArgs) -> Result<(), String> { let ctx = find_repo()?; let repo = open_repo()?; let timeline = repo .current_timeline() .unwrap_or_else(|_| "detached".into()); - println!("Timeline: {}", color::timeline(&timeline)); - - // Get last seal tree hash for comparison - let last_tree = repo + // Get last seal info (and tree hash for comparison) up front so both the + // human and JSON outputs share the same data. + let head_leaf = repo .get_timeline_head(&timeline) .map_err(|e| e.to_string())? - .and_then(|idx| repo.get_leaf(idx).ok().flatten()) - .map(|leaf| leaf.tree_root); + .map(|idx| repo.get_leaf(idx).map_err(|e| e.to_string())) + .transpose()? + .flatten(); + let last_tree = head_leaf.as_ref().map(|leaf| leaf.tree_root); let cas = FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; let ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); @@ -330,20 +736,56 @@ fn cmd_status() -> Result<(), String> { .status(last_tree, &ignore_cache) .map_err(|e| e.to_string())?; - // Show last seal info - if let Some(head_idx) = repo - .get_timeline_head(&timeline) - .map_err(|e| e.to_string())? - { - if let Some(leaf) = repo.get_leaf(head_idx).map_err(|e| e.to_string())? { + if args.json { + let head = head_leaf.as_ref().map(|leaf| { let hash = leaf.hash(); - let name = seal::generate_seal_name(hash); - println!( - "Last seal: {} ({})", - color::seal_name(&name), - color::hash(&hash.short8()) - ); - } + json::SealRefJson { + seal_name: seal::generate_seal_name(hash), + hash: hash.to_hex(), + short_hash: hash.short8(), + } + }); + let staged_deletions: Vec = ws.staging.staged_deletions().iter().cloned().collect(); + let files: Vec = status + .iter() + .filter(|f| f.state != FileState::Unmodified) + .map(|f| json::FileJson { + path: f.path.clone(), + state: match f.state { + FileState::Untracked => "untracked", + FileState::Unmodified => "unmodified", + FileState::Modified => "modified", + FileState::Staged => "staged", + FileState::Deleted => "deleted", + } + .to_string(), + hash: f.hash.map(|h| h.to_hex()), + }) + .collect(); + let out = json::StatusJson { + timeline, + head, + files, + staged_deletions, + }; + println!( + "{}", + serde_json::to_string_pretty(&out).map_err(|e| e.to_string())? + ); + return Ok(()); + } + + println!("Timeline: {}", color::timeline(&timeline)); + + // Show last seal info + if let Some(leaf) = &head_leaf { + let hash = leaf.hash(); + let name = seal::generate_seal_name(hash); + println!( + "Last seal: {} ({})", + color::seal_name(&name), + color::hash(&hash.short8()) + ); } let staged: Vec<_> = status @@ -417,22 +859,21 @@ fn cmd_whereami() -> Result<(), String> { if let Some(head_idx) = repo .get_timeline_head(&timeline) .map_err(|e| e.to_string())? + && let Some(leaf) = repo.get_leaf(head_idx).map_err(|e| e.to_string())? { - if let Some(leaf) = repo.get_leaf(head_idx).map_err(|e| e.to_string())? { - let hash = leaf.hash(); - let name = seal::generate_seal_name(hash); - let rel = ivaldi_log::relative_time( - leaf.time_unix, - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64, - ); - println!("Last Seal: {} ({})", name, hash.short8()); - println!(" Message: \"{}\"", leaf.message); - println!(" Author: {}", leaf.author); - println!(" Date: {}", rel); - } + let hash = leaf.hash(); + let name = seal::generate_seal_name(hash); + let rel = ivaldi_log::relative_time( + leaf.time_unix, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + ); + println!("Last Seal: {} ({})", name, hash.short8()); + println!(" Message: \"{}\"", leaf.message); + println!(" Author: {}", leaf.author); + println!(" Date: {}", rel); } println!("Commits: {}", repo.timeline_commit_count(&timeline)); @@ -449,7 +890,7 @@ fn cmd_log(args: LogArgs) -> Result<(), String> { for (tl_name, _) in repo.list_timelines().map_err(|e| e.to_string())? { all.extend(repo.walk_history(&tl_name).map_err(|e| e.to_string())?); } - all.sort_by(|a, b| b.time_unix.cmp(&a.time_unix)); + all.sort_by_key(|e| std::cmp::Reverse(e.time_unix)); all.dedup_by_key(|e| e.index); all } else { @@ -462,36 +903,108 @@ fn cmd_log(args: LogArgs) -> Result<(), String> { &entries }; + let format = args.format.unwrap_or(if args.oneline { + LogFormat::Short + } else { + LogFormat::Medium + }); + + if format == LogFormat::Json { + let out: Vec = entries.iter().map(json::LogEntryJson::from).collect(); + println!( + "{}", + serde_json::to_string_pretty(&out).map_err(|e| e.to_string())? + ); + return Ok(()); + } + if entries.is_empty() { println!("No commits yet on timeline '{}'", timeline); return Ok(()); } for entry in entries { - if args.oneline { - println!( - "{} {} {}", - color::hash(&entry.short_hash), - color::seal_name(&entry.seal_name), - entry.message - ); - } else { - println!( - "Seal: {} ({})", - color::seal_name(&entry.seal_name), - color::hash(&entry.short_hash) - ); - println!("Timeline: {}", color::timeline(&entry.timeline)); - println!("Author: {}", color::author(&entry.author)); - println!("Date: {}", entry.time_unix); - println!(); - println!(" {}", entry.message); - println!(); + match format { + LogFormat::Short => { + println!( + "{} {} {}", + color::hash(&entry.short_hash), + color::seal_name(&entry.seal_name), + entry.message + ); + } + LogFormat::Medium => { + println!( + "Seal: {} ({})", + color::seal_name(&entry.seal_name), + color::hash(&entry.short_hash) + ); + println!("Timeline: {}", color::timeline(&entry.timeline)); + println!("Author: {}", color::author(&entry.author)); + println!("Date: {}", entry.time_unix); + println!(); + println!(" {}", entry.message); + println!(); + } + LogFormat::Full => { + println!( + "Seal: {} ({})", + color::seal_name(&entry.seal_name), + color::hash(&entry.short_hash) + ); + println!("Hash: {}", entry.hash.to_hex()); + println!("Timeline: {}", color::timeline(&entry.timeline)); + println!("Author: {}", color::author(&entry.author)); + println!( + "Date: {} ({})", + format_unix_utc(entry.time_unix), + entry.time_unix + ); + if let Ok(Some(leaf)) = repo.get_leaf(entry.index) { + println!("Tree: {}", leaf.tree_root.to_hex()); + if entry.is_merge { + let parents: Vec = + leaf.merge_idxs.iter().map(|i| i.to_string()).collect(); + println!("Merge parents: {}", parents.join(", ")); + } + } + println!(); + println!(" {}", entry.message); + println!(); + } + LogFormat::Json => unreachable!("handled above"), } } Ok(()) } +/// Format a unix-second timestamp as an absolute UTC date string +/// (`YYYY-MM-DD HH:MM:SS UTC`). Implements Howard Hinnant's civil-date +/// algorithm so we don't need a date crate. +fn format_unix_utc(unix_seconds: i64) -> String { + let days = unix_seconds.div_euclid(86_400); + let secs_of_day = unix_seconds.rem_euclid(86_400) as u32; + let h = secs_of_day / 3600; + let mi = (secs_of_day % 3600) / 60; + let s = secs_of_day % 60; + + // Days since 1970-01-01 → civil date (Hinnant). + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146_096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + let year = (y + if m <= 2 { 1 } else { 0 }) as i32; + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", + year, m, d, h, mi, s + ) +} + /// Read the contents of a file at a given tree root by walking the path. /// Returns `Ok(None)` if the path doesn't exist (or isn't a regular file) /// in that tree. @@ -651,12 +1164,7 @@ fn cmd_whodidit(args: WhodiditArgs) -> Result<(), String> { let pad: String = " ".repeat(8 + 1 + e.seal_name.len() + 2 + e.author.len() + 2); print!("{}", pad); } - println!( - "{:>width$}) {}", - li + 1, - line_text, - width = line_no_width - ); + println!("{:>width$}) {}", li + 1, line_text, width = line_no_width); } Ok(()) @@ -782,7 +1290,7 @@ fn cmd_diff(args: DiffArgs) -> Result<(), String> { println!("No staged changes."); } else { println!("Staged changes:"); - for (path, _) in ws.staging.staged_files() { + for path in ws.staging.staged_files().keys() { println!(" {} {}", color::bold_green("++"), path); } for path in ws.staging.staged_deletions() { @@ -851,79 +1359,580 @@ fn cmd_diff(args: DiffArgs) -> Result<(), String> { } } } - _ => return Err("too many arguments. Usage: ivaldi diff [ []]".into()), - } - Ok(()) -} + _ => return Err("too many arguments. Usage: ivaldi diff [ []]".into()), + } + Ok(()) +} + +/// Resolve a diff target to a (label, tree_hash) pair. +/// Tries timeline name first, then seal name/hash prefix. +fn resolve_tree(repo: &Repo, target: &str) -> Result<(String, crate::hash::B3Hash), String> { + // Try as timeline name first + if let Some(head_idx) = repo.get_timeline_head(target).map_err(|e| e.to_string())? + && let Some(leaf) = repo.get_leaf(head_idx).map_err(|e| e.to_string())? + { + return Ok((format!("timeline:{}", target), leaf.tree_root)); + } + + // Try as seal name / hash prefix + if let Some((_, leaf)) = repo.resolve_seal(target).map_err(|e| e.to_string())? { + let hash = leaf.hash(); + let name = seal::generate_seal_name(hash); + return Ok((format!("seal:{}", name), leaf.tree_root)); + } + + Err(format!( + "could not resolve '{}' as timeline or seal", + target + )) +} + +/// `rewind [--discard]`: move the timeline head back to an earlier +/// seal. Files are left exactly as they are unless `--discard` is given; +/// staged entries are cleared either way (they were gathered against the +/// old head). The seals after the target are orphaned, not deleted — they +/// stay recoverable via `travel --all`. +fn cmd_rewind(args: RewindArgs, quiet: bool) -> Result<(), String> { + let ctx = find_repo()?; + let repo = open_repo()?; + + if repo.has_merge_in_progress() { + return Err( + "cannot rewind during a merge. Finish with 'ivaldi fuse --continue' or \ + 'ivaldi fuse --abort' first." + .into(), + ); + } + + let timeline = repo.current_timeline().map_err(|e| e.to_string())?; + let (target_idx, target_leaf) = repo + .resolve_seal(&args.seal) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("seal not found: {}", args.seal))?; + + let head_idx = repo + .get_timeline_head(&timeline) + .map_err(|e| e.to_string())? + .ok_or("timeline has no seals")?; + if target_idx == head_idx { + if !quiet { + println!("Already at that seal — nothing to rewind."); + } + return Ok(()); + } + if !repo + .is_ancestor(target_idx, head_idx) + .map_err(|e| e.to_string())? + && !quiet + { + println!( + "note: '{}' is not an earlier seal on this timeline; the seals left behind \ + remain recoverable via 'ivaldi travel --all'", + args.seal + ); + } + + repo.set_timeline_head(&timeline, target_idx) + .map_err(|e| e.to_string())?; + + let cas = FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; + { + let mut ws_mut = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + ws_mut.staging.clear(); + ws_mut.save().map_err(|e| e.to_string())?; + } + if args.discard { + let ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + ws.materialize(target_leaf.tree_root) + .map_err(|e| e.to_string())?; + } + + if !quiet { + let name = repo + .get_seal_name(target_leaf.hash()) + .ok() + .flatten() + .unwrap_or_else(|| args.seal.clone()); + println!( + "Rewound '{}' to seal: {} ({})", + timeline, + name, + target_leaf.hash().short8() + ); + if args.discard { + println!("Working directory rewritten to match."); + } else { + println!("Your files were left unchanged."); + } + } + Ok(()) +} + +fn cmd_reset(args: ResetArgs, quiet: bool) -> Result<(), String> { + let ctx = find_repo()?; + + if args.hard { + // Materialize workspace from last seal tree. + let repo = open_repo()?; + let timeline = repo.current_timeline().unwrap_or_else(|_| "main".into()); + if let Some(head_idx) = repo + .get_timeline_head(&timeline) + .map_err(|e| e.to_string())? + && let Some(leaf) = repo.get_leaf(head_idx).map_err(|e| e.to_string())? + { + let cas = FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; + let ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + ws.materialize(leaf.tree_root).map_err(|e| e.to_string())?; + // Clear staging + let mut ws_mut = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + ws_mut.staging.clear(); + ws_mut.save().map_err(|e| e.to_string())?; + if !quiet { + println!("Reset to last seal. Working directory restored."); + } + return Ok(()); + } + return Err("no commits to reset to".into()); + } + + let cas = FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; + let mut ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + + if args.files.is_empty() { + ws.staging.clear(); + if !quiet { + println!("All files unstaged"); + } + } else { + for file in &args.files { + if ws.staging.unstage(file) && !quiet { + println!(" unstaged: {}", file); + } + } + } + ws.save().map_err(|e| e.to_string())?; + Ok(()) +} + +/// Shared preamble for undo/pluck: resolve the target seal and refuse in +/// states where a three-way apply would be unsafe or ambiguous. +fn resolve_for_pick( + repo: &Repo, + ws: &Workspace<'_>, + seal_query: &str, + verb: &str, +) -> Result<(u64, crate::leaf::Leaf), String> { + if repo.has_merge_in_progress() { + return Err(format!( + "cannot {} during a merge. Finish with 'ivaldi fuse --continue' or \ + 'ivaldi fuse --abort' first.", + verb + )); + } + if !ws.staging.is_empty() { + return Err(format!( + "cannot {} with staged changes. Seal them first or unstage with 'ivaldi reset'.", + verb + )); + } + let (idx, leaf) = repo + .resolve_seal(seal_query) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("seal not found: {}", seal_query))?; + if leaf.is_merge() { + return Err(format!( + "cannot {} a merge seal yet (no way to choose which parent's side to keep)", + verb + )); + } + Ok((idx, leaf)) +} + +/// Tree file-map of a leaf's parent (empty when the leaf is a timeline's +/// first seal). +fn parent_tree_files_of( + repo: &Repo, + store: &crate::fsmerkle::FsStore<'_>, + leaf: &crate::leaf::Leaf, +) -> Result, String> { + let parent_tree = if leaf.has_parent() { + repo.get_leaf(leaf.prev_idx) + .map_err(|e| e.to_string())? + .map(|l| l.tree_root) + } else { + None + }; + crate::pick::tree_files(store, parent_tree) +} + +/// Finish a successful undo/pluck: report or materialize. +fn finish_pick( + outcome: crate::pick::ApplyOutcome, + cas: &FileCas, + ctx: &RepoContext, + verb: &str, + quiet: bool, +) -> Result<(), String> { + use crate::pick::ApplyOutcome; + match outcome { + ApplyOutcome::Conflicts(paths) => { + let mut msg = format!("{} conflicts with other changes in:\n", verb); + for p in &paths { + msg.push_str(&format!(" {}\n", p)); + } + msg.push_str("Resolve by editing the files manually and sealing, nothing was changed."); + Err(msg) + } + ApplyOutcome::NoChanges => { + if !quiet { + println!("{} produced no changes — nothing to seal.", verb); + } + Ok(()) + } + ApplyOutcome::Applied(result) => { + // Reflect the new head in the working directory. + let repo = open_repo()?; + if let Some(leaf) = repo.get_leaf(result.index).map_err(|e| e.to_string())? { + let ws = Workspace::new(cas, &ctx.work_dir, &ctx.ivaldi_dir); + let ignore_cache = ignore::load_pattern_cache(&ctx.work_dir); + ws.materialize_with_ignore(leaf.tree_root, &ignore_cache) + .map_err(|e| e.to_string())?; + } + if !quiet { + println!( + "Created seal: {} ({})", + result.seal_name, + result.hash.short8() + ); + } + Ok(()) + } + } +} + +fn cmd_undo(args: UndoArgs, quiet: bool) -> Result<(), String> { + let ctx = find_repo()?; + let mut repo = open_repo()?; + let cas = FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; + let ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + + let (_idx, leaf) = resolve_for_pick(&repo, &ws, &args.seal, "undo")?; + let store = crate::fsmerkle::FsStore::new(&cas); + + // undo: base = the seal's tree, theirs = its parent's tree. + let base = crate::pick::tree_files(&store, Some(leaf.tree_root))?; + let theirs = parent_tree_files_of(&repo, &store, &leaf)?; + + let first_line = leaf.message.lines().next().unwrap_or("").to_string(); + let seal_label = repo + .get_seal_name(leaf.hash()) + .ok() + .flatten() + .unwrap_or_else(|| args.seal.clone()); + let message = args.m.clone().unwrap_or_else(|| { + format!( + "Undo \"{}\"\n\nThis undoes seal {} ({}).", + first_line, + seal_label, + leaf.hash().short8() + ) + }); + + let cfg = repo.config(); + let author = cfg.author() + .ok_or("user.name and user.email not configured. Run:\n ivaldi config --set user.name \"Your Name\"\n ivaldi config --set user.email \"you@example.com\"")?; + + let outcome = crate::pick::three_way_seal(&mut repo, &cas, &base, &theirs, &author, &message)?; + drop(repo); + finish_pick(outcome, &cas, &ctx, "undo", quiet) +} + +fn cmd_pluck(args: PluckArgs, quiet: bool) -> Result<(), String> { + let ctx = find_repo()?; + let mut repo = open_repo()?; + let cas = FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; + let ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + + let (_idx, leaf) = resolve_for_pick(&repo, &ws, &args.seal, "pluck")?; + let store = crate::fsmerkle::FsStore::new(&cas); + + // pluck: base = the seal's parent tree, theirs = the seal's tree. + let base = parent_tree_files_of(&repo, &store, &leaf)?; + let theirs = crate::pick::tree_files(&store, Some(leaf.tree_root))?; + + let seal_label = repo + .get_seal_name(leaf.hash()) + .ok() + .flatten() + .unwrap_or_else(|| args.seal.clone()); + let message = args.m.clone().unwrap_or_else(|| { + format!( + "{}\n\n(plucked from {} {})", + leaf.message, + seal_label, + leaf.hash().short8() + ) + }); + + let cfg = repo.config(); + let author = cfg.author() + .ok_or("user.name and user.email not configured. Run:\n ivaldi config --set user.name \"Your Name\"\n ivaldi config --set user.email \"you@example.com\"")?; + + let outcome = crate::pick::three_way_seal(&mut repo, &cas, &base, &theirs, &author, &message)?; + drop(repo); + finish_pick(outcome, &cas, &ctx, "pluck", quiet) +} + +/// Refuse to run while an interrupted timeline switch needs recovery. +/// Called by mutating commands (except `timeline switch`, which handles +/// resume itself). Read-only commands stay usable for orientation. +fn ensure_no_interrupted_switch(ivaldi_dir: &std::path::Path) -> Result<(), String> { + match crate::switch_journal::load(ivaldi_dir) { + Ok(None) => Ok(()), + Ok(Some(j)) => Err(format!( + "an interrupted timeline switch from '{}' to '{}' needs recovery.\n\ + Run 'ivaldi timeline switch {}' to complete it, or 'ivaldi timeline switch {}' to roll back.", + j.from, j.to, j.to, j.from + )), + Err(e) => Err(format!( + "cannot read .ivaldi/{}: {}.\n\ + Inspect (and if invalid, delete) that file, then retry.", + crate::switch_journal::JOURNAL_FILE, + e + )), + } +} + +/// Switch timelines with crash-recovery journaling. +/// +/// Ordering: shelve the current timeline's dirty state (and flush the CAS — +/// the shelf holds the only copies), clear staging, write the journal, then +/// do the destructive-but-idempotent steps (HEAD rewrite, materialize, +/// shelf restore) and clear the journal. A crash after the journal is +/// written is recovered by re-running the switch toward `to` (complete) or +/// `from` (roll back); both replay only idempotent steps. +pub(crate) fn do_timeline_switch( + work_dir: &std::path::Path, + ivaldi_dir: &std::path::Path, + target: &str, + quiet: bool, +) -> Result<(), String> { + use crate::shelf::{Shelf, ShelfManager, WorkspaceChange}; + use crate::switch_journal::{self, SwitchJournal}; + + let repo = Repo::open(work_dir).map_err(|e| e.to_string())?; + let current = repo.current_timeline().unwrap_or_default(); + let cas = FileCas::new(ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; + let ignore_cache = ignore::load_pattern_cache(work_dir); + let shelf_mgr = ShelfManager::new(ivaldi_dir); + + fn change_counts(changes: &[WorkspaceChange], staged: usize) -> Vec { + let mut counts = (0usize, 0usize, 0usize); + for c in changes { + match c { + WorkspaceChange::Modified { .. } => counts.0 += 1, + WorkspaceChange::Untracked { .. } => counts.1 += 1, + WorkspaceChange::Deleted { .. } => counts.2 += 1, + } + } + let mut summary = Vec::new(); + if counts.0 > 0 { + summary.push(format!("{} modified", counts.0)); + } + if counts.1 > 0 { + summary.push(format!("{} untracked", counts.1)); + } + if counts.2 > 0 { + summary.push(format!("{} deleted", counts.2)); + } + if staged > 0 { + summary.push(format!("{} staged", staged)); + } + summary + } + + // Finish a switch to `dest`: HEAD rewrite, materialize, shelf restore. + // Every step is idempotent, so this is safe to replay on resume. + let finish = |dest: &str| -> Result, String> { + repo.switch_timeline(dest).map_err(|e| e.to_string())?; + + if let Some(idx) = repo.get_timeline_head(dest).map_err(|e| e.to_string())? + && let Some(leaf) = repo.get_leaf(idx).map_err(|e| e.to_string())? + { + let ws_mat = Workspace::new(&cas, work_dir, ivaldi_dir); + ws_mat + .materialize_with_ignore(leaf.tree_root, &ignore_cache) + .map_err(|e| format!("failed to materialize timeline: {}", e))?; + } + + let mut restored_summary = Vec::new(); + let mut ws_mut = Workspace::new(&cas, work_dir, ivaldi_dir); + ws_mut.staging.clear(); + if let Ok(Some(shelf)) = shelf_mgr.load_shelf(dest) { + if !shelf.workspace_changes.is_empty() { + ws_mut + .apply_changes(&shelf.workspace_changes) + .map_err(|e| format!("failed to restore shelved changes: {}", e))?; + } + for (path, hash) in &shelf.staged_files { + ws_mut.staging.stage(path, *hash); + } + restored_summary = change_counts(&shelf.workspace_changes, shelf.staged_files.len()); + shelf_mgr.remove_shelf(dest).ok(); + } + ws_mut.save().map_err(|e| e.to_string())?; + Ok(restored_summary) + }; -/// Resolve a diff target to a (label, tree_hash) pair. -/// Tries timeline name first, then seal name/hash prefix. -fn resolve_tree(repo: &Repo, target: &str) -> Result<(String, crate::hash::B3Hash), String> { - // Try as timeline name first - if let Some(head_idx) = repo.get_timeline_head(target).map_err(|e| e.to_string())? { - if let Some(leaf) = repo.get_leaf(head_idx).map_err(|e| e.to_string())? { - return Ok((format!("timeline:{}", target), leaf.tree_root)); + // ---- Resume / rollback of an interrupted switch ---- + if let Some(j) = switch_journal::load(ivaldi_dir).map_err(|e| { + format!( + "cannot read .ivaldi/{}: {}. Inspect (and if invalid, delete) that file, then retry.", + switch_journal::JOURNAL_FILE, + e + ) + })? { + if target != j.to && target != j.from { + return Err(format!( + "an interrupted timeline switch from '{}' to '{}' needs recovery.\n\ + Run 'ivaldi timeline switch {}' to complete it, or 'ivaldi timeline switch {}' to roll back,\n\ + before switching elsewhere.", + j.from, j.to, j.to, j.from + )); + } + // Do NOT re-capture the worktree: it is mid-transition; the source + // timeline's dirty state is already in its shelf. + if !quiet { + if target == j.to { + println!("Completing interrupted switch '{}' → '{}'", j.from, j.to); + } else { + println!("Rolling back interrupted switch '{}' → '{}'", j.from, j.to); + } + } + let restored_summary = finish(target)?; + switch_journal::clear(ivaldi_dir).map_err(|e| e.to_string())?; + if !quiet { + println!("Switched to timeline: {}", target); + if !restored_summary.is_empty() { + println!( + "Restored shelved changes for '{}': {}", + target, + restored_summary.join(", ") + ); + } } + return Ok(()); } - // Try as seal name / hash prefix - if let Some((_, leaf)) = repo.resolve_seal(target).map_err(|e| e.to_string())? { - let hash = leaf.hash(); - let name = seal::generate_seal_name(hash); - return Ok((format!("seal:{}", name), leaf.tree_root)); + if current == target { + if !quiet { + println!("Already on timeline: {}", target); + } + return Ok(()); } - Err(format!( - "could not resolve '{}' as timeline or seal", - target - )) -} - -fn cmd_reset(args: ResetArgs, quiet: bool) -> Result<(), String> { - let ctx = find_repo()?; + // Verify target exists before any side effects + let target_head_idx = repo.get_timeline_head(target).map_err(|e| e.to_string())?; + if target_head_idx.is_none() { + let ref_path = ivaldi_dir.join("refs/heads").join(target); + if !ref_path.exists() { + return Err(format!("timeline '{}' not found", target)); + } + } - if args.hard { - // Materialize workspace from last seal tree - let repo = open_repo()?; - let timeline = repo.current_timeline().unwrap_or_else(|_| "main".into()); - if let Some(head_idx) = repo - .get_timeline_head(&timeline) + // ---- Auto-shelve: capture everything dirty about `current` ---- + // + // Staging + working-tree changes (Modified, Untracked, Deleted) go into + // a shelf keyed by `current`. This must happen BEFORE materialize, which + // rewrites the working tree to look like the target timeline. + let mut shelved_summary: Vec = Vec::new(); + let shelf_saved; + { + let ws = Workspace::new(&cas, work_dir, ivaldi_dir); + let current_tree = repo + .get_timeline_head(¤t) .map_err(|e| e.to_string())? - { - if let Some(leaf) = repo.get_leaf(head_idx).map_err(|e| e.to_string())? { - let cas = - FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; - let ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); - ws.materialize(leaf.tree_root).map_err(|e| e.to_string())?; - // Clear staging - let mut ws_mut = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); - ws_mut.staging.clear(); - ws_mut.save().map_err(|e| e.to_string())?; - if !quiet { - println!("Reset to last seal. Working directory restored."); - } - return Ok(()); - } + .and_then(|idx| repo.get_leaf(idx).ok().flatten()) + .map(|l| l.tree_root); + let workspace_changes = ws + .capture_changes(current_tree, &ignore_cache) + .map_err(|e| format!("failed to auto-shelve: {}", e))?; + + let mut staged = std::collections::BTreeMap::new(); + for (path, hash) in ws.staging.staged_files() { + staged.insert(path.clone(), *hash); + } + + if !staged.is_empty() || !workspace_changes.is_empty() { + shelved_summary = change_counts(&workspace_changes, staged.len()); + let shelf = Shelf { + timeline: current.clone(), + staged_files: staged, + workspace_changes, + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + }; + shelf_mgr.save_shelf(&shelf).map_err(|e| e.to_string())?; + shelf_saved = true; + } else { + // No dirty state — clear any stale shelf so we don't + // reapply stale changes if the user switches back. + shelf_mgr.remove_shelf(¤t).ok(); + shelf_saved = false; } - return Err("no commits to reset to".into()); } - let cas = FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; - let mut ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); + // The shelf references blobs that capture_changes just wrote — the CAS + // now holds the only copies of the user's uncommitted content, and + // materialize is about to destroy the working-tree copies. + cas.flush().map_err(|e| e.to_string())?; - if args.files.is_empty() { - ws.staging.clear(); - if !quiet { - println!("All files unstaged"); + // The staged entries belong to `current` and now live in its shelf; + // clear staging so a crash can't leave the target timeline pointing at + // the source's stale staging entries. + { + let mut ws_mut = Workspace::new(&cas, work_dir, ivaldi_dir); + ws_mut.staging.clear(); + ws_mut.save().map_err(|e| e.to_string())?; + } + + // ---- Journal, then the idempotent destructive steps ---- + switch_journal::write( + ivaldi_dir, + &SwitchJournal { + from: current.clone(), + to: target.to_string(), + shelf_saved, + started_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + }, + ) + .map_err(|e| e.to_string())?; + + let restored_summary = finish(target)?; + switch_journal::clear(ivaldi_dir).map_err(|e| e.to_string())?; + + if !quiet { + if !shelved_summary.is_empty() { + println!( + "Auto-shelved on '{}': {}", + current, + shelved_summary.join(", ") + ); } - } else { - for file in &args.files { - if ws.staging.unstage(file) && !quiet { - println!(" unstaged: {}", file); - } + println!("Switched to timeline: {}", target); + if !restored_summary.is_empty() { + println!( + "Restored shelved changes for '{}': {}", + target, + restored_summary.join(", ") + ); } } - ws.save().map_err(|e| e.to_string())?; Ok(()) } @@ -949,173 +1958,33 @@ fn cmd_timeline(args: TimelineArgs, quiet: bool) -> Result<(), String> { } TimelineCommands::Switch(switch_args) => { let ctx = find_repo()?; - let repo = open_repo()?; - let current = repo.current_timeline().unwrap_or_default(); - - if current == switch_args.name { - if !quiet { - println!("Already on timeline: {}", switch_args.name); - } - return Ok(()); - } - - // Verify target exists before any side effects - let target_head_idx = repo - .get_timeline_head(&switch_args.name) - .map_err(|e| e.to_string())?; - if target_head_idx.is_none() { - let ref_path = ctx.ivaldi_dir.join("refs/heads").join(&switch_args.name); - if !ref_path.exists() { - return Err(format!("timeline '{}' not found", switch_args.name)); - } - } - - let cas = FileCas::new(ctx.ivaldi_dir.join("objects")).map_err(|e| e.to_string())?; - let ignore_cache = ignore::load_pattern_cache(&ctx.work_dir); - - // ---- Auto-shelve: capture everything dirty about `current` ---- - // - // We save staging + working-tree changes (Modified, Untracked, - // Deleted) into a shelf keyed by `current`. This must happen - // BEFORE materialize, since materialize will rewrite the working - // tree to look like the target timeline. - use crate::shelf::{Shelf, ShelfManager, WorkspaceChange}; - let shelf_mgr = ShelfManager::new(&ctx.ivaldi_dir); - let mut shelved_summary: Vec = Vec::new(); - - { - let ws = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); - let current_tree = repo - .get_timeline_head(¤t) - .map_err(|e| e.to_string())? - .and_then(|idx| repo.get_leaf(idx).ok().flatten()) - .map(|l| l.tree_root); - let workspace_changes = ws - .capture_changes(current_tree, &ignore_cache) - .map_err(|e| format!("failed to auto-shelve: {}", e))?; - - let mut staged = std::collections::BTreeMap::new(); - for (path, hash) in ws.staging.staged_files() { - staged.insert(path.clone(), *hash); - } - - if !staged.is_empty() || !workspace_changes.is_empty() { - let mut counts = (0usize, 0usize, 0usize, staged.len()); - for c in &workspace_changes { - match c { - WorkspaceChange::Modified { .. } => counts.0 += 1, - WorkspaceChange::Untracked { .. } => counts.1 += 1, - WorkspaceChange::Deleted { .. } => counts.2 += 1, - } - } - if counts.0 > 0 { - shelved_summary.push(format!("{} modified", counts.0)); - } - if counts.1 > 0 { - shelved_summary.push(format!("{} untracked", counts.1)); - } - if counts.2 > 0 { - shelved_summary.push(format!("{} deleted", counts.2)); - } - if counts.3 > 0 { - shelved_summary.push(format!("{} staged", counts.3)); - } - - let shelf = Shelf { - timeline: current.clone(), - staged_files: staged, - workspace_changes, - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64, - }; - shelf_mgr.save_shelf(&shelf).map_err(|e| e.to_string())?; - } else { - // No dirty state — clear any stale shelf so we don't - // reapply stale changes if the user switches back. - shelf_mgr.remove_shelf(¤t).ok(); - } - } - - // ---- Update HEAD and materialize target tree ---- - repo.switch_timeline(&switch_args.name) - .map_err(|e| e.to_string())?; - - if let Some(idx) = target_head_idx { - if let Some(leaf) = repo.get_leaf(idx).map_err(|e| e.to_string())? { - let ws_mat = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); - ws_mat - .materialize_with_ignore(leaf.tree_root, &ignore_cache) - .map_err(|e| format!("failed to materialize timeline: {}", e))?; - } - } - - // ---- Restore target's shelf (if any) ---- - let mut restored_summary: Vec = Vec::new(); - { - let mut ws_mut = Workspace::new(&cas, &ctx.work_dir, &ctx.ivaldi_dir); - ws_mut.staging.clear(); - - if let Ok(Some(shelf)) = shelf_mgr.load_shelf(&switch_args.name) { - if !shelf.workspace_changes.is_empty() { - ws_mut - .apply_changes(&shelf.workspace_changes) - .map_err(|e| format!("failed to restore shelved changes: {}", e))?; - } - for (path, hash) in &shelf.staged_files { - ws_mut.staging.stage(path, *hash); - } - let mut counts = (0usize, 0usize, 0usize, shelf.staged_files.len()); - for c in &shelf.workspace_changes { - match c { - WorkspaceChange::Modified { .. } => counts.0 += 1, - WorkspaceChange::Untracked { .. } => counts.1 += 1, - WorkspaceChange::Deleted { .. } => counts.2 += 1, - } - } - if counts.0 > 0 { - restored_summary.push(format!("{} modified", counts.0)); - } - if counts.1 > 0 { - restored_summary.push(format!("{} untracked", counts.1)); - } - if counts.2 > 0 { - restored_summary.push(format!("{} deleted", counts.2)); - } - if counts.3 > 0 { - restored_summary.push(format!("{} staged", counts.3)); - } - shelf_mgr.remove_shelf(&switch_args.name).ok(); - } - ws_mut.save().map_err(|e| e.to_string())?; - } - - if !quiet { - if !shelved_summary.is_empty() { - println!( - "Auto-shelved on '{}': {}", - current, - shelved_summary.join(", ") - ); - } - println!("Switched to timeline: {}", switch_args.name); - if !restored_summary.is_empty() { - println!( - "Restored shelved changes for '{}': {}", - switch_args.name, - restored_summary.join(", ") - ); - } - } - Ok(()) + do_timeline_switch(&ctx.work_dir, &ctx.ivaldi_dir, &switch_args.name, quiet) } - TimelineCommands::List => { + TimelineCommands::List(list_args) => { let repo = open_repo()?; let current = repo.current_timeline().unwrap_or_default(); let timelines = repo.list_timelines().map_err(|e| e.to_string())?; - if timelines.is_empty() { + if list_args.json { + let out: Vec = if timelines.is_empty() { + vec![json::TimelineJson { + name: current.clone(), + current: true, + }] + } else { + timelines + .iter() + .map(|(name, _)| json::TimelineJson { + name: name.clone(), + current: name == ¤t, + }) + .collect() + }; + println!( + "{}", + serde_json::to_string_pretty(&out).map_err(|e| e.to_string())? + ); + } else if timelines.is_empty() { println!("* {}", current); } else { for (name, _) in &timelines { @@ -1145,9 +2014,7 @@ fn cmd_timeline(args: TimelineArgs, quiet: bool) -> Result<(), String> { let (old, new) = match rename_args.names.as_slice() { [new] => (current.clone(), new.clone()), [old, new] => (old.clone(), new.clone()), - [old, mid, new] if mid.eq_ignore_ascii_case("to") => { - (old.clone(), new.clone()) - } + [old, mid, new] if mid.eq_ignore_ascii_case("to") => (old.clone(), new.clone()), [_, mid, _] => { return Err(format!( "expected `tl rename OLD to NEW` (got `{}` between names)", @@ -1157,7 +2024,8 @@ fn cmd_timeline(args: TimelineArgs, quiet: bool) -> Result<(), String> { _ => return Err("usage: tl rename [OLD [to]] NEW".into()), }; - repo.rename_timeline(&old, &new).map_err(|e| e.to_string())?; + repo.rename_timeline(&old, &new) + .map_err(|e| e.to_string())?; if !quiet { println!( "Renamed timeline: {} → {}", @@ -1295,10 +2163,12 @@ fn cmd_fuse(args: FuseArgs, quiet: bool) -> Result<(), String> { .source .as_deref() .ok_or("source timeline required. Usage: ivaldi fuse to ")?; - let strategy = Strategy::from_str(&args.strategy).ok_or(format!( - "unknown strategy: {}. Options: auto, ours, theirs, union, base", - args.strategy - ))?; + let strategy = args.strategy.parse::().map_err(|_| { + format!( + "unknown strategy: {}. Options: auto, ours, theirs, union, base", + args.strategy + ) + })?; let target = repo.current_timeline().map_err(|e| e.to_string())?; @@ -1334,10 +2204,12 @@ fn cmd_fuse(args: FuseArgs, quiet: bool) -> Result<(), String> { // the two heads, and use its tree as the merge base. This is what makes // the `auto` strategy actually useful — without it, every file differing // between sides would be reported as a conflict. - if let Some(base_idx) = repo.merge_base(target_head, source_head).map_err(|e| e.to_string())? { - if let Some(base_leaf) = repo.get_leaf(base_idx).map_err(|e| e.to_string())? { - collect_blob_hashes(&store, base_leaf.tree_root, "", &mut base_files)?; - } + if let Some(base_idx) = repo + .merge_base(target_head, source_head) + .map_err(|e| e.to_string())? + && let Some(base_leaf) = repo.get_leaf(base_idx).map_err(|e| e.to_string())? + { + collect_blob_hashes(&store, base_leaf.tree_root, "", &mut base_files)?; } collect_blob_hashes(&store, target_leaf.tree_root, "", &mut ours_files)?; collect_blob_hashes(&store, source_leaf.tree_root, "", &mut theirs_files)?; @@ -1561,22 +2433,18 @@ fn cmd_weld(args: WeldArgs, quiet: bool) -> Result<(), String> { // Walk from end back along the timeline chain, collecting until we hit start. let mut indices = Vec::new(); - let mut found_start = false; for entry in &history { indices.push(entry.index); if entry.index == start_idx { - found_start = true; - break; - } - if entry.index == end_idx && entry.index != end_idx { break; } } // Trim leading entries above `end_idx` if `end_idx` isn't the head. + let found_start; if let Some(end_pos) = indices.iter().position(|i| *i == end_idx) { indices = indices[end_pos..].to_vec(); - // Re-check: after trimming we still need to find start_idx in what remains. - found_start = indices.iter().any(|i| *i == start_idx); + // After trimming we still need start_idx in what remains. + found_start = indices.contains(&start_idx); } else { return Err(format!( "end seal {} is not reachable from current timeline head", @@ -1632,15 +2500,25 @@ fn cmd_weld(args: WeldArgs, quiet: bool) -> Result<(), String> { // `range` is newest-first: [END, ..., START]. Validate contiguity on the // timeline chain — each entry's prev_idx must equal the next entry's idx. + // Render seal names (not MMR indices) in errors. + let seal_label = |idx: u64| -> String { + repo.get_leaf(idx) + .ok() + .flatten() + .and_then(|l| repo.get_seal_name(l.hash()).ok().flatten()) + .unwrap_or_else(|| format!("seal #{}", idx)) + }; for w in range.windows(2) { let leaf = repo .get_leaf(w[0]) .map_err(|e| e.to_string())? - .ok_or("corrupt leaf in range")?; + .ok_or_else(|| format!("corrupt seal in range: {}", seal_label(w[0])))?; if leaf.prev_idx != w[1] { return Err(format!( - "range is not contiguous on '{}' (seal at idx {} does not parent {})", - timeline, w[1], w[0] + "range is not contiguous on '{}': {} is not the parent of {}", + timeline, + seal_label(w[1]), + seal_label(w[0]) )); } } @@ -1774,8 +2652,8 @@ fn cmd_config(args: ConfigArgs) -> Result<(), String> { let repo_ctx = find_repo().ok(); let use_global = args.global || repo_ctx.is_none(); let target_path = if use_global { - let path = config::global_config_path() - .ok_or("cannot locate global config: $HOME is not set")?; + let path = + config::global_config_path().ok_or("cannot locate global config: $HOME is not set")?; if !args.global && args.set.is_some() { // Auto-fallback: the user didn't pass --global but we're outside a repo. eprintln!( @@ -1795,10 +2673,9 @@ fn cmd_config(args: ConfigArgs) -> Result<(), String> { if args.list { // Merged view when inside a repo; annotate provenance. let global = config::load_global(); - let local = repo_ctx - .as_ref() - .filter(|_| !args.global) - .map(|ctx| Config::load(&ctx.ivaldi_dir.join("config")).unwrap_or_else(|_| Config::new())); + let local = repo_ctx.as_ref().filter(|_| !args.global).map(|ctx| { + Config::load(&ctx.ivaldi_dir.join("config")).unwrap_or_else(|_| Config::new()) + }); let mut merged = Config::new(); merged.merge(&global); @@ -1812,7 +2689,12 @@ fn cmd_config(args: ConfigArgs) -> Result<(), String> { (_, Some(_)) => "global", _ => "default", }; - println!("{} = {} {}", color::cyan(key), value, color::dim(&format!("({})", provenance))); + println!( + "{} = {} {}", + color::cyan(key), + value, + color::dim(&format!("({})", provenance)) + ); } return Ok(()); } @@ -1839,9 +2721,13 @@ fn cmd_config(args: ConfigArgs) -> Result<(), String> { return Ok(()); } - // No flags — launch the interactive form. - let inside_repo = repo_ctx.is_some() && !args.global; - crate::tui::config_form::run(&target_path, inside_repo).map_err(|e| e.to_string())?; + // No flags — launch the interactive form. The form itself carries a + // local/global scope selector; --global only picks the starting scope. + let global_path = + config::global_config_path().ok_or("cannot locate global config: $HOME is not set")?; + let local_path = repo_ctx.as_ref().map(|ctx| ctx.ivaldi_dir.join("config")); + crate::tui::config_form::run(local_path.as_deref(), &global_path, args.global) + .map_err(|e| e.to_string())?; Ok(()) } @@ -1910,9 +2796,26 @@ fn cmd_portal(args: PortalArgs, quiet: bool) -> Result<(), String> { } } } - PortalCommands::List => { + PortalCommands::List(list_args) => { let portals = mgr.list().map_err(|e| e.to_string())?; - if portals.is_empty() { + if list_args.json { + let out: Vec = portals + .iter() + .map(|p| json::PortalJson { + repo: p.to_string_repr(), + platform: match p.platform { + Platform::GitHub => "github", + Platform::GitLab => "gitlab", + } + .to_string(), + url: p.base_url.clone(), + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&out).map_err(|e| e.to_string())? + ); + } else if portals.is_empty() { println!("No portals configured."); } else { println!("Configured portals:"); @@ -1982,8 +2885,7 @@ fn cmd_auth(args: AuthArgs) -> Result<(), String> { if login_args.gitlab { let host = gitlab::resolve_host(login_args.gitlab_host.as_deref()); println!("Initiating GitLab authentication against {}...", host); - let device_code = gitlab::request_device_code(&host) - .map_err(|e| e.to_string())?; + let device_code = gitlab::request_device_code(&host).map_err(|e| e.to_string())?; println!( "\nFirst, copy your one-time code: {}", device_code.user_code @@ -1996,12 +2898,9 @@ fn cmd_auth(args: AuthArgs) -> Result<(), String> { println!("Then visit: {}", url); } println!("\nWaiting for authentication..."); - let token = gitlab::poll_for_token( - &host, - &device_code.device_code, - device_code.interval, - ) - .map_err(|e| e.to_string())?; + let token = + gitlab::poll_for_token(&host, &device_code.device_code, device_code.interval) + .map_err(|e| e.to_string())?; let store = TokenStore::new().map_err(|e| e.to_string())?; store .save_token(Platform::GitLab, token) @@ -2018,10 +2917,7 @@ fn cmd_auth(args: AuthArgs) -> Result<(), String> { device_code.user_code ); if open_in_browser(&device_code.verification_uri) { - println!( - "Opened {} in your browser.", - device_code.verification_uri - ); + println!("Opened {} in your browser.", device_code.verification_uri); println!("(If nothing opened, visit the URL above manually.)"); } else { println!("Then visit: {}", device_code.verification_uri); @@ -2077,14 +2973,10 @@ fn cmd_download(args: DownloadArgs, quiet: bool) -> Result<(), String> { if let Some(url) = crate::p2p::PeerUrl::parse(&args.repo) { use crate::identity; use crate::known_peers::TofuPolicy; - let id_path = identity::default_path() - .ok_or("could not resolve $HOME for ~/.ivaldi/identity")?; - let id = - identity::Identity::load_or_create(&id_path).map_err(|e| e.to_string())?; - let default_dir = url - .timeline - .clone() - .unwrap_or_else(|| url.host.clone()); + let id_path = + identity::default_path().ok_or("could not resolve $HOME for ~/.ivaldi/identity")?; + let id = identity::Identity::load_or_create(&id_path).map_err(|e| e.to_string())?; + let default_dir = url.timeline.clone().unwrap_or_else(|| url.host.clone()); let target_dir = std::path::PathBuf::from(args.directory.as_deref().unwrap_or(&default_dir)); let policy = if args.accept_new_peer { @@ -2180,7 +3072,9 @@ fn cmd_upload(args: UploadArgs, quiet: bool) -> Result<(), String> { .clone() .unwrap_or_else(|| repo.current_timeline().unwrap_or_else(|_| "main".into())); if args.force { - print!("WARNING: Force push will OVERWRITE remote history! Type 'force push' to confirm: "); + print!( + "WARNING: Force push will OVERWRITE remote history! Type 'force push' to confirm: " + ); std::io::stdout().flush().map_err(|e| e.to_string())?; let mut input = String::new(); std::io::stdin() @@ -2229,10 +3123,9 @@ fn cmd_upload(args: UploadArgs, quiet: bool) -> Result<(), String> { use crate::identity; use crate::known_peers::TofuPolicy; - let id_path = identity::default_path() - .ok_or("could not resolve $HOME for ~/.ivaldi/identity")?; - let id = - identity::Identity::load_or_create(&id_path).map_err(|e| e.to_string())?; + let id_path = + identity::default_path().ok_or("could not resolve $HOME for ~/.ivaldi/identity")?; + let id = identity::Identity::load_or_create(&id_path).map_err(|e| e.to_string())?; let timeline = args .branch .clone() @@ -2303,8 +3196,7 @@ fn cmd_scout(_args: ScoutArgs) -> Result<(), String> { .map_err(|e| e.to_string())? .ok_or("no portal configured. Run 'ivaldi portal add owner/repo'.")?; - let branches = sync::scout_with_status(&client, &repo, &portal) - .map_err(|e| e.to_string())?; + let branches = sync::scout_with_status(&client, &repo, &portal).map_err(|e| e.to_string())?; println!("Remote timelines available:"); for branch in &branches { @@ -2428,6 +3320,7 @@ fn cmd_sync(args: SyncArgs, quiet: bool) -> Result<(), String> { /// Format a change marker for display. /// Returns (marker, color_fn) for the given ChangeKind. +#[cfg(test)] pub(crate) fn change_marker( kind: crate::fsmerkle::ChangeKind, ) -> (&'static str, fn(&str) -> String) { @@ -2441,6 +3334,7 @@ pub(crate) fn change_marker( } /// Format a file state marker for display. +#[cfg(test)] pub(crate) fn state_marker(state: FileState) -> (&'static str, fn(&str) -> String) { match state { FileState::Modified => ("~~", color::bold_yellow), @@ -2493,8 +3387,9 @@ fn cmd_review(args: ReviewArgs, quiet: bool) -> Result<(), String> { } else if let Some(ref status_str) = list_args.status { ReviewFilter { status: Some( - ReviewStatus::from_str(status_str) - .ok_or(format!("unknown status: {}", status_str))?, + status_str + .parse::() + .map_err(|_| format!("unknown status: {}", status_str))?, ), } } else { @@ -2774,8 +3669,8 @@ fn cmd_serve(args: ServeArgs, _quiet: bool) -> Result<(), String> { let ctx = find_repo()?; - let id_path = identity::default_path() - .ok_or("could not resolve $HOME for ~/.ivaldi/identity")?; + let id_path = + identity::default_path().ok_or("could not resolve $HOME for ~/.ivaldi/identity")?; let id = identity::Identity::load_or_create(&id_path).map_err(|e| e.to_string())?; let peer_store_path = ctx.ivaldi_dir.join("authorized_peers"); p2p::serve(&args.bind, ctx.work_dir.clone(), &id, peer_store_path) @@ -2785,19 +3680,19 @@ fn cmd_serve(args: ServeArgs, _quiet: bool) -> Result<(), String> { fn cmd_peer(args: PeerArgs, _quiet: bool) -> Result<(), String> { use crate::identity; - use crate::peers::{decode_pubkey, PeerStore}; + use crate::peers::{PeerStore, decode_pubkey}; match args.command { PeerCommands::Whoami => { - let id_path = identity::default_path() - .ok_or("could not resolve $HOME for ~/.ivaldi/identity")?; + let id_path = + identity::default_path().ok_or("could not resolve $HOME for ~/.ivaldi/identity")?; let id = identity::Identity::load_or_create(&id_path).map_err(|e| e.to_string())?; println!("{}", id.pubkey_hex()); Ok(()) } PeerCommands::Trust(t) => { let ctx = find_repo()?; - let pubkey = decode_pubkey(&t.pubkey).map_err(|e| e)?; + let pubkey = decode_pubkey(&t.pubkey)?; let store = PeerStore::repo_local(&ctx.ivaldi_dir); store .trust(pubkey, t.name.as_deref()) @@ -2841,7 +3736,7 @@ fn cmd_peer(args: PeerArgs, _quiet: bool) -> Result<(), String> { } } PeerCommands::Known(known) => { - use crate::known_peers::{fingerprint, KnownPeers}; + use crate::known_peers::{KnownPeers, fingerprint}; let store = KnownPeers::default_for_user() .ok_or("could not resolve $HOME for ~/.ivaldi/known_peers")?; match known.command { @@ -2877,12 +3772,406 @@ fn cmd_peer(args: PeerArgs, _quiet: bool) -> Result<(), String> { } } +/// `ivaldi completions ` — write a completion script to stdout. +/// Requires no repository and mutates nothing. +fn cmd_completions(args: CompletionsArgs) -> Result<(), String> { + clap_complete::generate( + args.shell, + &mut ::command(), + "ivaldi", + &mut std::io::stdout(), + ); + Ok(()) +} + +/// `ivaldi man --out DIR` — render `ivaldi.1` plus one `ivaldi-.1` +/// page per subcommand into DIR. Requires no repository and mutates nothing. +fn cmd_man(args: ManArgs, quiet: bool) -> Result<(), String> { + std::fs::create_dir_all(&args.out) + .map_err(|e| format!("create {}: {}", args.out.display(), e))?; + + let cmd = ::command(); + + let write_page = |cmd: clap::Command, file: &str| -> Result<(), String> { + let mut buf: Vec = Vec::new(); + clap_mangen::Man::new(cmd) + .render(&mut buf) + .map_err(|e| e.to_string())?; + let path = args.out.join(file); + std::fs::write(&path, &buf).map_err(|e| format!("write {}: {}", path.display(), e)) + }; + + write_page(cmd.clone(), "ivaldi.1")?; + let mut pages = 1; + for sub in cmd.get_subcommands() { + let name = sub.get_name().to_string(); + write_page(sub.clone(), &format!("ivaldi-{}.1", name))?; + pages += 1; + } + + if !quiet { + println!("Wrote {} man page(s) to {}", pages, args.out.display()); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; use crate::fsmerkle::ChangeKind; use crate::workspace::FileState; + mod patch_session { + use super::super::*; + use crate::cas::Cas; + use crate::fsmerkle::FsStore; + use crate::workspace::DotfileAllowlist; + use std::collections::BTreeMap; + use std::fs; + use std::path::PathBuf; + + /// Forge a repo whose head seals a.txt with 14 numbered lines, then + /// modify lines 2 and 12 on disk (two distinct hunks). + fn setup() -> (tempfile::TempDir, PathBuf, PathBuf, String) { + let dir = tempfile::tempdir().unwrap(); + let work_dir = dir.path().to_path_buf(); + let ivaldi_dir = work_dir.join(".ivaldi"); + forge::forge(&work_dir).unwrap(); + let mut cfg = Config::new(); + cfg.set("user.name", "Test"); + cfg.set("user.email", "t@ivaldi.dev"); + cfg.save(&ivaldi_dir.join("config")).unwrap(); + + let old: Vec = (1..=14).map(|i| format!("line {}", i)).collect(); + let old_content = old.join("\n") + "\n"; + fs::write(work_dir.join("a.txt"), &old_content).unwrap(); + + let cas = FileCas::new(ivaldi_dir.join("objects")).unwrap(); + let mut ws = Workspace::new(&cas, &work_dir, &ivaldi_dir); + ws.gather(&["a.txt"], &DotfileAllowlist::load(&ivaldi_dir)) + .unwrap(); + let tree = ws.build_seal_tree(None).unwrap(); + let mut repo = Repo::open(&work_dir).unwrap(); + repo.commit(tree, "Test", "initial").unwrap(); + ws.staging.clear(); + ws.save().unwrap(); + + let mut new = old.clone(); + new[1] = "line 2 CHANGED".into(); + new[11] = "line 12 CHANGED".into(); + fs::write(work_dir.join("a.txt"), new.join("\n") + "\n").unwrap(); + + (dir, work_dir, ivaldi_dir, old_content) + } + + fn run( + work_dir: &std::path::Path, + ivaldi_dir: &std::path::Path, + answers: &str, + ) -> (Vec, BTreeMap) { + let cas = FileCas::new(ivaldi_dir.join("objects")).unwrap(); + let mut ws = Workspace::new(&cas, work_dir, ivaldi_dir); + let repo = Repo::open(work_dir).unwrap(); + let timeline = repo.current_timeline().unwrap(); + let parent_tree = repo + .get_timeline_head(&timeline) + .unwrap() + .and_then(|idx| repo.get_leaf(idx).unwrap()) + .map(|l| l.tree_root) + .unwrap(); + let parent_files = ws.list_tree_files(parent_tree).unwrap(); + drop(repo); + + let ignore_cache = ignore::load_pattern_cache(work_dir); + let mut input = std::io::Cursor::new(answers.as_bytes().to_vec()); + let staged = run_patch_session( + &mut ws, + &cas, + &parent_files, + &[], + &ignore_cache, + &mut input, + true, + ) + .unwrap(); + ws.save().unwrap(); + + let mut staged_hashes = BTreeMap::new(); + for (path, hash) in ws.staging.staged_files() { + staged_hashes.insert(path.clone(), *hash); + } + (staged, staged_hashes) + } + + fn blob_text(ivaldi_dir: &std::path::Path, hash: crate::hash::B3Hash) -> String { + let cas = FileCas::new(ivaldi_dir.join("objects")).unwrap(); + let store = FsStore::new(&cas); + let (_, content) = store.load_blob(hash).unwrap(); + String::from_utf8(content).unwrap() + } + + #[test] + fn stage_first_hunk_only() { + let (_dir, work_dir, ivaldi_dir, _old) = setup(); + let (staged, hashes) = run(&work_dir, &ivaldi_dir, "y\nn\n"); + assert_eq!(staged, vec!["a.txt"]); + + let text = blob_text(&ivaldi_dir, hashes["a.txt"]); + assert!(text.contains("line 2 CHANGED")); + assert!(!text.contains("line 12 CHANGED")); + assert!(text.contains("line 12\n")); + } + + #[test] + fn quit_stages_nothing() { + let (_dir, work_dir, ivaldi_dir, _old) = setup(); + let (staged, hashes) = run(&work_dir, &ivaldi_dir, "q\n"); + assert!(staged.is_empty()); + assert!(hashes.is_empty()); + } + + #[test] + fn all_rest_stages_full_change() { + let (_dir, work_dir, ivaldi_dir, _old) = setup(); + let (staged, hashes) = run(&work_dir, &ivaldi_dir, "a\n"); + assert_eq!(staged, vec!["a.txt"]); + let text = blob_text(&ivaldi_dir, hashes["a.txt"]); + assert!(text.contains("line 2 CHANGED")); + assert!(text.contains("line 12 CHANGED")); + } + + #[test] + fn untracked_file_gets_whole_file_prompt() { + let (_dir, work_dir, ivaldi_dir, old) = setup(); + // Restore a.txt so only the new untracked file is a candidate. + fs::write(work_dir.join("a.txt"), &old).unwrap(); + fs::write(work_dir.join("b.txt"), "fresh\n").unwrap(); + + let (staged, hashes) = run(&work_dir, &ivaldi_dir, "y\n"); + assert_eq!(staged, vec!["b.txt"]); + let cas = FileCas::new(ivaldi_dir.join("objects")).unwrap(); + assert!(cas.has(hashes["b.txt"]).unwrap()); + } + + #[test] + fn binary_file_gets_whole_file_prompt() { + let (_dir, work_dir, ivaldi_dir, _old) = setup(); + fs::write(work_dir.join("a.txt"), b"bin\x00ary new").unwrap(); + // Seal tree's a.txt is text, disk is binary → whole-file prompt. + let (staged, hashes) = run(&work_dir, &ivaldi_dir, "y\n"); + assert_eq!(staged, vec!["a.txt"]); + let cas = FileCas::new(ivaldi_dir.join("objects")).unwrap(); + let store = FsStore::new(&cas); + let (_, content) = store.load_blob(hashes["a.txt"]).unwrap(); + assert_eq!(content, b"bin\x00ary new"); + } + } + + mod switch_recovery { + use super::super::*; + use crate::switch_journal::{self, SwitchJournal}; + use crate::workspace::DotfileAllowlist; + use std::fs; + use std::path::{Path, PathBuf}; + + /// Forge a repo with one seal on `main` (a.txt) and a `feature` + /// timeline branched from it. + fn setup() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let work_dir = dir.path().to_path_buf(); + let ivaldi_dir = work_dir.join(".ivaldi"); + forge::forge(&work_dir).unwrap(); + let mut cfg = Config::new(); + cfg.set("user.name", "Test"); + cfg.set("user.email", "t@ivaldi.dev"); + cfg.save(&ivaldi_dir.join("config")).unwrap(); + + fs::write(work_dir.join("a.txt"), "main content").unwrap(); + seal_all(&work_dir, &ivaldi_dir, "initial"); + + let repo = Repo::open(&work_dir).unwrap(); + repo.create_timeline("feature", None).unwrap(); + (dir, work_dir, ivaldi_dir) + } + + fn seal_all(work_dir: &Path, ivaldi_dir: &Path, message: &str) { + let cas = FileCas::new(ivaldi_dir.join("objects")).unwrap(); + let mut ws = Workspace::new(&cas, work_dir, ivaldi_dir); + ws.gather(&["a.txt"], &DotfileAllowlist::load(ivaldi_dir)) + .unwrap(); + let mut repo = Repo::open(work_dir).unwrap(); + let timeline = repo.current_timeline().unwrap(); + let parent = repo + .get_timeline_head(&timeline) + .unwrap() + .and_then(|idx| repo.get_leaf(idx).unwrap()) + .map(|l| l.tree_root); + let tree = ws.build_seal_tree(parent).unwrap(); + repo.commit(tree, "Test ", message).unwrap(); + ws.staging.clear(); + ws.save().unwrap(); + } + + fn no_tmp_files(dir: &Path) -> bool { + !fs::read_dir(dir) + .unwrap() + .any(|e| e.unwrap().file_name().to_string_lossy().contains(".tmp.")) + } + + #[test] + fn happy_path_shelves_and_restores() { + let (_dir, work_dir, ivaldi_dir) = setup(); + + // Dirty the worktree on main. + fs::write(work_dir.join("a.txt"), "dirty edit").unwrap(); + fs::write(work_dir.join("b.txt"), "untracked").unwrap(); + + do_timeline_switch(&work_dir, &ivaldi_dir, "feature", true).unwrap(); + assert!(switch_journal::load(&ivaldi_dir).unwrap().is_none()); + assert!(no_tmp_files(&ivaldi_dir)); + let repo = Repo::open(&work_dir).unwrap(); + assert_eq!(repo.current_timeline().unwrap(), "feature"); + // Worktree reverted to the sealed tree; dirt is shelved on main. + assert_eq!( + fs::read_to_string(work_dir.join("a.txt")).unwrap(), + "main content" + ); + assert!(!work_dir.join("b.txt").exists()); + drop(repo); + + // Switching back restores the shelf. + do_timeline_switch(&work_dir, &ivaldi_dir, "main", true).unwrap(); + assert_eq!( + fs::read_to_string(work_dir.join("a.txt")).unwrap(), + "dirty edit" + ); + assert_eq!( + fs::read_to_string(work_dir.join("b.txt")).unwrap(), + "untracked" + ); + assert!(switch_journal::load(&ivaldi_dir).unwrap().is_none()); + } + + #[test] + fn resume_completes_interrupted_switch() { + let (_dir, work_dir, ivaldi_dir) = setup(); + + // Simulate a crash right after the journal write: shelve phase + // done (clean worktree → no shelf), journal present, HEAD and + // worktree untouched. + switch_journal::write( + &ivaldi_dir, + &SwitchJournal { + from: "main".into(), + to: "feature".into(), + shelf_saved: false, + started_at: 0, + }, + ) + .unwrap(); + + do_timeline_switch(&work_dir, &ivaldi_dir, "feature", true).unwrap(); + assert!(switch_journal::load(&ivaldi_dir).unwrap().is_none()); + let repo = Repo::open(&work_dir).unwrap(); + assert_eq!(repo.current_timeline().unwrap(), "feature"); + } + + #[test] + fn rollback_returns_to_source() { + let (_dir, work_dir, ivaldi_dir) = setup(); + switch_journal::write( + &ivaldi_dir, + &SwitchJournal { + from: "main".into(), + to: "feature".into(), + shelf_saved: false, + started_at: 0, + }, + ) + .unwrap(); + + do_timeline_switch(&work_dir, &ivaldi_dir, "main", true).unwrap(); + assert!(switch_journal::load(&ivaldi_dir).unwrap().is_none()); + let repo = Repo::open(&work_dir).unwrap(); + assert_eq!(repo.current_timeline().unwrap(), "main"); + } + + #[test] + fn third_timeline_refused_while_journal_exists() { + let (_dir, work_dir, ivaldi_dir) = setup(); + let repo = Repo::open(&work_dir).unwrap(); + repo.create_timeline("third", None).unwrap(); + repo.switch_timeline("main").unwrap(); + drop(repo); + + switch_journal::write( + &ivaldi_dir, + &SwitchJournal { + from: "main".into(), + to: "feature".into(), + shelf_saved: false, + started_at: 0, + }, + ) + .unwrap(); + + let err = do_timeline_switch(&work_dir, &ivaldi_dir, "third", true).unwrap_err(); + assert!(err.contains("interrupted timeline switch")); + // Journal untouched. + assert!(switch_journal::load(&ivaldi_dir).unwrap().is_some()); + } + + #[test] + fn guard_blocks_mutations_while_journal_exists() { + let (_dir, _work_dir, ivaldi_dir) = setup(); + assert!(ensure_no_interrupted_switch(&ivaldi_dir).is_ok()); + + switch_journal::write( + &ivaldi_dir, + &SwitchJournal { + from: "main".into(), + to: "feature".into(), + shelf_saved: true, + started_at: 0, + }, + ) + .unwrap(); + let err = ensure_no_interrupted_switch(&ivaldi_dir).unwrap_err(); + assert!(err.contains("timeline switch feature")); + } + + #[test] + fn already_on_timeline_still_resumes_when_journal_targets_it() { + // Crash right after the HEAD write: HEAD already points at the + // target, but materialize/restore never ran. Re-running the + // switch must complete it, not early-return "already on". + let (_dir, work_dir, ivaldi_dir) = setup(); + let repo = Repo::open(&work_dir).unwrap(); + repo.switch_timeline("feature").unwrap(); + drop(repo); + fs::write(work_dir.join("a.txt"), "mid-transition garbage").unwrap(); + + switch_journal::write( + &ivaldi_dir, + &SwitchJournal { + from: "main".into(), + to: "feature".into(), + shelf_saved: false, + started_at: 0, + }, + ) + .unwrap(); + + do_timeline_switch(&work_dir, &ivaldi_dir, "feature", true).unwrap(); + assert!(switch_journal::load(&ivaldi_dir).unwrap().is_none()); + // Materialize ran: the sealed content is back. + assert_eq!( + fs::read_to_string(work_dir.join("a.txt")).unwrap(), + "main content" + ); + } + } + #[test] fn status_markers_added() { let (marker, _) = state_marker(FileState::Untracked); diff --git a/src/cli/json.rs b/src/cli/json.rs new file mode 100644 index 0000000..d466e80 --- /dev/null +++ b/src/cli/json.rs @@ -0,0 +1,128 @@ +//! Plain serde view structs for `--json` / `--format json` output. +//! +//! These mirror what the human-readable commands print. They are kept as +//! flat string-based views so domain types (`HistoryEntry`, `Leaf`, …) +//! never need to derive `Serialize` themselves. + +use crate::repo::HistoryEntry; + +/// JSON view of `ivaldi status`. +#[derive(serde::Serialize)] +pub struct StatusJson { + pub timeline: String, + pub head: Option, + pub files: Vec, + pub staged_deletions: Vec, +} + +/// JSON reference to a seal (commit). +#[derive(serde::Serialize)] +pub struct SealRefJson { + pub seal_name: String, + pub hash: String, + pub short_hash: String, +} + +/// JSON view of a single workspace file and its state. +#[derive(serde::Serialize)] +pub struct FileJson { + pub path: String, + pub state: String, + pub hash: Option, +} + +/// JSON view of one `ivaldi log` entry. +#[derive(serde::Serialize)] +pub struct LogEntryJson { + pub index: u64, + pub hash: String, + pub short_hash: String, + pub seal_name: String, + pub author: String, + pub message: String, + pub time_unix: i64, + pub timeline: String, + pub is_merge: bool, +} + +impl From<&HistoryEntry> for LogEntryJson { + fn from(entry: &HistoryEntry) -> Self { + Self { + index: entry.index, + hash: entry.hash.to_hex(), + short_hash: entry.short_hash.clone(), + seal_name: entry.seal_name.clone(), + author: entry.author.clone(), + message: entry.message.clone(), + time_unix: entry.time_unix, + timeline: entry.timeline.clone(), + is_merge: entry.is_merge, + } + } +} + +/// JSON view of one `ivaldi timeline list` row. +#[derive(serde::Serialize)] +pub struct TimelineJson { + pub name: String, + pub current: bool, +} + +/// JSON view of one `ivaldi portal list` row. +#[derive(serde::Serialize)] +pub struct PortalJson { + /// `owner/repo` as printed by `portal list`. + pub repo: String, + /// Platform name (`github` or `gitlab`). + pub platform: String, + /// Custom instance URL, if configured. + pub url: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::B3Hash; + + #[test] + fn log_entry_json_has_expected_keys() { + let hash = B3Hash::digest(b"seal content"); + let entry = HistoryEntry { + index: 7, + hash, + seal_name: "swift-eagle".into(), + short_hash: hash.short8(), + author: "Jane Doe ".into(), + message: "add login".into(), + time_unix: 1_700_000_000, + timeline: "main".into(), + is_merge: true, + }; + + let value = serde_json::to_value(LogEntryJson::from(&entry)).unwrap(); + let obj = value.as_object().unwrap(); + for key in [ + "index", + "hash", + "short_hash", + "seal_name", + "author", + "message", + "time_unix", + "timeline", + "is_merge", + ] { + assert!(obj.contains_key(key), "missing key: {}", key); + } + + assert_eq!(value["index"], 7); + assert_eq!(value["hash"], hash.to_hex()); + assert_eq!(value["short_hash"], hash.short8()); + assert_eq!(value["seal_name"], "swift-eagle"); + assert_eq!(value["author"], "Jane Doe "); + assert_eq!(value["message"], "add login"); + assert_eq!(value["time_unix"], 1_700_000_000_i64); + assert_eq!(value["timeline"], "main"); + assert_eq!(value["is_merge"], true); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8bca758..f559dcf 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,6 +3,7 @@ //! All commands are defined using `clap` with 1:1 parity to the Go Cobra implementation. mod commands; +mod json; use clap::{Parser, Subcommand}; @@ -41,8 +42,11 @@ pub enum Commands { /// Create a sealed commit from staged files Seal(SealArgs), + /// Redo the most recent seal, folding in staged changes and/or a new message + Reseal(ResealArgs), + /// Show repository status - Status, + Status(StatusArgs), /// Show current timeline and position #[command(alias = "wai")] @@ -61,6 +65,16 @@ pub enum Commands { /// Unstage files or reset changes Reset(ResetArgs), + /// Move the timeline head back to an earlier seal + Rewind(RewindArgs), + + /// Create a seal that undoes an earlier seal's changes + Undo(UndoArgs), + + /// Apply another seal's changes to the current timeline + #[command(alias = "cherry-pick")] + Pluck(PluckArgs), + /// Manage timelines (branches) #[command(alias = "tl")] Timeline(TimelineArgs), @@ -75,7 +89,8 @@ pub enum Commands { #[command(alias = "w")] Weld(WeldArgs), - /// View and modify configuration + /// View and modify configuration (bare `config` opens an interactive form) + #[command(alias = "configure")] Config(ConfigArgs), /// Add patterns to .ivaldiignore @@ -114,6 +129,46 @@ pub enum Commands { /// Manage authorized peers for `ivaldi serve` Peer(PeerArgs), + + /// Generate shell completion script to stdout + Completions(CompletionsArgs), + + /// Generate man pages into a directory + #[command(hide = true)] + Man(ManArgs), +} + +#[derive(clap::Args, Debug)] +pub struct UndoArgs { + /// Seal to undo (name or hash prefix) + pub seal: String, + + /// Custom message (default: Undo "") + #[arg(short)] + pub m: Option, +} + +#[derive(clap::Args, Debug)] +pub struct PluckArgs { + /// Seal to apply (name or hash prefix) + pub seal: String, + + /// Custom message (default: original message with a "plucked from" trailer) + #[arg(short)] + pub m: Option, +} + +#[derive(clap::Args, Debug)] +pub struct CompletionsArgs { + /// Shell to generate completions for + pub shell: clap_complete::Shell, +} + +#[derive(clap::Args, Debug)] +pub struct ManArgs { + /// Output directory for the generated man pages + #[arg(long, default_value = "man")] + pub out: std::path::PathBuf, } #[derive(clap::Args, Debug)] @@ -190,6 +245,10 @@ pub struct GatherArgs { /// Skip interactive prompts for hidden files #[arg(long)] pub allow_all: bool, + + /// Interactively choose which hunks of each changed file to stage + #[arg(short = 'p', long, conflicts_with = "allow_all")] + pub patch: bool, } #[derive(clap::Args, Debug)] @@ -203,18 +262,59 @@ pub struct SealArgs { pub m: Option, } +#[derive(clap::Args, Debug)] +pub struct ResealArgs { + /// New message (without one, the old message is kept) + #[arg()] + pub message: Option, + + /// New message (alternative flag) + #[arg(short)] + pub m: Option, +} + +impl ResealArgs { + pub fn get_message(&self) -> Option<&str> { + self.message.as_deref().or(self.m.as_deref()) + } +} + impl SealArgs { pub fn get_message(&self) -> Option<&str> { self.message.as_deref().or(self.m.as_deref()) } } +#[derive(clap::Args, Debug)] +pub struct StatusArgs { + /// Emit machine-readable JSON instead of human-readable output + #[arg(long)] + pub json: bool, +} + +/// Output format for `ivaldi log`. +#[derive(Clone, Copy, Debug, PartialEq, clap::ValueEnum)] +pub enum LogFormat { + /// One line per seal (same as --oneline) + Short, + /// Default multi-line format + Medium, + /// Medium plus absolute date, tree root, and merge parents + Full, + /// Machine-readable JSON array + Json, +} + #[derive(clap::Args, Debug)] pub struct LogArgs { /// Show concise one-line format #[arg(long)] pub oneline: bool, + /// Output format (short, medium, full, json) + #[arg(long, value_enum, conflicts_with = "oneline")] + pub format: Option, + /// Limit number of commits shown #[arg(long)] pub limit: Option, @@ -261,6 +361,17 @@ pub struct ResetArgs { pub hard: bool, } +#[derive(clap::Args, Debug)] +pub struct RewindArgs { + /// Seal to rewind the timeline head to (name or hash prefix) + pub seal: String, + + /// Also rewrite the working directory to match that seal (destructive!). + /// Without this flag your files are left exactly as they are. + #[arg(long)] + pub discard: bool, +} + #[derive(clap::Args, Debug)] pub struct TimelineArgs { #[command(subcommand)] @@ -279,7 +390,7 @@ pub enum TimelineCommands { /// List all timelines #[command(alias = "ls")] - List, + List(TimelineListArgs), /// Remove a timeline #[command(alias = "rm")] @@ -294,6 +405,13 @@ pub enum TimelineCommands { Butterfly(ButterflyArgs), } +#[derive(clap::Args, Debug)] +pub struct TimelineListArgs { + /// Emit machine-readable JSON instead of human-readable output + #[arg(long)] + pub json: bool, +} + #[derive(clap::Args, Debug)] pub struct TimelineCreateArgs { /// Name for the new timeline @@ -470,12 +588,19 @@ pub enum PortalCommands { Add(PortalAddArgs), /// List configured portals - List, + List(PortalListArgs), /// Remove a portal Remove(PortalRemoveArgs), } +#[derive(clap::Args, Debug)] +pub struct PortalListArgs { + /// Emit machine-readable JSON instead of human-readable output + #[arg(long)] + pub json: bool, +} + #[derive(clap::Args, Debug)] pub struct PortalAddArgs { /// Repository in owner/repo format @@ -766,6 +891,91 @@ mod tests { use super::*; use clap::Parser; + #[test] + fn parse_configure_alias() { + let cli = Cli::try_parse_from(["ivaldi", "configure"]).unwrap(); + assert!(matches!(cli.command.unwrap(), Commands::Config(_))); + } + + #[test] + fn parse_reseal() { + let cli = Cli::try_parse_from(["ivaldi", "reseal"]).unwrap(); + match cli.command.unwrap() { + Commands::Reseal(args) => assert!(args.get_message().is_none()), + _ => panic!("expected Reseal"), + } + + let cli = Cli::try_parse_from(["ivaldi", "reseal", "new msg"]).unwrap(); + match cli.command.unwrap() { + Commands::Reseal(args) => assert_eq!(args.get_message(), Some("new msg")), + _ => panic!("expected Reseal"), + } + + let cli = Cli::try_parse_from(["ivaldi", "reseal", "-m", "flag msg"]).unwrap(); + match cli.command.unwrap() { + Commands::Reseal(args) => assert_eq!(args.get_message(), Some("flag msg")), + _ => panic!("expected Reseal"), + } + } + + #[test] + fn parse_undo_and_pluck() { + let cli = Cli::try_parse_from(["ivaldi", "undo", "swift-eagle"]).unwrap(); + match cli.command.unwrap() { + Commands::Undo(args) => { + assert_eq!(args.seal, "swift-eagle"); + assert!(args.m.is_none()); + } + _ => panic!("expected Undo"), + } + + let cli = Cli::try_parse_from(["ivaldi", "pluck", "abc123", "-m", "msg"]).unwrap(); + match cli.command.unwrap() { + Commands::Pluck(args) => { + assert_eq!(args.seal, "abc123"); + assert_eq!(args.m.as_deref(), Some("msg")); + } + _ => panic!("expected Pluck"), + } + + // git muscle memory works. + let cli = Cli::try_parse_from(["ivaldi", "cherry-pick", "abc123"]).unwrap(); + assert!(matches!(cli.command.unwrap(), Commands::Pluck(_))); + } + + #[test] + fn parse_rewind_and_reset() { + let cli = Cli::try_parse_from(["ivaldi", "rewind", "swift-eagle"]).unwrap(); + match cli.command.unwrap() { + Commands::Rewind(args) => { + assert_eq!(args.seal, "swift-eagle"); + assert!(!args.discard); + } + _ => panic!("expected Rewind"), + } + + let cli = Cli::try_parse_from(["ivaldi", "rewind", "abc123", "--discard"]).unwrap(); + match cli.command.unwrap() { + Commands::Rewind(args) => assert!(args.discard), + _ => panic!("expected Rewind"), + } + + // Rewind requires a seal. + assert!(Cli::try_parse_from(["ivaldi", "rewind"]).is_err()); + + // Reset keeps its original shape: files + bare --hard. + let cli = Cli::try_parse_from(["ivaldi", "reset", "--hard"]).unwrap(); + match cli.command.unwrap() { + Commands::Reset(args) => assert!(args.hard), + _ => panic!("expected Reset"), + } + let cli = Cli::try_parse_from(["ivaldi", "reset", "file.txt"]).unwrap(); + match cli.command.unwrap() { + Commands::Reset(args) => assert_eq!(args.files, vec!["file.txt"]), + _ => panic!("expected Reset"), + } + } + #[test] fn parse_timeline_create() { let cli = Cli::try_parse_from(["ivaldi", "timeline", "create", "feature"]).unwrap(); @@ -855,8 +1065,7 @@ mod tests { #[test] fn parse_timeline_rename_with_to_connector() { - let cli = - Cli::try_parse_from(["ivaldi", "tl", "rename", "master", "to", "main"]).unwrap(); + let cli = Cli::try_parse_from(["ivaldi", "tl", "rename", "master", "to", "main"]).unwrap(); match cli.command.unwrap() { Commands::Timeline(args) => match args.command { TimelineCommands::Rename(r) => { @@ -1169,6 +1378,88 @@ mod tests { } } + #[test] + fn parse_status_json() { + let cli = Cli::try_parse_from(["ivaldi", "status", "--json"]).unwrap(); + match cli.command.unwrap() { + Commands::Status(args) => assert!(args.json), + _ => panic!("expected Status"), + } + + // Bare status still parses with json off. + let cli = Cli::try_parse_from(["ivaldi", "status"]).unwrap(); + match cli.command.unwrap() { + Commands::Status(args) => assert!(!args.json), + _ => panic!("expected Status"), + } + } + + #[test] + fn parse_log_format_json() { + let cli = Cli::try_parse_from(["ivaldi", "log", "--format", "json"]).unwrap(); + match cli.command.unwrap() { + Commands::Log(args) => { + assert_eq!(args.format, Some(LogFormat::Json)); + assert!(!args.oneline); + } + _ => panic!("expected Log"), + } + } + + #[test] + fn parse_log_oneline_still_works() { + let cli = Cli::try_parse_from(["ivaldi", "log", "--oneline"]).unwrap(); + match cli.command.unwrap() { + Commands::Log(args) => { + assert!(args.oneline); + assert!(args.format.is_none()); + } + _ => panic!("expected Log"), + } + } + + #[test] + fn parse_log_oneline_conflicts_with_format() { + assert!(Cli::try_parse_from(["ivaldi", "log", "--oneline", "--format", "json"]).is_err()); + } + + #[test] + fn parse_timeline_list_json() { + let cli = Cli::try_parse_from(["ivaldi", "timeline", "list", "--json"]).unwrap(); + match cli.command.unwrap() { + Commands::Timeline(args) => match args.command { + TimelineCommands::List(l) => assert!(l.json), + _ => panic!("expected List"), + }, + _ => panic!("expected Timeline"), + } + } + + #[test] + fn parse_completions_fish() { + let cli = Cli::try_parse_from(["ivaldi", "completions", "fish"]).unwrap(); + match cli.command.unwrap() { + Commands::Completions(args) => { + assert_eq!(args.shell, clap_complete::Shell::Fish); + } + _ => panic!("expected Completions"), + } + } + + #[test] + fn completions_fish_generates_nonempty_output() { + let mut buf = Vec::new(); + clap_complete::generate( + clap_complete::Shell::Fish, + &mut ::command(), + "ivaldi", + &mut buf, + ); + assert!(!buf.is_empty()); + let script = String::from_utf8(buf).unwrap(); + assert!(script.contains("ivaldi")); + } + #[test] fn parse_review_reopen() { let cli = Cli::try_parse_from(["ivaldi", "review", "reopen", "2"]).unwrap(); diff --git a/src/color.rs b/src/color.rs index 23cf7e2..cf62352 100644 --- a/src/color.rs +++ b/src/color.rs @@ -77,6 +77,11 @@ pub fn cyan(text: &str) -> String { wrap(CYAN, text) } +/// Render a CLI error line with a consistent red `error:` prefix. +pub fn error(msg: &str) -> String { + format!("{} {}", bold_red("error:"), msg) +} + pub fn bold_green(text: &str) -> String { if is_enabled() { format!("{}{}{}{}", BOLD, GREEN, text, RESET) diff --git a/src/config.rs b/src/config.rs index 6e97eb7..7f66973 100644 --- a/src/config.rs +++ b/src/config.rs @@ -120,7 +120,8 @@ impl Config { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(ConfigError::Io)?; } - fs::write(path, self.to_string_repr()).map_err(ConfigError::Io) + crate::atomic_io::atomic_write(path, self.to_string_repr().as_bytes()) + .map_err(ConfigError::Io) } /// Load from a file. @@ -164,10 +165,10 @@ pub fn global_config_path() -> Option { /// Load only the global config (ignoring any repo config). pub fn load_global() -> Config { let mut cfg = Config::new(); - if let Some(path) = global_config_path() { - if let Ok(user_cfg) = Config::load(&path) { - cfg.merge(&user_cfg); - } + if let Some(path) = global_config_path() + && let Ok(user_cfg) = Config::load(&path) + { + cfg.merge(&user_cfg); } cfg } diff --git a/src/diff.rs b/src/diff.rs index f40f684..4569e99 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -76,13 +76,112 @@ pub fn print_blob_as_deleted(store: &FsStore<'_>, hash: B3Hash) { /// LCS-based diff op: each entry is one source-side or new-side line. #[derive(Debug, Clone)] -enum LineOp { +pub(crate) enum LineOp { Context(String), Add(String), Remove(String), } -fn compute_ops(old: &str, new: &str) -> Vec { +/// One displayable/selectable hunk: a run of changes plus surrounding +/// context, expressed as a range of indices into the global ops vec. +/// Hunks produced by [`compute_hunks`] never overlap. +#[derive(Debug, Clone)] +pub(crate) struct Hunk { + pub ops_range: std::ops::Range, +} + +/// Group changed ops into hunks: each run of changes whose gaps are within +/// `2 * context` is merged, then padded with up to `context` lines of +/// surrounding context. +pub(crate) fn compute_hunks(ops: &[LineOp], context: usize) -> Vec { + let changed: Vec = ops + .iter() + .enumerate() + .filter_map(|(i, op)| match op { + LineOp::Add(_) | LineOp::Remove(_) => Some(i), + LineOp::Context(_) => None, + }) + .collect(); + + let mut hunks = Vec::new(); + let mut i = 0; + while i < changed.len() { + let start = changed[i].saturating_sub(context); + let mut j = i; + while j + 1 < changed.len() && changed[j + 1] <= changed[j] + 2 * context { + j += 1; + } + let end = (changed[j] + context + 1).min(ops.len()); + hunks.push(Hunk { + ops_range: start..end, + }); + i = j + 1; + } + hunks +} + +/// Reconstruct file content with only the `selected` hunks applied. +/// +/// Walks the global ops vec: context lines always pass through; removals +/// pass through (as the old line) unless their hunk is selected; additions +/// appear only when their hunk is selected. Ops outside any hunk are +/// context by construction. +/// +/// Trailing newline: `str::lines()` drops the final terminator, so it is +/// re-attached from whichever side "owns" the end of the file — `new` when +/// a selected hunk reaches EOF, `old` otherwise. (A diff consisting solely +/// of a trailing-newline change produces no line ops and is not selectable; +/// known limitation.) +pub(crate) fn apply_selected_hunks( + old: &str, + new: &str, + ops: &[LineOp], + hunks: &[Hunk], + selected: &[bool], +) -> String { + debug_assert_eq!(hunks.len(), selected.len()); + + let mut lines: Vec<&str> = Vec::new(); + let mut hunk_idx = 0; + for (i, op) in ops.iter().enumerate() { + while hunk_idx < hunks.len() && hunks[hunk_idx].ops_range.end <= i { + hunk_idx += 1; + } + let in_selected = + hunk_idx < hunks.len() && hunks[hunk_idx].ops_range.contains(&i) && selected[hunk_idx]; + match op { + LineOp::Context(s) => lines.push(s), + LineOp::Add(s) => { + if in_selected { + lines.push(s); + } + } + LineOp::Remove(s) => { + if !in_selected { + lines.push(s); + } + } + } + } + + let eof_owned_by_new = match (hunks.last(), selected.last()) { + (Some(h), Some(&sel)) => sel && h.ops_range.end >= ops.len(), + _ => false, + }; + let ends_with_newline = if eof_owned_by_new { + new.ends_with('\n') + } else { + old.ends_with('\n') + }; + + let mut out = lines.join("\n"); + if ends_with_newline && !out.is_empty() { + out.push('\n'); + } + out +} + +pub(crate) fn compute_ops(old: &str, new: &str) -> Vec { let old_lines: Vec<&str> = old.lines().collect(); let new_lines: Vec<&str> = new.lines().collect(); let m = old_lines.len(); @@ -130,42 +229,22 @@ fn compute_ops(old: &str, new: &str) -> Vec { fn print_unified_lines(old: &str, new: &str) { const CONTEXT: usize = 3; let ops = compute_ops(old, new); - - // Find indices of changed ops, then emit each run of changes plus - // up to CONTEXT context lines on each side, skipping unrelated context - // in between. - let changed: Vec = ops - .iter() - .enumerate() - .filter_map(|(i, op)| match op { - LineOp::Add(_) | LineOp::Remove(_) => Some(i), - LineOp::Context(_) => None, - }) - .collect(); - - if changed.is_empty() { - return; - } + let hunks = compute_hunks(&ops, CONTEXT); let mut printed_to: Option = None; - let mut i = 0; - while i < changed.len() { - let start = changed[i].saturating_sub(CONTEXT); - let mut j = i; - while j + 1 < changed.len() && changed[j + 1] <= changed[j] + 2 * CONTEXT { - j += 1; - } - let end = (changed[j] + CONTEXT + 1).min(ops.len()); + for hunk in &hunks { + let start = hunk.ops_range.start; + let end = hunk.ops_range.end; let block_start = match printed_to { Some(p) if p >= start => p, _ => start, }; - if let Some(p) = printed_to { - if block_start > p { - println!(" {}", color::dim("...")); - } + if let Some(p) = printed_to + && block_start > p + { + println!(" {}", color::dim("...")); } for op in &ops[block_start..end] { @@ -176,7 +255,6 @@ fn print_unified_lines(old: &str, new: &str) { } } printed_to = Some(end); - i = j + 1; } } @@ -193,10 +271,7 @@ mod tests { #[test] fn ops_for_pure_addition() { let ops = compute_ops("a\nb", "a\nb\nc"); - let adds = ops - .iter() - .filter(|o| matches!(o, LineOp::Add(_))) - .count(); + let adds = ops.iter().filter(|o| matches!(o, LineOp::Add(_))).count(); let removes = ops .iter() .filter(|o| matches!(o, LineOp::Remove(_))) @@ -208,10 +283,7 @@ mod tests { #[test] fn ops_for_pure_removal() { let ops = compute_ops("a\nb\nc", "a\nc"); - let adds = ops - .iter() - .filter(|o| matches!(o, LineOp::Add(_))) - .count(); + let adds = ops.iter().filter(|o| matches!(o, LineOp::Add(_))).count(); let removes = ops .iter() .filter(|o| matches!(o, LineOp::Remove(_))) @@ -224,10 +296,7 @@ mod tests { fn ops_for_modify() { let ops = compute_ops("hello\nworld", "hello\nrust"); // 1 context (hello), 1 remove (world), 1 add (rust) - let adds = ops - .iter() - .filter(|o| matches!(o, LineOp::Add(_))) - .count(); + let adds = ops.iter().filter(|o| matches!(o, LineOp::Add(_))).count(); let removes = ops .iter() .filter(|o| matches!(o, LineOp::Remove(_))) @@ -235,4 +304,92 @@ mod tests { assert_eq!(adds, 1); assert_eq!(removes, 1); } + + // ---- Hunk machinery ---- + + /// Two changes far apart in a sea of context: lines 2 and 12 of 14. + fn two_hunk_input() -> (String, String) { + let old: Vec = (1..=14).map(|i| format!("line {}", i)).collect(); + let mut new = old.clone(); + new[1] = "line 2 CHANGED".into(); + new[11] = "line 12 CHANGED".into(); + (old.join("\n") + "\n", new.join("\n") + "\n") + } + + fn apply(old: &str, new: &str, pick: &dyn Fn(usize) -> bool) -> String { + let ops = compute_ops(old, new); + let hunks = compute_hunks(&ops, 3); + let selected: Vec = (0..hunks.len()).map(pick).collect(); + apply_selected_hunks(old, new, &ops, &hunks, &selected) + } + + #[test] + fn hunks_split_distant_changes_and_merge_close_ones() { + let (old, new) = two_hunk_input(); + let ops = compute_ops(&old, &new); + assert_eq!(compute_hunks(&ops, 3).len(), 2); + + // Changes on adjacent lines merge into one hunk. + let ops2 = compute_ops("a\nb\nc\nd", "a\nB\nC\nd"); + assert_eq!(compute_hunks(&ops2, 3).len(), 1); + } + + #[test] + fn select_none_returns_old() { + let (old, new) = two_hunk_input(); + assert_eq!(apply(&old, &new, &|_| false), old); + } + + #[test] + fn select_all_returns_new() { + let (old, new) = two_hunk_input(); + assert_eq!(apply(&old, &new, &|_| true), new); + } + + #[test] + fn select_first_hunk_only() { + let (old, new) = two_hunk_input(); + let result = apply(&old, &new, &|i| i == 0); + assert!(result.contains("line 2 CHANGED")); + assert!(result.contains("line 12\n")); + assert!(!result.contains("line 12 CHANGED")); + } + + #[test] + fn select_second_hunk_only() { + let (old, new) = two_hunk_input(); + let result = apply(&old, &new, &|i| i == 1); + assert!(result.contains("line 2\n")); + assert!(!result.contains("line 2 CHANGED")); + assert!(result.contains("line 12 CHANGED")); + } + + #[test] + fn trailing_newline_matrix() { + // Change at EOF, new side drops the trailing newline. + let old = "a\nb\nlast\n"; + let new = "a\nb\nlast changed"; + assert_eq!(apply(old, new, &|_| true), new); + assert_eq!(apply(old, new, &|_| false), old); + + // Change at EOF, new side gains a trailing newline. + let old2 = "a\nb\nlast"; + let new2 = "a\nb\nlast changed\n"; + assert_eq!(apply(old2, new2, &|_| true), new2); + assert_eq!(apply(old2, new2, &|_| false), old2); + + // Change far from EOF: terminator stays old's either way. + let old3 = "first\nx\nx\nx\nx\nx\nx\nend\n"; + let new3 = "FIRST\nx\nx\nx\nx\nx\nx\nend\n"; + assert_eq!(apply(old3, new3, &|_| true), new3); + assert_eq!(apply(old3, new3, &|_| false), old3); + } + + #[test] + fn apply_to_empty_old_builds_new_file() { + let old = ""; + let new = "fresh\ncontent\n"; + assert_eq!(apply(old, new, &|_| true), new); + assert_eq!(apply(old, new, &|_| false), old); + } } diff --git a/src/filechunk.rs b/src/filechunk.rs index a68069b..cb413f4 100644 --- a/src/filechunk.rs +++ b/src/filechunk.rs @@ -78,7 +78,7 @@ impl<'a> ChunkBuilder<'a> { // Build binary tree bottom-up while nodes.len() > 1 { - let mut next_level = Vec::with_capacity((nodes.len() + 1) / 2); + let mut next_level = Vec::with_capacity(nodes.len().div_ceil(2)); let mut i = 0; while i < nodes.len() { if i + 1 < nodes.len() { diff --git a/src/forge.rs b/src/forge.rs index eb8f793..72dc6c7 100644 --- a/src/forge.rs +++ b/src/forge.rs @@ -81,12 +81,9 @@ pub fn forge(work_dir: &Path) -> Result { // Create default config let config = Config::new(); - config.save(&ivaldi_dir.join("config")).map_err(|e| { - ForgeError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )) - })?; + config + .save(&ivaldi_dir.join("config")) + .map_err(|e| ForgeError::Io(std::io::Error::other(e.to_string())))?; // Detect and import from existing .git/ if present let git_imported = detect_and_import_git(work_dir, &ivaldi_dir); @@ -159,7 +156,7 @@ pub fn write_head(ivaldi_dir: &Path, head: &HeadRef) -> Result<(), ForgeError> { HeadRef::Timeline(name) => format!("ref: refs/heads/{}\n", name), HeadRef::Detached(hash) => format!("{}\n", hash), }; - fs::write(&head_path, content).map_err(ForgeError::Io) + crate::atomic_io::atomic_write(&head_path, content.as_bytes()).map_err(ForgeError::Io) } /// What HEAD points to. diff --git a/src/fsmerkle.rs b/src/fsmerkle.rs index a599b65..f428a32 100644 --- a/src/fsmerkle.rs +++ b/src/fsmerkle.rs @@ -572,9 +572,7 @@ fn validate_name(name: &str) -> Result<(), FsMerkleError> { fn validate_mode(mode: u32, kind: NodeKind) -> Result<(), FsMerkleError> { match kind { - NodeKind::Blob - if mode != MODE_FILE && mode != MODE_EXEC && mode != MODE_SYMLINK => - { + NodeKind::Blob if mode != MODE_FILE && mode != MODE_EXEC && mode != MODE_SYMLINK => { Err(FsMerkleError::InvalidMode { mode, kind, diff --git a/src/fuse.rs b/src/fuse.rs index 2ac01d5..dec3038 100644 --- a/src/fuse.rs +++ b/src/fuse.rs @@ -28,15 +28,17 @@ pub enum Strategy { Base, } -impl Strategy { - pub fn from_str(s: &str) -> Option { +impl std::str::FromStr for Strategy { + type Err = (); + + fn from_str(s: &str) -> Result { match s { - "auto" => Some(Self::Auto), - "ours" => Some(Self::Ours), - "theirs" => Some(Self::Theirs), - "union" => Some(Self::Union), - "base" => Some(Self::Base), - _ => None, + "auto" => Ok(Self::Auto), + "ours" => Ok(Self::Ours), + "theirs" => Ok(Self::Theirs), + "union" => Ok(Self::Union), + "base" => Ok(Self::Base), + _ => Err(()), } } } @@ -135,9 +137,9 @@ impl FuseEngine { }); } // Auto never concatenates; it surfaces conflicts instead. - MergeDecision::Concat(..) => unreachable!( - "auto strategy does not produce Concat decisions" - ), + MergeDecision::Concat(..) => { + unreachable!("auto strategy does not produce Concat decisions") + } } } Strategy::Ours => { @@ -567,10 +569,7 @@ mod tests { /// Build a `path -> hash` map by storing each content in `store`, so the /// union strategy can actually load the blobs to concatenate them. - fn stored( - store: &FsStore<'_>, - entries: &[(&str, &[u8])], - ) -> BTreeMap { + fn stored(store: &FsStore<'_>, entries: &[(&str, &[u8])]) -> BTreeMap { entries .iter() .map(|(path, content)| { @@ -612,16 +611,29 @@ mod tests { fn union_clean_resolves_do_not_concat() { let (_dir, cas) = tmp_cas(); let store = FsStore::new(&cas); - let base = stored(&store, &[("only_ours.txt", b"O0"), ("only_theirs.txt", b"T0")]); + let base = stored( + &store, + &[("only_ours.txt", b"O0"), ("only_theirs.txt", b"T0")], + ); // only_ours changed on our side; only_theirs changed on theirs. - let ours = stored(&store, &[("only_ours.txt", b"O1"), ("only_theirs.txt", b"T0")]); - let theirs = stored(&store, &[("only_ours.txt", b"O0"), ("only_theirs.txt", b"T1")]); + let ours = stored( + &store, + &[("only_ours.txt", b"O1"), ("only_theirs.txt", b"T0")], + ); + let theirs = stored( + &store, + &[("only_ours.txt", b"O0"), ("only_theirs.txt", b"T1")], + ); let result = FuseEngine::fuse(&store, &base, &ours, &theirs, Strategy::Union); assert!(result.success); // Single-sided changes take the changed version verbatim, NOT a concat. - let (_, a) = store.load_blob(result.merged_files["only_ours.txt"]).unwrap(); - let (_, b) = store.load_blob(result.merged_files["only_theirs.txt"]).unwrap(); + let (_, a) = store + .load_blob(result.merged_files["only_ours.txt"]) + .unwrap(); + let (_, b) = store + .load_blob(result.merged_files["only_theirs.txt"]) + .unwrap(); assert_eq!(a, b"O1"); assert_eq!(b, b"T1"); } @@ -698,12 +710,12 @@ mod tests { #[test] fn strategy_from_str() { - assert_eq!(Strategy::from_str("auto"), Some(Strategy::Auto)); - assert_eq!(Strategy::from_str("ours"), Some(Strategy::Ours)); - assert_eq!(Strategy::from_str("theirs"), Some(Strategy::Theirs)); - assert_eq!(Strategy::from_str("union"), Some(Strategy::Union)); - assert_eq!(Strategy::from_str("base"), Some(Strategy::Base)); - assert_eq!(Strategy::from_str("invalid"), None); + assert_eq!("auto".parse::().ok(), Some(Strategy::Auto)); + assert_eq!("ours".parse::().ok(), Some(Strategy::Ours)); + assert_eq!("theirs".parse::().ok(), Some(Strategy::Theirs)); + assert_eq!("union".parse::().ok(), Some(Strategy::Union)); + assert_eq!("base".parse::().ok(), Some(Strategy::Base)); + assert_eq!("invalid".parse::().ok(), None); } #[test] diff --git a/src/gc.rs b/src/gc.rs index fd044cc..796c40c 100644 --- a/src/gc.rs +++ b/src/gc.rs @@ -76,12 +76,10 @@ pub fn collect_garbage( } // Clean up empty shard directories - if !dry_run { - if let Ok(entries) = fs::read_dir(objects_dir) { - for entry in entries.flatten() { - if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - let _ = fs::remove_dir(entry.path()); // only removes if empty - } + if !dry_run && let Ok(entries) = fs::read_dir(objects_dir) { + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let _ = fs::remove_dir(entry.path()); // only removes if empty } } } diff --git a/src/git_export.rs b/src/git_export.rs index 7e02dc2..076186d 100644 --- a/src/git_export.rs +++ b/src/git_export.rs @@ -35,9 +35,9 @@ use std::collections::{BTreeMap, BTreeSet}; -use crate::cas::{Cas, FileCas}; +use crate::cas::FileCas; use crate::fsmerkle::{self, FsStore, NodeKind}; -use crate::git_remote::{git_object_id, GitObjectKind}; +use crate::git_remote::{GitObjectKind, git_object_id}; use crate::hash::B3Hash; use crate::leaf::{Leaf, NO_PARENT}; use crate::repo::Repo; @@ -96,12 +96,11 @@ pub fn export_chain( // Seed leaf_to_git with already-mapped ancestors so parent lookups // resolve without re-translating. for idx in 0..repo.commit_count() { - if let Ok(Some(leaf)) = repo.get_leaf(idx) { - if let Some(sha_str) = known_mapping.get_sha1(leaf.hash()) { - if let Some(b) = sha1_hex_to_bytes(&sha_str) { - leaf_to_git.insert(idx, b); - } - } + if let Ok(Some(leaf)) = repo.get_leaf(idx) + && let Some(sha_str) = known_mapping.get_sha1(leaf.hash()) + && let Some(b) = sha1_hex_to_bytes(sha_str) + { + leaf_to_git.insert(idx, b); } } @@ -123,16 +122,16 @@ pub fn export_chain( // Resolve parent SHA-1s from already-translated map. let mut parents: Vec<[u8; 20]> = Vec::new(); - if leaf.has_parent() { - if let Some(p) = leaf_to_git.get(&leaf.prev_idx).copied() { - parents.push(p); - } + if leaf.has_parent() + && let Some(p) = leaf_to_git.get(&leaf.prev_idx).copied() + { + parents.push(p); } for &midx in &leaf.merge_idxs { - if let Some(p) = leaf_to_git.get(&midx).copied() { - if !parents.contains(&p) { - parents.push(p); - } + if let Some(p) = leaf_to_git.get(&midx).copied() + && !parents.contains(&p) + { + parents.push(p); } } @@ -184,12 +183,11 @@ fn collect_topological( // Stop descending only when this leaf's mapped git SHA-1 is in // the *server's* advertised set — i.e. the server already has // this exact commit + everything it transitively references. - if let Some(sha_str) = known_mapping.get_sha1(leaf.hash()) { - if let Some(b) = sha1_hex_to_bytes(&sha_str) { - if server_has_sha1.contains(&b) { - continue; - } - } + if let Some(sha_str) = known_mapping.get_sha1(leaf.hash()) + && let Some(b) = sha1_hex_to_bytes(sha_str) + && server_has_sha1.contains(&b) + { + continue; } chain.push(idx); for p in leaf.all_parents() { @@ -278,16 +276,11 @@ fn translate_blob( .map_err(|e| ExportError::Other(e.to_string()))?; let sha1 = sha1_hex_to_bytes(git_object_id(GitObjectKind::Blob, &content).as_str()) .expect("git_object_id always returns 40 hex chars"); - if !objects.contains_key(&sha1) { - objects.insert( - sha1, - GitObject { - sha1, - kind: GitObjectKind::Blob, - body: content, - }, - ); - } + objects.entry(sha1).or_insert(GitObject { + sha1, + kind: GitObjectKind::Blob, + body: content, + }); Ok(sha1) } @@ -308,11 +301,7 @@ fn mint_git_commit_body(leaf: &Leaf, tree_sha1: &[u8; 20], parents: &[[u8; 20]]) .get("git.author_tz") .map(String::as_str) .unwrap_or("+0000"); - let _ = writeln!( - s, - "author {} {} {}", - leaf.author, leaf.time_unix, author_tz - ); + let _ = writeln!(s, "author {} {} {}", leaf.author, leaf.time_unix, author_tz); let (committer_line, committer_time, committer_tz) = match ( leaf.meta.get("git.committer"), @@ -391,8 +380,7 @@ mod tests { 1_700_000_000, "first commit", ); - leaf.meta - .insert("git.author_tz".into(), "+0530".into()); + leaf.meta.insert("git.author_tz".into(), "+0530".into()); leaf.meta .insert("git.committer".into(), "Bob ".into()); leaf.meta @@ -527,9 +515,7 @@ mod tests { let tree_sha = translate_tree(&store, tree_hash, &mut cache, &mut objects).unwrap(); let body = &objects.get(&tree_sha).unwrap().body; - let contains = |needle: &[u8]| { - body.windows(needle.len()).any(|w| w == needle) - }; + let contains = |needle: &[u8]| body.windows(needle.len()).any(|w| w == needle); assert!(contains(b"100755 run.sh\0"), "executable mode preserved"); assert!(contains(b"120000 link\0"), "symlink mode preserved"); assert!(contains(b"100644 regular.txt\0"), "regular mode preserved"); diff --git a/src/git_pack_writer.rs b/src/git_pack_writer.rs index 3e2ca80..d13e400 100644 --- a/src/git_pack_writer.rs +++ b/src/git_pack_writer.rs @@ -19,8 +19,8 @@ use std::io::Write; -use flate2::write::ZlibEncoder; use flate2::Compression; +use flate2::write::ZlibEncoder; use sha1::{Digest, Sha1}; use crate::git_export::GitObject; diff --git a/src/git_remote.rs b/src/git_remote.rs index b5dc260..70f88d2 100644 --- a/src/git_remote.rs +++ b/src/git_remote.rs @@ -26,7 +26,8 @@ const GITHUB_BASE: &str = "https://github.com"; /// username. Bearer tokens work for `api.github.com` but are not consistently /// accepted on the git endpoints, so we use Basic here. fn basic_auth_header(token: &str) -> String { - let encoded = base64::engine::general_purpose::STANDARD.encode(format!("x-access-token:{}", token)); + let encoded = + base64::engine::general_purpose::STANDARD.encode(format!("x-access-token:{}", token)); format!("Basic {}", encoded) } @@ -152,17 +153,18 @@ impl SmartHttpClient { fn discover_refs(&self, base: &str) -> Result { let pb = progress::spinner("Discovering remote refs"); let url = format!("{}/info/refs?service=git-upload-pack", base); - let do_call = |token: Option<&str>| -> Result, ureq::Error> { - let mut r = self - .agent - .get(&url) - .header("Accept", "application/x-git-upload-pack-advertisement") - .header("User-Agent", "ivaldi-vcs/0.1.0"); - if let Some(t) = token { - r = r.header("Authorization", basic_auth_header(t)); - } - r.call() - }; + let do_call = + |token: Option<&str>| -> Result, ureq::Error> { + let mut r = self + .agent + .get(&url) + .header("Accept", "application/x-git-upload-pack-advertisement") + .header("User-Agent", "ivaldi-vcs/0.1.0"); + if let Some(t) = token { + r = r.header("Authorization", basic_auth_header(t)); + } + r.call() + }; let resp = match do_call(self.token.as_deref()) { Ok(resp) => { @@ -215,7 +217,9 @@ impl SmartHttpClient { body.extend_from_slice(b"0000"); body.extend(pkt_line("done\n")); - let do_call = |token: Option<&str>, body: &[u8]| -> Result, ureq::Error> { + let do_call = |token: Option<&str>, + body: &[u8]| + -> Result, ureq::Error> { let mut r = self .agent .post(&url) @@ -395,9 +399,7 @@ pub(crate) fn select_branch_from_discovery( .clone() .map(GitRemoteError::BranchNotFound) .unwrap_or_else(|| { - GitRemoteError::Protocol( - "remote did not advertise a usable default ref".into(), - ) + GitRemoteError::Protocol("remote did not advertise a usable default ref".into()) }) })?; @@ -464,15 +466,13 @@ pub(crate) fn parse_discovery(data: &[u8]) -> Result } } - if default_branch.is_none() { - if let Some(head) = refs.iter().find(|r| r.name == "HEAD") { - if let Some(target) = refs - .iter() - .find(|r| r.name.starts_with("refs/heads/") && r.id == head.id) - { - default_branch = target.name.strip_prefix("refs/heads/").map(str::to_string); - } - } + if default_branch.is_none() + && let Some(head) = refs.iter().find(|r| r.name == "HEAD") + && let Some(target) = refs + .iter() + .find(|r| r.name.starts_with("refs/heads/") && r.id == head.id) + { + default_branch = target.name.strip_prefix("refs/heads/").map(str::to_string); } Ok(Discovery { @@ -553,20 +553,29 @@ struct PackedEntry { data: Vec, } -pub(crate) fn parse_packfile( - data: &[u8], -) -> Result, GitRemoteError> { +/// One resolved delta entry: `(entry index, object data, sha1, object kind)`. +type ResolvedDelta = (usize, Vec, String, GitObjectKind); + +pub(crate) fn parse_packfile(data: &[u8]) -> Result, GitRemoteError> { if data.len() < 12 || &data[..4] != b"PACK" { return Err(GitRemoteError::Protocol("invalid packfile header".into())); } - let version = u32::from_be_bytes(data[4..8].try_into().unwrap()); + let version = u32::from_be_bytes( + data[4..8] + .try_into() + .map_err(|_| GitRemoteError::Protocol("truncated packfile header".into()))?, + ); if !(2..=3).contains(&version) { return Err(GitRemoteError::Unsupported(format!( "unsupported pack version {}", version ))); } - let count = u32::from_be_bytes(data[8..12].try_into().unwrap()) as usize; + let count = u32::from_be_bytes( + data[8..12] + .try_into() + .map_err(|_| GitRemoteError::Protocol("truncated packfile header".into()))?, + ) as usize; let mut idx = 12usize; let mut entries = Vec::with_capacity(count); @@ -627,8 +636,7 @@ fn resolve_pack_entries( .collect(); // Slot per entry, populated as its wave completes. - let mut slot: Vec, String, GitObjectKind)>> = - (0..n).map(|_| None).collect(); + let mut slot: Vec, String, GitObjectKind)>> = (0..n).map(|_| None).collect(); let mut sha_to_idx: HashMap = HashMap::with_capacity(n); // Wave 0: all base objects, hashed in parallel. @@ -666,15 +674,18 @@ fn resolve_pack_entries( // Partition into entries whose parent is already resolved (ready) // vs. entries that need a later wave (deferred). let (ready, deferred): (Vec, Vec) = - remaining.iter().copied().partition(|&i| match &entries[i].kind { - PackedKind::OfsDelta { base_offset } => entry_idx_by_offset - .get(base_offset) - .is_some_and(|&pi| slot[pi].is_some()), - PackedKind::RefDelta { base_sha } => sha_to_idx - .get(base_sha) - .is_some_and(|&pi| slot[pi].is_some()), - PackedKind::Base(_) => true, - }); + remaining + .iter() + .copied() + .partition(|&i| match &entries[i].kind { + PackedKind::OfsDelta { base_offset } => entry_idx_by_offset + .get(base_offset) + .is_some_and(|&pi| slot[pi].is_some()), + PackedKind::RefDelta { base_sha } => sha_to_idx + .get(base_sha) + .is_some_and(|&pi| slot[pi].is_some()), + PackedKind::Base(_) => true, + }); if ready.is_empty() { return Err(GitRemoteError::Protocol( @@ -682,7 +693,7 @@ fn resolve_pack_entries( )); } - let outputs: Result, String, GitObjectKind)>, GitRemoteError> = ready + let outputs: Result, GitRemoteError> = ready .par_iter() .map(|&i| { let e = &entries[i]; @@ -1090,8 +1101,7 @@ pub fn import_fetch_result( "Importing commits", )); - let prefetched = - prefetch_blobs(&all_blobs, &fetch.objects, &store, &mapping, &pb_blobs)?; + let prefetched = prefetch_blobs(&all_blobs, &fetch.objects, &store, &mapping, &pb_blobs)?; let blobs_downloaded = prefetched.len(); for (git_sha, hash) in prefetched { mapping.insert(&git_sha, hash); @@ -1157,13 +1167,12 @@ pub fn import_fetch_result( .insert("git.author_tz".into(), commit.author_tz.clone()); } if !commit.committer_name.is_empty() || !commit.committer_email.is_empty() { - let committer = format!( - "{} <{}>", - commit.committer_name, commit.committer_email, - ); + let committer = format!("{} <{}>", commit.committer_name, commit.committer_email,); leaf.meta.insert("git.committer".into(), committer); - leaf.meta - .insert("git.committer_time".into(), commit.committer_time.to_string()); + leaf.meta.insert( + "git.committer_time".into(), + commit.committer_time.to_string(), + ); if !commit.committer_tz.is_empty() { leaf.meta .insert("git.committer_tz".into(), commit.committer_tz.clone()); @@ -1194,7 +1203,11 @@ pub fn import_fetch_result( crate::logging::warn(&format!( "skipped {} git submodule entr{} (Ivaldi does not yet clone submodules); see .ivaldi/submodules.skipped", submodules_skipped.len(), - if submodules_skipped.len() == 1 { "y" } else { "ies" }, + if submodules_skipped.len() == 1 { + "y" + } else { + "ies" + }, )); let payload: String = submodules_skipped .iter() @@ -1209,14 +1222,11 @@ pub fn import_fetch_result( // `commit_raw` only updates the head when it actually writes a commit, // so without this the branch silently fails to materialize as a local // timeline. - let head_idx = leaf_idx_by_sha - .get(&fetch.head_sha) - .copied() - .or_else(|| { - mapping - .get_blake3(&fetch.head_sha) - .and_then(|b3| leaf_idx_by_hash.get(&b3).copied()) - }); + let head_idx = leaf_idx_by_sha.get(&fetch.head_sha).copied().or_else(|| { + mapping + .get_blake3(&fetch.head_sha) + .and_then(|b3| leaf_idx_by_hash.get(&b3).copied()) + }); if let Some(idx) = head_idx { repo.set_timeline_head(&fetch.branch, idx) .map_err(|e| GitRemoteError::Io(e.to_string()))?; @@ -1292,16 +1302,13 @@ fn import_tree( let mapped_name = match entry.name.as_str() { ".ivaldiignore" => Some(entry.name.clone()), ".gitignore" => Some(".ivaldiignore".to_string()), - n if n.starts_with('.') - && entry.mode != "40000" - && entry.mode != "040000" => - { - None - } + n if n.starts_with('.') && entry.mode != "40000" && entry.mode != "040000" => None, _ => Some(entry.name.clone()), }; - let Some(out_name) = mapped_name else { continue }; + let Some(out_name) = mapped_name else { + continue; + }; let child_path = if path_prefix.is_empty() { out_name.clone() @@ -1342,9 +1349,7 @@ fn import_tree( // the renamed `.gitignore`. continue; } - } else if out_name == ".ivaldiignore" - && rename_present.contains(&out_name) - { + } else if out_name == ".ivaldiignore" && rename_present.contains(&out_name) { // We earlier kept a renamed `.gitignore`; replace it now // that the real `.ivaldiignore` is here. ivaldi_entries.retain(|e| e.name != out_name); @@ -1482,9 +1487,9 @@ fn prefetch_blobs( pending .par_iter() .map(|sha| { - let blob = objects.get(sha.as_str()).ok_or_else(|| { - GitRemoteError::Protocol(format!("missing blob object {}", sha)) - })?; + let blob = objects + .get(sha.as_str()) + .ok_or_else(|| GitRemoteError::Protocol(format!("missing blob object {}", sha)))?; let (hash, _) = store .put_blob(&blob.data) .map_err(|e| GitRemoteError::Io(e.to_string()))?; diff --git a/src/github.rs b/src/github.rs index 4f40f37..21eb626 100644 --- a/src/github.rs +++ b/src/github.rs @@ -4,13 +4,12 @@ //! commit, blob operations, and OAuth device flow. use std::io::Read; -use std::thread; -use std::time::Duration; use base64::Engine; use serde::{Deserialize, Serialize}; use crate::auth::{self, Token}; +use crate::oauth_device; use crate::portal::Platform; const GITHUB_API: &str = "https://api.github.com"; @@ -34,7 +33,9 @@ fn header_str<'a>(resp: &'a ureq::http::Response, name: &str) -> Opt resp.headers().get(name).and_then(|v| v.to_str().ok()) } -fn check_status(resp: ureq::http::Response) -> Result, GitHubError> { +fn check_status( + resp: ureq::http::Response, +) -> Result, GitHubError> { let status = resp.status().as_u16(); if (200..300).contains(&status) { return Ok(resp); @@ -48,6 +49,12 @@ fn check_status(resp: ureq::http::Response) -> Result Self { + Self::new() + } +} + impl GitHubClient { pub fn new() -> Self { let token = auth::resolve_auth(Platform::GitHub).map(|m| m.token); @@ -211,7 +218,11 @@ impl GitHubClient { ) -> Result { let encoded = base64::engine::general_purpose::STANDARD.encode(content); let body = serde_json::json!({"content": encoded, "encoding": "base64"}); - let resp = self.send_json("POST", &format!("/repos/{}/{}/git/blobs", owner, repo), body)?; + let resp = self.send_json( + "POST", + &format!("/repos/{}/{}/git/blobs", owner, repo), + body, + )?; let r: ShaResponse = resp.into_body().read_json().map_err(gh_err)?; Ok(r.sha) } @@ -253,11 +264,16 @@ impl GitHubClient { if let Some(b) = base_tree { body["base_tree"] = serde_json::Value::String(b.into()); } - let resp = self.send_json("POST", &format!("/repos/{}/{}/git/trees", owner, repo), body)?; + let resp = self.send_json( + "POST", + &format!("/repos/{}/{}/git/trees", owner, repo), + body, + )?; let r: ShaResponse = resp.into_body().read_json().map_err(gh_err)?; Ok(r.sha) } + #[allow(clippy::too_many_arguments)] pub fn create_commit( &self, owner: &str, @@ -279,8 +295,11 @@ impl GitHubClient { if let Some(c) = committer { body["committer"] = serde_json::to_value(c).expect("identity serialization"); } - let resp = - self.send_json("POST", &format!("/repos/{}/{}/git/commits", owner, repo), body)?; + let resp = self.send_json( + "POST", + &format!("/repos/{}/{}/git/commits", owner, repo), + body, + )?; let r: ShaResponse = resp.into_body().read_json().map_err(gh_err)?; Ok(r.sha) } @@ -315,60 +334,27 @@ impl GitHubClient { } pub fn request_device_code() -> Result { - let client_id = - std::env::var("IVALDI_GITHUB_CLIENT_ID").unwrap_or(auth::GITHUB_CLIENT_ID.into()); - let scopes = std::env::var("IVALDI_GITHUB_SCOPES").unwrap_or(auth::GITHUB_SCOPES.into()); - let body = format!("client_id={}&scope={}", client_id, scopes); - let resp = ureq::post(auth::GITHUB_DEVICE_CODE_URL) - .header("Accept", "application/json") - .header("Content-Type", "application/x-www-form-urlencoded") - .send(body.as_bytes()) - .map_err(gh_err)?; - resp.into_body().read_json().map_err(gh_err) + Ok(oauth_device::request_device_code(&device_flow_config())?) } pub fn poll_for_token(device_code: &str, interval: u64) -> Result { - let client_id = - std::env::var("IVALDI_GITHUB_CLIENT_ID").unwrap_or(auth::GITHUB_CLIENT_ID.into()); - loop { - thread::sleep(Duration::from_secs(interval)); - let body = format!( - "client_id={}&device_code={}&grant_type=urn:ietf:params:oauth:grant-type:device_code", - client_id, device_code - ); - let resp = ureq::post(auth::GITHUB_ACCESS_TOKEN_URL) - .header("Accept", "application/json") - .header("Content-Type", "application/x-www-form-urlencoded") - .send(body.as_bytes()) - .map_err(gh_err)?; - let r: TokenPollResponse = resp.into_body().read_json().map_err(gh_err)?; - if r.access_token.as_deref().is_some_and(|s| !s.is_empty()) { - return Ok(Token { - access_token: r.access_token.unwrap_or_default(), - token_type: r.token_type.unwrap_or_default(), - scope: r.scope.unwrap_or_default(), - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64, - }); - } - match r.error.as_deref() { - Some("authorization_pending") => continue, - Some("slow_down") => { - thread::sleep(Duration::from_secs(5)); - continue; - } - Some(e) => { - return Err(GitHubError::Other(format!( - "{}: {}", - e, - r.error_description.unwrap_or_default() - ))); - } - None => continue, - } - } + Ok(oauth_device::poll_for_token( + &device_flow_config(), + device_code, + interval, + )?) + } +} + +/// Build the shared device-flow configuration from GitHub's constants +/// (overridable via `IVALDI_GITHUB_CLIENT_ID` / `IVALDI_GITHUB_SCOPES`). +fn device_flow_config() -> oauth_device::DeviceFlowConfig { + oauth_device::DeviceFlowConfig { + device_code_url: auth::GITHUB_DEVICE_CODE_URL.to_string(), + token_url: auth::GITHUB_ACCESS_TOKEN_URL.to_string(), + client_id: std::env::var("IVALDI_GITHUB_CLIENT_ID") + .unwrap_or(auth::GITHUB_CLIENT_ID.into()), + scopes: std::env::var("IVALDI_GITHUB_SCOPES").unwrap_or(auth::GITHUB_SCOPES.into()), } } @@ -466,23 +452,7 @@ struct ShaResponse { sha: String, } -#[derive(Debug, Deserialize)] -pub struct DeviceCodeResponse { - pub device_code: String, - pub user_code: String, - pub verification_uri: String, - pub expires_in: u64, - pub interval: u64, -} - -#[derive(Debug, Deserialize)] -struct TokenPollResponse { - access_token: Option, - token_type: Option, - scope: Option, - error: Option, - error_description: Option, -} +pub use crate::oauth_device::DeviceCodeResponse; // --- Errors --- @@ -509,6 +479,19 @@ fn gh_err(e: impl std::fmt::Display) -> GitHubError { } } +impl From for GitHubError { + fn from(e: oauth_device::DeviceFlowError) -> Self { + use oauth_device::DeviceFlowError; + match e { + DeviceFlowError::Http(m) => GitHubError::Http(m), + DeviceFlowError::Other(m) => GitHubError::Other(m), + other @ (DeviceFlowError::Expired | DeviceFlowError::Denied) => { + GitHubError::Other(other.to_string()) + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/gitlab.rs b/src/gitlab.rs index 8ae95ba..bed5894 100644 --- a/src/gitlab.rs +++ b/src/gitlab.rs @@ -9,12 +9,10 @@ //! base URL (e.g. `https://gitlab.example.com`). For a custom OAuth app, //! set `IVALDI_GITLAB_CLIENT_ID`. -use std::thread; -use std::time::Duration; - -use serde::Deserialize; - use crate::auth::{self, Token}; +use crate::oauth_device; + +pub use crate::oauth_device::DeviceCodeResponse; /// Resolve the GitLab base URL the user wants to authenticate against. /// Order: explicit `host` argument → `IVALDI_GITLAB_HOST` env → default. @@ -56,59 +54,35 @@ pub enum GitLabAuthError { Other(String), } -fn http_err(e: impl std::fmt::Display) -> GitLabAuthError { - GitLabAuthError::Http(e.to_string()) -} - -/// Response from `POST /oauth/authorize_device`. -#[derive(Debug, Deserialize)] -pub struct DeviceCodeResponse { - pub device_code: String, - pub user_code: String, - pub verification_uri: String, - /// Some GitLab versions return `verification_uri_complete` (preferred URL - /// to open in the browser — already includes the user_code). - #[serde(default)] - pub verification_uri_complete: Option, - pub expires_in: u64, - pub interval: u64, -} - -impl DeviceCodeResponse { - /// The URL we should open in the browser. Prefers the `_complete` form - /// when present so the user doesn't have to paste the code. - pub fn browser_url(&self) -> &str { - self.verification_uri_complete - .as_deref() - .unwrap_or(&self.verification_uri) +impl From for GitLabAuthError { + fn from(e: oauth_device::DeviceFlowError) -> Self { + use oauth_device::DeviceFlowError; + match e { + DeviceFlowError::Http(m) => GitLabAuthError::Http(m), + DeviceFlowError::Expired => GitLabAuthError::Expired, + DeviceFlowError::Denied => GitLabAuthError::Denied, + DeviceFlowError::Other(m) => GitLabAuthError::Other(m), + } } } -#[derive(Debug, Deserialize)] -struct TokenPollResponse { - access_token: Option, - token_type: Option, - scope: Option, - error: Option, - error_description: Option, +/// Build the shared device-flow configuration for `host` from GitLab's +/// constants (overridable via `IVALDI_GITLAB_CLIENT_ID` / +/// `IVALDI_GITLAB_SCOPES`). +fn device_flow_config(host: &str) -> oauth_device::DeviceFlowConfig { + oauth_device::DeviceFlowConfig { + device_code_url: format!("{}{}", host, auth::GITLAB_DEVICE_AUTH_PATH), + token_url: format!("{}{}", host, auth::GITLAB_TOKEN_PATH), + client_id: client_id(), + scopes: scopes(), + } } /// Kick off the device flow. Returns the user code + URL the caller prints. pub fn request_device_code(host: &str) -> Result { - let url = format!("{}{}", host, auth::GITLAB_DEVICE_AUTH_PATH); - let body = format!("client_id={}&scope={}", client_id(), urlencode(&scopes())); - let resp = ureq::post(&url) - .header("Accept", "application/json") - .header("Content-Type", "application/x-www-form-urlencoded") - .send(body.as_bytes()) - .map_err(http_err)?; - if !resp.status().is_success() { - return Err(GitLabAuthError::Http(format!( - "device-code request returned HTTP {}", - resp.status().as_u16() - ))); - } - resp.into_body().read_json().map_err(http_err) + Ok(oauth_device::request_device_code(&device_flow_config( + host, + ))?) } /// Poll `/oauth/token` until the user completes (or denies) authorization. @@ -117,90 +91,17 @@ pub fn poll_for_token( device_code: &str, interval: u64, ) -> Result { - let url = format!("{}{}", host, auth::GITLAB_TOKEN_PATH); - let cid = client_id(); - let mut interval = interval.max(1); - loop { - thread::sleep(Duration::from_secs(interval)); - let body = format!( - "client_id={}&device_code={}&grant_type=urn:ietf:params:oauth:grant-type:device_code", - cid, device_code - ); - let resp = ureq::post(&url) - .header("Accept", "application/json") - .header("Content-Type", "application/x-www-form-urlencoded") - .send(body.as_bytes()) - .map_err(http_err)?; - let r: TokenPollResponse = resp.into_body().read_json().map_err(http_err)?; - if r.access_token.as_deref().is_some_and(|s| !s.is_empty()) { - return Ok(Token { - access_token: r.access_token.unwrap_or_default(), - token_type: r.token_type.unwrap_or_default(), - scope: r.scope.unwrap_or_default(), - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64, - }); - } - match r.error.as_deref() { - Some("authorization_pending") => continue, - Some("slow_down") => { - interval = interval.saturating_add(5); - continue; - } - Some("expired_token") => return Err(GitLabAuthError::Expired), - Some("access_denied") => return Err(GitLabAuthError::Denied), - Some(other) => { - return Err(GitLabAuthError::Other(format!( - "{}: {}", - other, - r.error_description.unwrap_or_default() - ))); - } - None => continue, - } - } -} - -/// Minimal application/x-www-form-urlencoded encoding for the few characters -/// we actually pass through (spaces in scope strings, mostly). We don't pull -/// in a URL crate just for this. -fn urlencode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) - } - _ => out.push_str(&format!("%{:02X}", b)), - } - } - out + Ok(oauth_device::poll_for_token( + &device_flow_config(host), + device_code, + interval, + )?) } #[cfg(test)] mod tests { use super::*; - #[test] - fn device_code_deserializes_with_optional_verification_uri_complete() { - let j = r#"{"device_code":"dc","user_code":"ABCD","verification_uri":"https://gitlab.com/oauth/device","verification_uri_complete":"https://gitlab.com/oauth/device?user_code=ABCD","expires_in":900,"interval":5}"#; - let r: DeviceCodeResponse = serde_json::from_str(j).unwrap(); - assert_eq!(r.user_code, "ABCD"); - assert_eq!( - r.browser_url(), - "https://gitlab.com/oauth/device?user_code=ABCD" - ); - } - - #[test] - fn browser_url_falls_back_to_verification_uri() { - let j = r#"{"device_code":"dc","user_code":"ABCD","verification_uri":"https://gitlab.com/oauth/device","expires_in":900,"interval":5}"#; - let r: DeviceCodeResponse = serde_json::from_str(j).unwrap(); - assert_eq!(r.browser_url(), "https://gitlab.com/oauth/device"); - } - #[test] fn resolve_host_prefers_explicit_then_env_then_default() { let r = resolve_host(Some("https://git.example.com/")); @@ -218,10 +119,4 @@ mod tests { == Some(r.clone()) ); } - - #[test] - fn urlencode_preserves_unreserved() { - assert_eq!(urlencode("read_user write_repository"), "read_user%20write_repository"); - assert_eq!(urlencode("api"), "api"); - } } diff --git a/src/identity.rs b/src/identity.rs index 7d0fa09..4aaa742 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -96,8 +96,8 @@ impl Identity { secret_hex: hex::encode(self.secret), public_hex: hex::encode(self.public), }; - let json = serde_json::to_vec_pretty(&stored) - .map_err(|e| IdentityError::Other(e.to_string()))?; + let json = + serde_json::to_vec_pretty(&stored).map_err(|e| IdentityError::Other(e.to_string()))?; // Atomic-ish write: temp file + rename, then chmod 0600 on Unix. let tmp = path.with_extension("tmp"); fs::write(&tmp, &json).map_err(IdentityError::Io)?; diff --git a/src/known_peers.rs b/src/known_peers.rs index 8bf1f19..573d7d4 100644 --- a/src/known_peers.rs +++ b/src/known_peers.rs @@ -63,10 +63,10 @@ impl KnownPeers { /// Open the default store at `~/.ivaldi/known_peers` (overridable via /// `IVALDI_KNOWN_PEERS`). pub fn default_for_user() -> Option { - if let Ok(p) = std::env::var("IVALDI_KNOWN_PEERS") { - if !p.is_empty() { - return Some(Self::new(PathBuf::from(p))); - } + if let Ok(p) = std::env::var("IVALDI_KNOWN_PEERS") + && !p.is_empty() + { + return Some(Self::new(PathBuf::from(p))); } let base = std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) @@ -142,18 +142,14 @@ impl KnownPeers { let pk = parts.next().ok_or_else(|| { KnownPeersError::Other(format!("line {}: missing pubkey", lineno + 1)) })?; - let pubkey = decode_pubkey(pk).map_err(|e| { - KnownPeersError::Other(format!("line {}: {}", lineno + 1, e)) - })?; + let pubkey = decode_pubkey(pk) + .map_err(|e| KnownPeersError::Other(format!("line {}: {}", lineno + 1, e)))?; out.insert(key.to_string(), pubkey); } Ok(out) } - fn write_map( - &self, - map: &BTreeMap, - ) -> Result<(), KnownPeersError> { + fn write_map(&self, map: &BTreeMap) -> Result<(), KnownPeersError> { if let Some(parent) = self.path.parent() { fs::create_dir_all(parent).map_err(KnownPeersError::Io)?; } @@ -269,10 +265,7 @@ mod tests { let dir = tempdir().unwrap(); let path = dir.path().join("known_peers"); let pubkey_hex = hex::encode(key(0x77)); - let content = format!( - "# top comment\n\nhost:9999 {} # tail\n\n", - pubkey_hex - ); + let content = format!("# top comment\n\nhost:9999 {} # tail\n\n", pubkey_hex); std::fs::write(&path, content).unwrap(); let kp = KnownPeers::new(path); assert_eq!(kp.lookup("host", 9999, &key(0x77)).unwrap(), Known::Match); diff --git a/src/lib.rs b/src/lib.rs index d5c6d47..e1c3322 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod atomic_io; pub mod auth; pub mod bisect; pub mod butterfly; @@ -23,12 +24,15 @@ pub mod identity; pub mod ignore; pub mod known_peers; pub mod leaf; +pub mod lock; pub mod log; pub mod logging; pub mod mmr; +pub mod oauth_device; pub mod p2p; pub mod pack; pub mod peers; +pub mod pick; pub mod portal; pub mod progress; pub mod remote; @@ -40,6 +44,7 @@ pub mod shift; pub mod ssh_transport; pub mod store; pub mod submodule; +pub mod switch_journal; pub mod sync; pub mod tags; pub mod timeline; diff --git a/src/lock.rs b/src/lock.rs new file mode 100644 index 0000000..8baf6e8 --- /dev/null +++ b/src/lock.rs @@ -0,0 +1,105 @@ +//! Process-level repository lock. +//! +//! redb serializes individual store transactions, but multi-step operations +//! (seal, timeline switch, fuse, ...) also touch plain files under +//! `.ivaldi/` (HEAD, staging, shelves) with no coordination. [`RepoLock`] +//! gives mutating commands an exclusive advisory `flock(2)` on +//! `.ivaldi/repo.lock` so two concurrent ivaldi processes can't interleave. +//! +//! The kernel releases the lock when the holding process exits — including +//! on crash — so a stale lock file is never a problem. (This is why an +//! `O_CREAT|O_EXCL` sentinel file was rejected.) Read-only commands take no +//! lock; they still serialize against writers via redb's own file lock. + +use std::fs; +use std::io::Write; +use std::path::Path; + +use rustix::fs::{FlockOperation, flock}; + +/// Held for the duration of a mutating command. The flock is released when +/// this struct is dropped (or the process dies). +#[derive(Debug)] +pub struct RepoLock { + _file: fs::File, +} + +#[derive(Debug, thiserror::Error)] +pub enum LockError { + #[error( + "another ivaldi process is operating on this repository \ + (lock held on .ivaldi/repo.lock). Wait for it to finish and retry." + )] + Contended, + #[error("I/O error acquiring repository lock: {0}")] + Io(#[from] std::io::Error), +} + +impl RepoLock { + /// Open/create `.ivaldi/repo.lock` and take a non-blocking exclusive lock. + pub fn acquire(ivaldi_dir: &Path) -> Result { + let path = ivaldi_dir.join("repo.lock"); + let mut file = fs::File::options() + .create(true) + .write(true) + .truncate(false) + .open(&path)?; + + flock(&file, FlockOperation::NonBlockingLockExclusive).map_err(|e| { + if e == rustix::io::Errno::WOULDBLOCK { + LockError::Contended + } else { + LockError::Io(std::io::Error::from(e)) + } + })?; + + // Diagnostic only — never read for correctness. Safe: we hold the lock. + let _ = file.set_len(0); + let _ = writeln!(file, "{}", std::process::id()); + + Ok(RepoLock { _file: file }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".ivaldi")).unwrap(); + dir + } + + #[test] + fn second_acquire_contends() { + let dir = setup(); + let ivaldi_dir = dir.path().join(".ivaldi"); + + // flock is per open file description, so two acquires in one process + // genuinely contend. + let first = RepoLock::acquire(&ivaldi_dir).unwrap(); + let second = RepoLock::acquire(&ivaldi_dir); + assert!(matches!(second, Err(LockError::Contended))); + let msg = second.unwrap_err().to_string(); + assert!(msg.contains("another ivaldi process")); + + drop(first); + RepoLock::acquire(&ivaldi_dir).unwrap(); + } + + #[test] + fn creates_lock_file() { + let dir = setup(); + let ivaldi_dir = dir.path().join(".ivaldi"); + let _lock = RepoLock::acquire(&ivaldi_dir).unwrap(); + assert!(ivaldi_dir.join("repo.lock").exists()); + } + + #[test] + fn missing_dir_errors() { + let dir = tempfile::tempdir().unwrap(); + let result = RepoLock::acquire(&dir.path().join("no-such-dir")); + assert!(matches!(result, Err(LockError::Io(_)))); + } +} diff --git a/src/mmr.rs b/src/mmr.rs index 6f8d800..950211b 100644 --- a/src/mmr.rs +++ b/src/mmr.rs @@ -140,7 +140,7 @@ impl Mmr { let mut pos_in_tree = leaf_idx; for sibling in &proof.siblings { - if pos_in_tree % 2 == 0 { + if pos_in_tree.is_multiple_of(2) { // current is left child current = compute_internal_hash(current, *sibling); } else { diff --git a/src/oauth_device.rs b/src/oauth_device.rs new file mode 100644 index 0000000..7f8d498 --- /dev/null +++ b/src/oauth_device.rs @@ -0,0 +1,287 @@ +//! Shared OAuth 2.0 Device Authorization Grant (RFC 8628) implementation. +//! +//! Both the GitHub (`src/github.rs`) and GitLab (`src/gitlab.rs`) device +//! flows are thin wrappers around this module: they build a +//! [`DeviceFlowConfig`] from their provider-specific endpoints/client ids and +//! map [`DeviceFlowError`] into their own error enums, keeping their public +//! signatures unchanged. + +use std::thread; +use std::time::Duration; + +use serde::Deserialize; + +use crate::auth::Token; + +/// Provider-specific endpoints and credentials for one device flow. +pub struct DeviceFlowConfig { + /// Full URL of the device-code endpoint (e.g. `.../login/device/code`). + pub device_code_url: String, + /// Full URL of the token endpoint polled for completion. + pub token_url: String, + /// OAuth application client id. + pub client_id: String, + /// Scope string in the provider's native format (url-encoded on send). + pub scopes: String, +} + +/// Errors from the device authorization flow. +#[derive(Debug, thiserror::Error)] +pub enum DeviceFlowError { + #[error("HTTP: {0}")] + Http(String), + #[error("authorization expired before completion — run `ivaldi auth login` again")] + Expired, + #[error("user denied the authorization request")] + Denied, + #[error("{0}")] + Other(String), +} + +fn http_err(e: impl std::fmt::Display) -> DeviceFlowError { + DeviceFlowError::Http(e.to_string()) +} + +/// Response from the device-code endpoint. +#[derive(Debug, Deserialize)] +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + /// Some providers (e.g. GitLab) return `verification_uri_complete` + /// (preferred URL to open in the browser — already includes the + /// user_code). + #[serde(default)] + pub verification_uri_complete: Option, + pub expires_in: u64, + pub interval: u64, +} + +impl DeviceCodeResponse { + /// The URL we should open in the browser. Prefers the `_complete` form + /// when present so the user doesn't have to paste the code. + pub fn browser_url(&self) -> &str { + self.verification_uri_complete + .as_deref() + .unwrap_or(&self.verification_uri) + } +} + +/// One response from the token endpoint while polling. +#[derive(Debug, Deserialize)] +pub struct TokenPollResponse { + pub access_token: Option, + pub token_type: Option, + pub scope: Option, + pub error: Option, + pub error_description: Option, +} + +/// Kick off the device flow. Returns the user code + URL the caller prints. +pub fn request_device_code(cfg: &DeviceFlowConfig) -> Result { + let body = format!( + "client_id={}&scope={}", + cfg.client_id, + urlencode(&cfg.scopes) + ); + let resp = ureq::post(&cfg.device_code_url) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .send(body.as_bytes()) + .map_err(http_err)?; + if !resp.status().is_success() { + return Err(DeviceFlowError::Http(format!( + "device-code request returned HTTP {}", + resp.status().as_u16() + ))); + } + resp.into_body().read_json().map_err(http_err) +} + +/// Poll the token endpoint until the user completes (or denies) +/// authorization. Per RFC 8628, `slow_down` increases the poll interval by +/// 5 seconds. +pub fn poll_for_token( + cfg: &DeviceFlowConfig, + device_code: &str, + interval_secs: u64, +) -> Result { + let mut interval = interval_secs.max(1); + loop { + thread::sleep(Duration::from_secs(interval)); + let body = format!( + "client_id={}&device_code={}&grant_type=urn:ietf:params:oauth:grant-type:device_code", + cfg.client_id, device_code + ); + let resp = ureq::post(&cfg.token_url) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .send(body.as_bytes()) + .map_err(http_err)?; + let r: TokenPollResponse = resp.into_body().read_json().map_err(http_err)?; + match decide_poll_outcome(r) { + PollOutcome::Token(token) => return Ok(token), + PollOutcome::Continue => continue, + PollOutcome::SlowDown => { + interval = interval.saturating_add(5); + continue; + } + PollOutcome::Fail(e) => return Err(e), + } + } +} + +/// What the poll loop should do after one token-endpoint response. +pub(crate) enum PollOutcome { + Token(Token), + Continue, + SlowDown, + Fail(DeviceFlowError), +} + +/// Pure decision logic for one poll response (unit-testable seam). +pub(crate) fn decide_poll_outcome(resp: TokenPollResponse) -> PollOutcome { + if let Some(access_token) = resp.access_token.filter(|t| !t.is_empty()) { + return PollOutcome::Token(Token { + access_token, + token_type: resp.token_type.unwrap_or_default(), + scope: resp.scope.unwrap_or_default(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0), + }); + } + match resp.error.as_deref() { + Some("authorization_pending") => PollOutcome::Continue, + Some("slow_down") => PollOutcome::SlowDown, + Some("expired_token") => PollOutcome::Fail(DeviceFlowError::Expired), + Some("access_denied") => PollOutcome::Fail(DeviceFlowError::Denied), + Some(other) => PollOutcome::Fail(DeviceFlowError::Other(format!( + "{}: {}", + other, + resp.error_description.unwrap_or_default() + ))), + // A "success" response with an empty/missing token and no error code + // is malformed — fail loudly rather than polling forever. + None => PollOutcome::Fail(DeviceFlowError::Other( + "token endpoint returned neither an access token nor an error".into(), + )), + } +} + +/// Minimal application/x-www-form-urlencoded encoding for the few characters +/// we actually pass through (spaces in scope strings, mostly). We don't pull +/// in a URL crate just for this. +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn poll_resp(json: &str) -> TokenPollResponse { + serde_json::from_str(json).unwrap() + } + + #[test] + fn device_code_deserializes_with_optional_verification_uri_complete() { + let j = r#"{"device_code":"dc","user_code":"ABCD","verification_uri":"https://gitlab.com/oauth/device","verification_uri_complete":"https://gitlab.com/oauth/device?user_code=ABCD","expires_in":900,"interval":5}"#; + let r: DeviceCodeResponse = serde_json::from_str(j).unwrap(); + assert_eq!(r.user_code, "ABCD"); + assert_eq!( + r.browser_url(), + "https://gitlab.com/oauth/device?user_code=ABCD" + ); + } + + #[test] + fn browser_url_falls_back_to_verification_uri() { + let j = r#"{"device_code":"dc","user_code":"ABCD","verification_uri":"https://gitlab.com/oauth/device","expires_in":900,"interval":5}"#; + let r: DeviceCodeResponse = serde_json::from_str(j).unwrap(); + assert_eq!(r.browser_url(), "https://gitlab.com/oauth/device"); + } + + #[test] + fn urlencode_preserves_unreserved() { + assert_eq!( + urlencode("read_user write_repository"), + "read_user%20write_repository" + ); + assert_eq!(urlencode("api"), "api"); + } + + #[test] + fn poll_outcome_pending_continues() { + let r = poll_resp(r#"{"error":"authorization_pending"}"#); + assert!(matches!(decide_poll_outcome(r), PollOutcome::Continue)); + } + + #[test] + fn poll_outcome_slow_down() { + let r = poll_resp(r#"{"error":"slow_down"}"#); + assert!(matches!(decide_poll_outcome(r), PollOutcome::SlowDown)); + } + + #[test] + fn poll_outcome_expired() { + let r = poll_resp(r#"{"error":"expired_token"}"#); + assert!(matches!( + decide_poll_outcome(r), + PollOutcome::Fail(DeviceFlowError::Expired) + )); + } + + #[test] + fn poll_outcome_denied() { + let r = poll_resp(r#"{"error":"access_denied"}"#); + assert!(matches!( + decide_poll_outcome(r), + PollOutcome::Fail(DeviceFlowError::Denied) + )); + } + + #[test] + fn poll_outcome_unknown_error_includes_description() { + let r = poll_resp(r#"{"error":"server_error","error_description":"boom"}"#); + match decide_poll_outcome(r) { + PollOutcome::Fail(DeviceFlowError::Other(msg)) => { + assert_eq!(msg, "server_error: boom"); + } + _ => panic!("expected Other failure"), + } + } + + #[test] + fn poll_outcome_success_builds_token() { + let r = poll_resp(r#"{"access_token":"tok123","token_type":"bearer","scope":"repo"}"#); + match decide_poll_outcome(r) { + PollOutcome::Token(t) => { + assert_eq!(t.access_token, "tok123"); + assert_eq!(t.token_type, "bearer"); + assert_eq!(t.scope, "repo"); + assert!(t.created_at > 0); + } + _ => panic!("expected token"), + } + } + + #[test] + fn poll_outcome_empty_token_is_failure() { + let r = poll_resp(r#"{"access_token":""}"#); + assert!(matches!( + decide_poll_outcome(r), + PollOutcome::Fail(DeviceFlowError::Other(_)) + )); + } +} diff --git a/src/p2p.rs b/src/p2p.rs index 5feba0e..7f51038 100644 --- a/src/p2p.rs +++ b/src/p2p.rs @@ -133,10 +133,7 @@ pub enum Message { /// Client → server: send everything reachable from `timeline`. The /// optional `have` list lets the client say "I already have these /// leaf-hashes" so the server can prune. - WantTimeline { - timeline: String, - have: Vec, - }, + WantTimeline { timeline: String, have: Vec }, /// Server → client: data payload(s). Multiple `Bundle` messages may be /// sent in sequence before a final `Done`. @@ -208,10 +205,7 @@ pub struct Channel { impl Channel { /// Initiator side. Performs Noise XX with the supplied static keypair. - pub fn connect( - addr: impl ToSocketAddrs, - identity: &Identity, - ) -> Result { + pub fn connect(addr: impl ToSocketAddrs, identity: &Identity) -> Result { let stream = TcpStream::connect(addr)?; stream.set_read_timeout(Some(Duration::from_secs(60)))?; stream.set_write_timeout(Some(Duration::from_secs(60)))?; @@ -240,8 +234,8 @@ impl Channel { /// Send one logical message. Encrypts via Noise, frames with a 4-byte /// big-endian length prefix. pub fn send(&mut self, msg: &Message) -> Result<(), P2pError> { - let payload = serde_json::to_vec(msg) - .map_err(|e| P2pError::Protocol(format!("encode: {}", e)))?; + let payload = + serde_json::to_vec(msg).map_err(|e| P2pError::Protocol(format!("encode: {}", e)))?; if payload.len() > MAX_FRAME { return Err(P2pError::Protocol(format!( "outbound message too large ({} > {})", @@ -587,7 +581,7 @@ fn handle_connection( serve_want(repo, &mut chan, &timeline, &have)?; } Message::PushStart { timeline } => { - serve_push(repo, &mut chan, &timeline, &peer_store)?; + serve_push(repo, &mut chan, &timeline, peer_store)?; } other => { chan.send(&Message::Error { @@ -630,13 +624,19 @@ fn serve_push( // Sanitize the sender label so it can't escape the `peers/` prefix. let sender_clean: String = sender .chars() - .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) .collect(); let landed_as = format!("peers/{}/{}", sender_clean, timeline); - let cas = FileCas::new(repo.ivaldi_dir.join("objects")) - .map_err(|e| P2pError::Io(e.to_string()))?; + let cas = + FileCas::new(repo.ivaldi_dir.join("objects")).map_err(|e| P2pError::Io(e.to_string()))?; let mut leaves_landed = 0usize; @@ -647,9 +647,8 @@ fn serve_push( // leaf's tree walk later). Bytes are content-addressed, // so duplicates are no-ops. for wb in blobs { - let raw = hex::decode(&wb.hash_hex).map_err(|e| { - P2pError::Protocol(format!("blob hash hex: {}", e)) - })?; + let raw = hex::decode(&wb.hash_hex) + .map_err(|e| P2pError::Protocol(format!("blob hash hex: {}", e)))?; let hash = crate::hash::B3Hash::from_slice(&raw) .ok_or_else(|| P2pError::Protocol("blob hash wrong length".into()))?; cas.put(hash, &wb.data) @@ -705,9 +704,7 @@ fn serve_want( let head = repo .get_timeline_head(timeline) .map_err(|e| P2pError::Protocol(e.to_string()))? - .ok_or_else(|| { - P2pError::Protocol(format!("unknown timeline '{}'", timeline)) - })?; + .ok_or_else(|| P2pError::Protocol(format!("unknown timeline '{}'", timeline)))?; // Walk the linear chain (prev_idx + merge parents) from head back, stopping // at any leaf whose blake3 the client already has. @@ -907,7 +904,7 @@ pub fn fetch_into_with_policy( return Err(P2pError::Protocol(format!( "unexpected response to ListTimelines: {:?}", other - ))) + ))); } } } @@ -926,9 +923,7 @@ pub fn fetch_into_with_policy( let mut leaves_imported = 0usize; let mut blobs_imported = 0usize; - let mut head_b3_hex: Option = None; - - loop { + let head_b3_hex: Option = loop { match chan.recv()? { Message::Bundle { leaves, blobs } => { for wl in leaves { @@ -941,18 +936,17 @@ pub fn fetch_into_with_policy( } } Message::Done { head_b3_hex: head } => { - head_b3_hex = Some(head); - break; + break Some(head); } Message::Error { message } => return Err(P2pError::Protocol(message)), other => { return Err(P2pError::Protocol(format!( "unexpected message: {:?}", other - ))) + ))); } } - } + }; chan.shutdown(); // Materialize the working tree from the imported head. @@ -964,11 +958,7 @@ pub fn fetch_into_with_policy( .get_leaf(head_idx) .map_err(|e| P2pError::Io(e.to_string()))? .ok_or_else(|| P2pError::Protocol("imported head leaf missing".into()))?; - let workspace = crate::workspace::Workspace::new( - &cas, - target_dir, - &target_dir.join(".ivaldi"), - ); + let workspace = crate::workspace::Workspace::new(&cas, target_dir, target_dir.join(".ivaldi")); workspace .materialize(head_leaf.tree_root) .map_err(|e| P2pError::Io(e.to_string()))?; @@ -998,14 +988,12 @@ fn enforce_tofu( remote: &[u8; crate::identity::KEY_LEN], policy: crate::known_peers::TofuPolicy, ) -> Result<(), P2pError> { - use crate::known_peers::{fingerprint, Known, KnownPeers, TofuPolicy}; + use crate::known_peers::{Known, KnownPeers, TofuPolicy, fingerprint}; let Some(store) = KnownPeers::default_for_user() else { // No HOME — fall through without TOFU. Identity files won't have // worked either; treat as "skipped" rather than fatal. - crate::logging::warn( - "no $HOME — skipping TOFU check; consider setting IVALDI_KNOWN_PEERS", - ); + crate::logging::warn("no $HOME — skipping TOFU check; consider setting IVALDI_KNOWN_PEERS"); return Ok(()); }; @@ -1037,10 +1025,7 @@ fn enforce_tofu( fingerprint(remote), ))), TofuPolicy::Prompt => { - eprintln!( - "First connection to {}:{}.", - url.host, url.port - ); + eprintln!("First connection to {}:{}.", url.host, url.port); eprintln!(" pubkey fingerprint: {}", fingerprint(remote)); eprint!("Trust this peer? [y/N] "); use std::io::Write; @@ -1049,8 +1034,7 @@ fn enforce_tofu( std::io::stdin() .read_line(&mut line) .map_err(|e| P2pError::Io(e.to_string()))?; - if line.trim().eq_ignore_ascii_case("y") - || line.trim().eq_ignore_ascii_case("yes") + if line.trim().eq_ignore_ascii_case("y") || line.trim().eq_ignore_ascii_case("yes") { store .record(&url.host, url.port, remote) @@ -1065,11 +1049,7 @@ fn enforce_tofu( } } -fn apply_leaf( - repo: &mut crate::repo::Repo, - timeline: &str, - wl: &WireLeaf, -) -> Result<(), P2pError> { +fn apply_leaf(repo: &mut crate::repo::Repo, timeline: &str, wl: &WireLeaf) -> Result<(), P2pError> { let leaf = crate::leaf::parse_leaf(&wl.canonical) .map_err(|e| P2pError::Protocol(format!("parse leaf: {}", e)))?; repo.commit_raw(leaf, timeline) @@ -1120,9 +1100,7 @@ pub fn push_to( let head_idx = repo .get_timeline_head(timeline) .map_err(|e| P2pError::Io(e.to_string()))? - .ok_or_else(|| { - P2pError::Protocol(format!("local timeline '{}' has no head", timeline)) - })?; + .ok_or_else(|| P2pError::Protocol(format!("local timeline '{}' has no head", timeline)))?; let mut chan = Channel::connect(url.socket_addr(), identity)?; enforce_tofu(url, &chan.remote_static, tofu)?; @@ -1156,8 +1134,8 @@ pub fn push_to( use crate::cas::{Cas, FileCas}; use crate::fsmerkle::FsStore; - let cas = FileCas::new(repo.ivaldi_dir.join("objects")) - .map_err(|e| P2pError::Io(e.to_string()))?; + let cas = + FileCas::new(repo.ivaldi_dir.join("objects")).map_err(|e| P2pError::Io(e.to_string()))?; let store = FsStore::new(&cas); let mut object_hashes: BTreeSet = BTreeSet::new(); @@ -1258,7 +1236,9 @@ mod tests { fn tofu_guard() -> std::sync::MutexGuard<'static, ()> { use std::sync::{Mutex, OnceLock}; static GATE: OnceLock> = OnceLock::new(); - GATE.get_or_init(|| Mutex::new(())).lock().unwrap_or_else(|e| e.into_inner()) + GATE.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) } /// Run `f` with `IVALDI_KNOWN_PEERS` pointing at a fresh tempfile. @@ -1349,8 +1329,7 @@ mod tests { crate::forge::forge(server_dir.path()).unwrap(); { let mut server_repo = crate::repo::Repo::open(server_dir.path()).unwrap(); - let cas = - crate::cas::FileCas::new(server_dir.path().join(".ivaldi/objects")).unwrap(); + let cas = crate::cas::FileCas::new(server_dir.path().join(".ivaldi/objects")).unwrap(); let store = crate::fsmerkle::FsStore::new(&cas); let (blob_hash, _) = store.put_blob(b"hello p2p").unwrap(); use crate::fsmerkle::{Entry, MODE_FILE, NodeKind}; @@ -1473,8 +1452,7 @@ mod tests { let bob_head_blake3; { let mut bob_repo = crate::repo::Repo::open(bob_dir.path()).unwrap(); - let cas = - crate::cas::FileCas::new(bob_dir.path().join(".ivaldi/objects")).unwrap(); + let cas = crate::cas::FileCas::new(bob_dir.path().join(".ivaldi/objects")).unwrap(); let store = crate::fsmerkle::FsStore::new(&cas); let (blob_hash, _) = store.put_blob(b"bob's contribution").unwrap(); use crate::fsmerkle::{Entry, MODE_FILE, NodeKind}; @@ -1540,12 +1518,12 @@ mod tests { // Inspect Alice's shared repo handle. Wait briefly for the // server worker to release the mutex after replying PushAccepted. for _ in 0..50 { - if let Ok(g) = alice_repo_arc.try_lock() { - if let Ok(Some(idx)) = g.get_timeline_head("peers/bob/main") { - let leaf = g.get_leaf(idx).unwrap().unwrap(); - assert_eq!(leaf.hash(), bob_head_blake3); - return; - } + if let Ok(g) = alice_repo_arc.try_lock() + && let Ok(Some(idx)) = g.get_timeline_head("peers/bob/main") + { + let leaf = g.get_leaf(idx).unwrap().unwrap(); + assert_eq!(leaf.hash(), bob_head_blake3); + return; } std::thread::sleep(std::time::Duration::from_millis(30)); } @@ -1562,8 +1540,7 @@ mod tests { crate::forge::forge(server_dir.path()).unwrap(); { let mut server_repo = crate::repo::Repo::open(server_dir.path()).unwrap(); - let cas = - crate::cas::FileCas::new(server_dir.path().join(".ivaldi/objects")).unwrap(); + let cas = crate::cas::FileCas::new(server_dir.path().join(".ivaldi/objects")).unwrap(); let store = crate::fsmerkle::FsStore::new(&cas); let (blob_hash, _) = store.put_blob(b"concurrent body").unwrap(); use crate::fsmerkle::{Entry, MODE_FILE, NodeKind}; diff --git a/src/pack.rs b/src/pack.rs index c166e87..180b4f6 100644 --- a/src/pack.rs +++ b/src/pack.rs @@ -34,6 +34,22 @@ const OP_INSERT: u8 = 1; /// Minimum savings ratio to use delta (25% savings required). const DELTA_MIN_SAVINGS: f64 = 0.25; +/// Read a 32-byte BLAKE3 hash at `off`, failing with `Corrupt` on short reads. +fn read_hash(data: &[u8], off: usize) -> Result { + data.get(off..off + 32) + .and_then(B3Hash::from_slice) + .ok_or(PackError::Corrupt) +} + +/// Read a little-endian u64 at `off`, failing with `Corrupt` on short reads. +fn read_u64_le(data: &[u8], off: usize) -> Result { + let bytes: [u8; 8] = data + .get(off..off + 8) + .and_then(|s| s.try_into().ok()) + .ok_or(PackError::Corrupt)?; + Ok(u64::from_le_bytes(bytes)) +} + /// A pack file containing multiple objects. pub struct PackWriter { entries: BTreeMap>, @@ -226,11 +242,12 @@ impl PackReader { pub fn total_objects(&self) -> usize { let mut count = 0; for pack_name in self.list_packs() { - if let Ok(data) = fs::read(self.pack_dir.join(&pack_name)) { - if data.len() >= 13 && &data[0..4] == PACK_MAGIC { - let entry_count = u64::from_le_bytes(data[5..13].try_into().unwrap_or([0; 8])); - count += entry_count as usize; - } + if let Ok(data) = fs::read(self.pack_dir.join(&pack_name)) + && data.len() >= 13 + && &data[0..4] == PACK_MAGIC + { + let entry_count = u64::from_le_bytes(data[5..13].try_into().unwrap_or([0; 8])); + count += entry_count as usize; } } count @@ -258,7 +275,7 @@ impl PackReader { continue; } let version = data[4]; - let entry_count = u64::from_le_bytes(data[5..13].try_into().unwrap_or([0; 8])) as usize; + let entry_count = read_u64_le(&data, 5)? as usize; match version { PACK_VERSION => { @@ -269,16 +286,9 @@ impl PackReader { for i in 0..entry_count { let idx_off = index_start + i * 48; - if idx_off + 48 > data.len() { - break; - } - let hash = B3Hash::from_slice(&data[idx_off..idx_off + 32]).unwrap(); - let offset = u64::from_le_bytes( - data[idx_off + 32..idx_off + 40].try_into().unwrap(), - ) as usize; - let size = u64::from_le_bytes( - data[idx_off + 40..idx_off + 48].try_into().unwrap(), - ) as usize; + let hash = read_hash(&data, idx_off)?; + let offset = read_u64_le(&data, idx_off + 32)? as usize; + let size = read_u64_le(&data, idx_off + 40)? as usize; let abs_offset = data_start + offset; if abs_offset + size <= data.len() { @@ -300,17 +310,10 @@ impl PackReader { Vec::with_capacity(entry_count); for i in 0..entry_count { let idx_off = index_start + i * 49; - if idx_off + 49 > data.len() { - break; - } - let hash = B3Hash::from_slice(&data[idx_off..idx_off + 32]).unwrap(); - let offset = u64::from_le_bytes( - data[idx_off + 32..idx_off + 40].try_into().unwrap(), - ) as usize; - let size = u64::from_le_bytes( - data[idx_off + 40..idx_off + 48].try_into().unwrap(), - ) as usize; - let etype = data[idx_off + 48]; + let hash = read_hash(&data, idx_off)?; + let offset = read_u64_le(&data, idx_off + 32)? as usize; + let size = read_u64_le(&data, idx_off + 40)? as usize; + let etype = *data.get(idx_off + 48).ok_or(PackError::Corrupt)?; entries.push((hash, offset, size, etype)); } @@ -361,7 +364,7 @@ impl PackReader { return Ok(None); } let version = data[4]; - let entry_count = u64::from_le_bytes(data[5..13].try_into().unwrap_or([0; 8])) as usize; + let entry_count = read_u64_le(data, 5)? as usize; match version { PACK_VERSION => { @@ -370,20 +373,13 @@ impl PackReader { for i in 0..entry_count { let idx_off = index_start + i * 48; - if idx_off + 48 > data.len() { - break; - } - let hash = B3Hash::from_slice(&data[idx_off..idx_off + 32]).unwrap(); + let hash = read_hash(data, idx_off)?; if hash != target_hash { continue; } - let offset = - u64::from_le_bytes(data[idx_off + 32..idx_off + 40].try_into().unwrap()) - as usize; - let size = - u64::from_le_bytes(data[idx_off + 40..idx_off + 48].try_into().unwrap()) - as usize; + let offset = read_u64_le(data, idx_off + 32)? as usize; + let size = read_u64_le(data, idx_off + 40)? as usize; let abs_offset = data_start + offset; if abs_offset + size > data.len() { return Err(PackError::Corrupt); @@ -399,17 +395,10 @@ impl PackReader { let mut entries: Vec<(B3Hash, usize, usize, u8)> = Vec::with_capacity(entry_count); for i in 0..entry_count { let idx_off = index_start + i * 49; - if idx_off + 49 > data.len() { - break; - } - let hash = B3Hash::from_slice(&data[idx_off..idx_off + 32]).unwrap(); - let offset = - u64::from_le_bytes(data[idx_off + 32..idx_off + 40].try_into().unwrap()) - as usize; - let size = - u64::from_le_bytes(data[idx_off + 40..idx_off + 48].try_into().unwrap()) - as usize; - let etype = data[idx_off + 48]; + let hash = read_hash(data, idx_off)?; + let offset = read_u64_le(data, idx_off + 32)? as usize; + let size = read_u64_le(data, idx_off + 40)? as usize; + let etype = *data.get(idx_off + 48).ok_or(PackError::Corrupt)?; entries.push((hash, offset, size, etype)); } @@ -456,7 +445,7 @@ impl PackReader { if raw.len() < 32 { return Err(PackError::Corrupt); } - let base_hash = B3Hash::from_slice(&raw[..32]).unwrap(); + let base_hash = read_hash(raw, 0)?; let delta_bytes = &raw[32..]; // Find base object @@ -506,21 +495,21 @@ pub fn compute_delta(base: &[u8], target: &[u8]) -> Vec { let mut best_len = 0usize; // Try to find a match in base - if ti + 4 <= target.len() { - if let Some(positions) = base_index.get(&target[ti..ti + 4]) { - for &pos in positions { - // Extend match as far as possible - let mut len = 0; - while pos + len < base.len() - && ti + len < target.len() - && base[pos + len] == target[ti + len] - { - len += 1; - } - if len > best_len { - best_len = len; - best_offset = pos; - } + if ti + 4 <= target.len() + && let Some(positions) = base_index.get(&target[ti..ti + 4]) + { + for &pos in positions { + // Extend match as far as possible + let mut len = 0; + while pos + len < base.len() + && ti + len < target.len() + && base[pos + len] == target[ti + len] + { + len += 1; + } + if len > best_len { + best_len = len; + best_offset = pos; } } } @@ -915,4 +904,45 @@ mod tests { let result = reader.get_object(B3Hash::digest(b"nonexistent")); assert!(result.is_err()); } + + #[test] + fn truncated_pack_index_is_corrupt() { + let dir = tempfile::tempdir().unwrap(); + let pack_dir = dir.path().join("packs"); + + let data1 = b"truncation test object"; + let hash1 = B3Hash::digest(data1); + let mut writer = PackWriter::new(); + writer.add(hash1, data1.to_vec()); + writer.write(&pack_dir).unwrap(); + + let reader = PackReader::new(&pack_dir); + let pack_name = reader.list_packs().remove(0); + let full = fs::read(pack_dir.join(&pack_name)).unwrap(); + + // Cut the buffer in the middle of the index region (after the + // 13-byte header but before the first 48-byte index entry ends). + let truncated = &full[..13 + 20]; + let result = reader.get_from_pack_data(truncated, hash1); + assert!(matches!(result, Err(PackError::Corrupt))); + } + + #[test] + fn untruncated_pack_data_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let pack_dir = dir.path().join("packs"); + + let data1 = b"roundtrip test object"; + let hash1 = B3Hash::digest(data1); + let mut writer = PackWriter::new(); + writer.add(hash1, data1.to_vec()); + writer.write(&pack_dir).unwrap(); + + let reader = PackReader::new(&pack_dir); + let pack_name = reader.list_packs().remove(0); + let full = fs::read(pack_dir.join(&pack_name)).unwrap(); + + let obj = reader.get_from_pack_data(&full, hash1).unwrap(); + assert_eq!(obj.as_deref(), Some(data1.as_slice())); + } } diff --git a/src/peers.rs b/src/peers.rs index 0363cee..99991a4 100644 --- a/src/peers.rs +++ b/src/peers.rs @@ -68,16 +68,21 @@ impl PeerStore { continue; } let mut parts = trimmed.split_whitespace(); - let key_hex = parts.next().ok_or_else(|| { - PeerError::Other(format!("line {}: missing pubkey", lineno + 1)) - })?; - let pubkey = decode_pubkey(key_hex).map_err(|e| { - PeerError::Other(format!("line {}: {}", lineno + 1, e)) - })?; + let key_hex = parts + .next() + .ok_or_else(|| PeerError::Other(format!("line {}: missing pubkey", lineno + 1)))?; + let pubkey = decode_pubkey(key_hex) + .map_err(|e| PeerError::Other(format!("line {}: {}", lineno + 1, e)))?; let name = parts.next().map(str::to_string); // Last entry for a given key wins, but preserve insertion order // for display. - by_key.insert(pubkey, PeerEntry { pubkey, name: name.clone() }); + by_key.insert( + pubkey, + PeerEntry { + pubkey, + name: name.clone(), + }, + ); out.push(PeerEntry { pubkey, name }); } // Dedup keeping the latest entry for each pubkey. diff --git a/src/pick.rs b/src/pick.rs new file mode 100644 index 0000000..af51e5d --- /dev/null +++ b/src/pick.rs @@ -0,0 +1,295 @@ +//! Shared three-way "apply a delta as a new seal" engine for `undo` and +//! `pluck` (cherry-pick). +//! +//! Both commands are the same operation with different inputs: run the fuse +//! engine with ours = the current head tree and a (base, theirs) pair that +//! encodes the delta to apply, then seal the merged tree as a plain +//! (non-merge) seal. +//! +//! - undo SEAL: base = SEAL's tree, theirs = SEAL's parent tree +//! - pluck SEAL: base = SEAL's parent tree, theirs = SEAL's tree +//! +//! The fuse engine works at file-hash granularity (no line-level merging), +//! so any file touched by both the delta and later history conflicts. In +//! that case the operation refuses and reports the paths — nothing is +//! committed and the working tree is untouched. + +use std::collections::BTreeMap; + +use crate::cas::Cas; +use crate::fsmerkle::{FsStore, NodeKind}; +use crate::fuse::{FuseEngine, Strategy}; +use crate::hash::B3Hash; +use crate::repo::{CommitResult, Repo}; + +/// Outcome of a three-way apply. +#[derive(Debug)] +pub enum ApplyOutcome { + /// A new seal was created with the merged tree. + Applied(CommitResult), + /// The delta touches files that later history also changed. + Conflicts(Vec), + /// The merged tree equals the current head tree — nothing to do. + NoChanges, +} + +/// Recursively collect `path → blob hash` for every file under a tree. +pub fn collect_tree_blobs( + store: &FsStore<'_>, + tree_hash: B3Hash, + prefix: &str, + files: &mut BTreeMap, +) -> Result<(), String> { + let tree = store.load_tree(tree_hash).map_err(|e| e.to_string())?; + for entry in &tree.entries { + let path = if prefix.is_empty() { + entry.name.clone() + } else { + format!("{}/{}", prefix, entry.name) + }; + match entry.kind { + NodeKind::Blob => { + files.insert(path, entry.hash); + } + NodeKind::Tree => { + collect_tree_blobs(store, entry.hash, &path, files)?; + } + } + } + Ok(()) +} + +/// File map for an optional tree root (`None` → empty map, e.g. the parent +/// of a timeline's first seal). +pub fn tree_files( + store: &FsStore<'_>, + tree: Option, +) -> Result, String> { + let mut files = BTreeMap::new(); + if let Some(root) = tree { + collect_tree_blobs(store, root, "", &mut files)?; + } + Ok(files) +} + +/// Three-way merge `(base → theirs)` onto the current head and seal the +/// result. Returns without committing on conflicts or when the merge is a +/// no-op. The caller materializes the new tree to the working directory. +pub fn three_way_seal( + repo: &mut Repo, + cas: &dyn Cas, + base: &BTreeMap, + theirs: &BTreeMap, + author: &str, + message: &str, +) -> Result { + let store = FsStore::new(cas); + + let timeline = repo.current_timeline().map_err(|e| e.to_string())?; + let head_tree = repo + .get_timeline_head(&timeline) + .map_err(|e| e.to_string())? + .and_then(|idx| repo.get_leaf(idx).ok().flatten()) + .map(|l| l.tree_root); + let ours = tree_files(&store, head_tree)?; + + let result = FuseEngine::fuse(&store, base, &ours, theirs, Strategy::Auto); + if !result.success { + return Ok(ApplyOutcome::Conflicts( + result.conflicts.into_iter().map(|c| c.path).collect(), + )); + } + if result.merged_files == ours { + return Ok(ApplyOutcome::NoChanges); + } + + let merged_tree = store + .build_tree_from_hash_map(&result.merged_files) + .map_err(|e| e.to_string())?; + let commit = repo + .commit(merged_tree, author, message) + .map_err(|e| e.to_string())?; + Ok(ApplyOutcome::Applied(commit)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cas::FileCas; + use crate::config::Config; + use crate::forge; + use std::path::Path; + + fn setup() -> (tempfile::TempDir, Repo, FileCas) { + let dir = tempfile::tempdir().unwrap(); + forge::forge(dir.path()).unwrap(); + let mut cfg = Config::new(); + cfg.set("user.name", "Test"); + cfg.set("user.email", "t@ivaldi.dev"); + cfg.save(&dir.path().join(".ivaldi/config")).unwrap(); + let cas = FileCas::new(dir.path().join(".ivaldi/objects")).unwrap(); + let repo = Repo::open(dir.path()).unwrap(); + (dir, repo, cas) + } + + /// Build and commit a tree from `path → content` pairs; returns the result. + fn seal_tree( + repo: &mut Repo, + cas: &FileCas, + files: &[(&str, &str)], + msg: &str, + ) -> CommitResult { + let store = FsStore::new(cas); + let mut map = BTreeMap::new(); + for (path, content) in files { + map.insert(path.to_string(), content.as_bytes().to_vec()); + } + let tree = store.build_tree_from_map(&map).unwrap(); + repo.commit(tree, "Test", msg).unwrap() + } + + fn head_files(repo: &Repo, cas: &FileCas, _work: &Path) -> BTreeMap { + let store = FsStore::new(cas); + let timeline = repo.current_timeline().unwrap(); + let tree = repo + .get_timeline_head(&timeline) + .unwrap() + .and_then(|idx| repo.get_leaf(idx).unwrap()) + .map(|l| l.tree_root); + tree_files(&store, tree).unwrap() + } + + fn leaf_tree_files(repo: &Repo, cas: &FileCas, idx: u64) -> BTreeMap { + let store = FsStore::new(cas); + let leaf = repo.get_leaf(idx).unwrap().unwrap(); + tree_files(&store, Some(leaf.tree_root)).unwrap() + } + + fn parent_tree_files(repo: &Repo, cas: &FileCas, idx: u64) -> BTreeMap { + let store = FsStore::new(cas); + let leaf = repo.get_leaf(idx).unwrap().unwrap(); + let parent_tree = if leaf.has_parent() { + repo.get_leaf(leaf.prev_idx).unwrap().map(|l| l.tree_root) + } else { + None + }; + tree_files(&store, parent_tree).unwrap() + } + + #[test] + fn undo_middle_seal_restores_prior_content() { + let (dir, mut repo, cas) = setup(); + seal_tree(&mut repo, &cas, &[("a.txt", "v1"), ("b.txt", "b")], "C1"); + let c2 = seal_tree(&mut repo, &cas, &[("a.txt", "v2"), ("b.txt", "b")], "C2"); + seal_tree( + &mut repo, + &cas, + &[("a.txt", "v2"), ("b.txt", "b"), ("c.txt", "c")], + "C3", + ); + + // Undo C2: base = C2's tree, theirs = C1's tree. + let base = leaf_tree_files(&repo, &cas, c2.index); + let theirs = parent_tree_files(&repo, &cas, c2.index); + let outcome = three_way_seal(&mut repo, &cas, &base, &theirs, "Test", "Undo C2").unwrap(); + + match outcome { + ApplyOutcome::Applied(_) => {} + other => panic!("expected Applied, got {:?}", other), + } + let files = head_files(&repo, &cas, dir.path()); + let store = FsStore::new(&cas); + let (_, content) = store.load_blob(files["a.txt"]).unwrap(); + assert_eq!(content, b"v1"); + // C3's addition is untouched. + assert!(files.contains_key("c.txt")); + } + + #[test] + fn undo_first_seal_deletes_its_files() { + let (dir, mut repo, cas) = setup(); + let c1 = seal_tree(&mut repo, &cas, &[("a.txt", "v1")], "C1"); + seal_tree(&mut repo, &cas, &[("a.txt", "v1"), ("b.txt", "b")], "C2"); + + let base = leaf_tree_files(&repo, &cas, c1.index); + let theirs = parent_tree_files(&repo, &cas, c1.index); // empty + let outcome = three_way_seal(&mut repo, &cas, &base, &theirs, "Test", "Undo C1").unwrap(); + + assert!(matches!(outcome, ApplyOutcome::Applied(_))); + let files = head_files(&repo, &cas, dir.path()); + assert!(!files.contains_key("a.txt")); + assert!(files.contains_key("b.txt")); + } + + #[test] + fn conflicting_undo_is_refused() { + let (_dir, mut repo, cas) = setup(); + seal_tree(&mut repo, &cas, &[("a.txt", "v1")], "C1"); + let c2 = seal_tree(&mut repo, &cas, &[("a.txt", "v2")], "C2"); + // C3 also touches a.txt → undoing C2 conflicts. + seal_tree(&mut repo, &cas, &[("a.txt", "v3")], "C3"); + + let base = leaf_tree_files(&repo, &cas, c2.index); + let theirs = parent_tree_files(&repo, &cas, c2.index); + let outcome = three_way_seal(&mut repo, &cas, &base, &theirs, "Test", "Undo C2").unwrap(); + + match outcome { + ApplyOutcome::Conflicts(paths) => assert_eq!(paths, vec!["a.txt".to_string()]), + other => panic!("expected Conflicts, got {:?}", other), + } + // Nothing committed. + assert_eq!(repo.walk_history("main").unwrap().len(), 3); + } + + #[test] + fn pluck_applies_only_the_delta() { + let (dir, mut repo, cas) = setup(); + seal_tree(&mut repo, &cas, &[("a.txt", "base")], "C1"); + + // Build the picked seal on a side timeline. + repo.create_timeline("side", None).unwrap(); + repo.switch_timeline("side").unwrap(); + let picked = seal_tree( + &mut repo, + &cas, + &[("a.txt", "base"), ("fix.txt", "the fix")], + "Fix", + ); + seal_tree( + &mut repo, + &cas, + &[ + ("a.txt", "base"), + ("fix.txt", "the fix"), + ("extra.txt", "x"), + ], + "Extra", + ); + repo.switch_timeline("main").unwrap(); + + // Pluck: base = picked's parent tree, theirs = picked's tree. + let base = parent_tree_files(&repo, &cas, picked.index); + let theirs = leaf_tree_files(&repo, &cas, picked.index); + let outcome = three_way_seal(&mut repo, &cas, &base, &theirs, "Test", "Fix").unwrap(); + + assert!(matches!(outcome, ApplyOutcome::Applied(_))); + let files = head_files(&repo, &cas, dir.path()); + assert!(files.contains_key("fix.txt")); + // The later "Extra" seal's file did NOT come along. + assert!(!files.contains_key("extra.txt")); + } + + #[test] + fn pluck_already_applied_is_noop() { + let (_dir, mut repo, cas) = setup(); + seal_tree(&mut repo, &cas, &[("a.txt", "v1")], "C1"); + let c2 = seal_tree(&mut repo, &cas, &[("a.txt", "v1"), ("b.txt", "b")], "C2"); + + // Plucking C2 onto a head that already contains it. + let base = parent_tree_files(&repo, &cas, c2.index); + let theirs = leaf_tree_files(&repo, &cas, c2.index); + let outcome = three_way_seal(&mut repo, &cas, &base, &theirs, "Test", "again").unwrap(); + assert!(matches!(outcome, ApplyOutcome::NoChanges)); + assert_eq!(repo.walk_history("main").unwrap().len(), 2); + } +} diff --git a/src/portal.rs b/src/portal.rs index f5a9287..9f80074 100644 --- a/src/portal.rs +++ b/src/portal.rs @@ -217,9 +217,7 @@ fn extract_host_and_path(raw: &str) -> Result<(Option, String), RepoSpec // ssh://git@host/owner/repo if let Some(rest) = raw.strip_prefix("ssh://") { let after_user = rest.splitn(2, '@').last().unwrap_or(rest); - let (host, path) = after_user - .split_once('/') - .ok_or(RepoSpecError::Invalid)?; + let (host, path) = after_user.split_once('/').ok_or(RepoSpecError::Invalid)?; return Ok((Some(host.to_string()), path.to_string())); } // git@host:owner/repo diff --git a/src/remote.rs b/src/remote.rs index 41963ba..06720b4 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -130,11 +130,11 @@ impl HashMapping { if line.is_empty() { continue; } - if let Some((sha1, blake3_hex)) = line.split_once(' ') { - if let Some(blake3) = B3Hash::from_hex(blake3_hex) { - self.sha1_to_blake3.insert(sha1.to_string(), blake3); - self.blake3_to_sha1.insert(blake3, sha1.to_string()); - } + if let Some((sha1, blake3_hex)) = line.split_once(' ') + && let Some(blake3) = B3Hash::from_hex(blake3_hex) + { + self.sha1_to_blake3.insert(sha1.to_string(), blake3); + self.blake3_to_sha1.insert(blake3, sha1.to_string()); } } } diff --git a/src/repo.rs b/src/repo.rs index 4468e25..d3e43cd 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -93,42 +93,47 @@ impl Repo { let mut new_leaf = Leaf::new(tree_root, &timeline, author, now, message); new_leaf.prev_idx = prev_idx; - // Compute hash and seal name - let leaf_hash = new_leaf.hash(); - let seal_name = seal::generate_seal_name(leaf_hash); - - // Persist leaf canonical bytes - let canonical = new_leaf.canonical_bytes(); - let idx = self.mmr.size(); - self.store - .put_leaf(idx, &canonical) - .map_err(RepoError::Store)?; - - // Append to in-memory MMR - let (leaf_idx, root) = self.mmr.append_leaf(new_leaf); - - // Update timeline head - self.store - .set_timeline_head(&timeline, leaf_idx) - .map_err(RepoError::Store)?; + // Delegate to commit_raw so all store writes (leaf, timeline head, + // seal mapping, mmr size) land in one transaction — a crash can never + // leave a leaf without its head pointer. Note commit_raw appends to + // the in-memory MMR before the store transaction; if the transaction + // fails the in-memory MMR is one leaf ahead, which is fine because + // every caller propagates the error and the process exits. + self.commit_raw(new_leaf, &timeline) + } - // Store seal name mapping - self.store - .put_seal_name(&seal_name, leaf_hash) - .map_err(RepoError::Store)?; + /// Replace the head seal of the current timeline (`reseal`). + /// + /// Appends a new leaf whose `prev_idx` is the old head's parent (so the + /// old head drops out of the chain) and moves the timeline head to it — + /// the same orphaning mechanism `weld` uses; the old leaf stays + /// recoverable via `travel --all`. `merge_idxs` are copied from the old + /// head so resealing a merge seal preserves merge topology. + pub fn reseal_head( + &mut self, + tree_root: B3Hash, + author: &str, + message: &str, + ) -> Result { + let timeline = self.current_timeline()?; + let head_idx = self + .store + .get_timeline_head(&timeline) + .map_err(RepoError::Store)? + .ok_or_else(|| RepoError::Other("nothing to reseal: timeline has no seals".into()))?; + let old_leaf = self + .get_leaf(head_idx)? + .ok_or_else(|| RepoError::Other(format!("corrupt head: leaf {} missing", head_idx)))?; - // Store MMR size - self.store - .set_meta("mmr.size", &self.mmr.size().to_string()) - .map_err(RepoError::Store)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; - Ok(CommitResult { - index: leaf_idx, - hash: leaf_hash, - seal_name, - root, - timeline, - }) + let mut new_leaf = Leaf::new(tree_root, &timeline, author, now, message); + new_leaf.prev_idx = old_leaf.prev_idx; + new_leaf.merge_idxs = old_leaf.merge_idxs.clone(); + self.commit_raw(new_leaf, &timeline) } /// Create a raw commit (seal) with a pre-built Leaf on a specific timeline. @@ -342,10 +347,8 @@ impl Repo { /// Walk commit history from a timeline head backwards. /// Walk the timeline's history along `prev_idx` only (first-parent /// view). Used internally by `walk_history` and `walk_history_dag`. - fn walk_history_first_parent( - &self, - timeline: &str, - ) -> Result, RepoError> { + #[cfg(test)] + fn walk_history_first_parent(&self, timeline: &str) -> Result, RepoError> { let head_idx = match self.get_timeline_head(timeline)? { Some(idx) => idx, None => return Ok(Vec::new()), @@ -424,16 +427,13 @@ impl Repo { } } // Newest-first to match walk_history. - entries.sort_by(|a, b| b.index.cmp(&a.index)); + entries.sort_by_key(|e| std::cmp::Reverse(e.index)); Ok(entries) } /// Full-DAG walk reachable from a timeline's head, sorted newest-first /// by MMR index (which is monotonic in commit creation order). - pub fn walk_history_dag( - &self, - timeline: &str, - ) -> Result, RepoError> { + pub fn walk_history_dag(&self, timeline: &str) -> Result, RepoError> { use std::collections::{BTreeSet, VecDeque}; let head_idx = match self.get_timeline_head(timeline)? { @@ -472,7 +472,7 @@ impl Repo { } } // Newest-first: higher MMR index = newer commit. - entries.sort_by(|a, b| b.index.cmp(&a.index)); + entries.sort_by_key(|e| std::cmp::Reverse(e.index)); Ok(entries) } @@ -529,6 +529,31 @@ impl Repo { Ok(best) } + /// True if `ancestor_idx` is reachable from `head_idx` (inclusive), + /// following both chain parents and merge parents. + pub fn is_ancestor(&self, ancestor_idx: u64, head_idx: u64) -> Result { + use std::collections::{BTreeSet, VecDeque}; + let mut visited: BTreeSet = BTreeSet::new(); + let mut q: VecDeque = VecDeque::new(); + q.push_back(head_idx); + while let Some(idx) = q.pop_front() { + if !visited.insert(idx) { + continue; + } + if idx == ancestor_idx { + return Ok(true); + } + if let Some(leaf) = self.get_leaf(idx)? { + for p in leaf.all_parents() { + if !visited.contains(&p) { + q.push_back(p); + } + } + } + } + Ok(false) + } + /// Get the seal name for a hash. pub fn get_seal_name(&self, hash: B3Hash) -> Result, RepoError> { self.store @@ -537,6 +562,9 @@ impl Repo { } /// Resolve a seal name or hash prefix to a leaf index. + /// + /// An ambiguous name prefix (multiple seal names match) is an error + /// listing the candidates, not a silent fall-through. pub fn resolve_seal(&self, query: &str) -> Result, RepoError> { // Try seal name prefix match let matches = self @@ -544,14 +572,25 @@ impl Repo { .find_seal_names_by_prefix(query) .map_err(RepoError::Store)?; - if matches.len() == 1 { - if let Some(hash) = self + if matches.len() > 1 { + let mut shown: Vec = matches.iter().take(5).cloned().collect(); + if matches.len() > shown.len() { + shown.push(format!("... ({} total)", matches.len())); + } + return Err(RepoError::Other(format!( + "ambiguous seal name '{}': matches {}", + query, + shown.join(", ") + ))); + } + + if matches.len() == 1 + && let Some(hash) = self .store .get_hash_by_seal_name(&matches[0]) .map_err(RepoError::Store)? - { - return self.find_leaf_by_hash(hash); - } + { + return self.find_leaf_by_hash(hash); } // Try hash prefix match @@ -571,10 +610,10 @@ impl Repo { fn find_leaf_by_hash(&self, hash: B3Hash) -> Result, RepoError> { let count = self.mmr.size(); for idx in 0..count { - if let Some(leaf) = self.get_leaf(idx)? { - if leaf.hash() == hash { - return Ok(Some((idx, leaf))); - } + if let Some(leaf) = self.get_leaf(idx)? + && leaf.hash() == hash + { + return Ok(Some((idx, leaf))); } } Ok(None) @@ -648,7 +687,8 @@ impl Repo { let path = self.ivaldi_dir.join("MERGE_STATE"); let data = serde_json::to_string_pretty(state).map_err(|e| RepoError::Other(e.to_string()))?; - std::fs::write(&path, data).map_err(|e| RepoError::Other(e.to_string()))?; + crate::atomic_io::atomic_write(&path, data.as_bytes()) + .map_err(|e| RepoError::Other(e.to_string()))?; Ok(()) } @@ -709,7 +749,8 @@ impl Repo { let path = dir.join(format!("{}.json", review.id)); let data = serde_json::to_string_pretty(review).map_err(|e| RepoError::Other(e.to_string()))?; - std::fs::write(&path, data).map_err(|e| RepoError::Other(e.to_string()))?; + crate::atomic_io::atomic_write(&path, data.as_bytes()) + .map_err(|e| RepoError::Other(e.to_string()))?; Ok(()) } @@ -917,6 +958,169 @@ mod tests { assert_eq!(leaf.prev_idx, NO_PARENT); } + #[test] + fn load_merge_state_rejects_corrupt_json() { + let (dir, repo) = setup_repo(); + std::fs::write(dir.path().join(".ivaldi/MERGE_STATE"), "{not json").unwrap(); + assert!(repo.load_merge_state().is_err()); + } + + #[test] + fn resolve_seal_ambiguous_prefix_errors() { + let (_dir, mut repo) = setup_repo(); + let mut names = Vec::new(); + // Commit until two seal names share a first letter (names are + // generated from hashes, so a handful of commits suffices). + for i in 0..30 { + let r = repo + .commit(B3Hash::digest(format!("t{}", i).as_bytes()), "A", "c") + .unwrap(); + names.push(r.seal_name); + let mut counts = std::collections::BTreeMap::new(); + for n in &names { + *counts.entry(n.chars().next().unwrap()).or_insert(0) += 1; + } + if let Some((c, _)) = counts.iter().find(|(_, v)| **v > 1) { + let err = repo.resolve_seal(&c.to_string()).unwrap_err(); + assert!(err.to_string().contains("ambiguous seal name")); + return; + } + } + panic!("no ambiguous prefix arose in 30 commits"); + } + + #[test] + fn is_ancestor_follows_chain_and_merges() { + let (_dir, mut repo) = setup_repo(); + let r1 = repo.commit(B3Hash::digest(b"t1"), "A", "C1").unwrap(); + let r2 = repo.commit(B3Hash::digest(b"t2"), "A", "C2").unwrap(); + let r3 = repo.commit(B3Hash::digest(b"t3"), "A", "C3").unwrap(); + + assert!(repo.is_ancestor(r1.index, r3.index).unwrap()); + assert!(repo.is_ancestor(r3.index, r3.index).unwrap()); + assert!(!repo.is_ancestor(r3.index, r1.index).unwrap()); + assert!(!repo.is_ancestor(r2.index, r1.index).unwrap()); + } + + #[test] + fn head_can_move_backwards_for_reset() { + let (_dir, mut repo) = setup_repo(); + let r1 = repo.commit(B3Hash::digest(b"t1"), "A", "C1").unwrap(); + repo.commit(B3Hash::digest(b"t2"), "A", "C2").unwrap(); + let r3 = repo.commit(B3Hash::digest(b"t3"), "A", "C3").unwrap(); + + repo.set_timeline_head("main", r1.index).unwrap(); + assert_eq!(repo.walk_history("main").unwrap().len(), 1); + // Orphaned seals remain present in the MMR. + assert!(repo.get_leaf(r3.index).unwrap().is_some()); + } + + #[test] + fn reseal_head_message_only_preserves_tree_and_parent() { + let (_dir, mut repo) = setup_repo(); + let tree = B3Hash::digest(b"t1"); + repo.commit(tree, "A", "First").unwrap(); + let r2 = repo.commit(B3Hash::digest(b"t2"), "A", "Second").unwrap(); + + let amended = repo + .reseal_head(B3Hash::digest(b"t2"), "A", "Better msg") + .unwrap(); + assert_ne!(amended.hash, r2.hash); + + let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); + assert_eq!(head_idx, amended.index); + let leaf = repo.get_leaf(head_idx).unwrap().unwrap(); + assert_eq!(leaf.message, "Better msg"); + assert_eq!(leaf.tree_root, B3Hash::digest(b"t2")); + // Parent skips the replaced seal and points at "First". + assert_eq!(leaf.prev_idx, 0); + // Old leaf is orphaned but still present. + assert!(repo.get_leaf(r2.index).unwrap().is_some()); + // History shows exactly two seals. + assert_eq!(repo.walk_history("main").unwrap().len(), 2); + } + + #[test] + fn amend_first_seal_keeps_no_parent() { + let (_dir, mut repo) = setup_repo(); + repo.commit(B3Hash::digest(b"t1"), "A", "First").unwrap(); + let amended = repo + .reseal_head(B3Hash::digest(b"t1b"), "A", "First v2") + .unwrap(); + let leaf = repo.get_leaf(amended.index).unwrap().unwrap(); + assert_eq!(leaf.prev_idx, NO_PARENT); + assert_eq!(repo.walk_history("main").unwrap().len(), 1); + } + + #[test] + fn amend_merge_head_preserves_merge_idxs() { + let (_dir, mut repo) = setup_repo(); + repo.commit(B3Hash::digest(b"base"), "A", "Base").unwrap(); + let side = repo.commit(B3Hash::digest(b"side"), "A", "Side").unwrap(); + + let mut merge_leaf = Leaf::new(B3Hash::digest(b"merged"), "main", "A", 1, "Merge"); + merge_leaf.prev_idx = 0; + merge_leaf.merge_idxs = vec![side.index]; + repo.commit_raw(merge_leaf, "main").unwrap(); + + let amended = repo + .reseal_head(B3Hash::digest(b"merged2"), "A", "Merge v2") + .unwrap(); + let leaf = repo.get_leaf(amended.index).unwrap().unwrap(); + assert_eq!(leaf.merge_idxs, vec![side.index]); + assert_eq!(leaf.prev_idx, 0); + } + + #[test] + fn amend_empty_timeline_errors() { + let (_dir, mut repo) = setup_repo(); + let err = repo + .reseal_head(B3Hash::digest(b"t"), "A", "msg") + .unwrap_err(); + assert!(err.to_string().contains("nothing to reseal")); + } + + #[test] + fn commit_equivalent_to_commit_raw() { + // commit() delegates to commit_raw(); the persisted store state must + // be identical to a hand-built leaf committed via commit_raw(). + let (_dir, mut repo_a) = setup_repo(); + let (_dir_b, mut repo_b) = setup_repo(); + + let tree = B3Hash::digest(b"equiv tree"); + let ra = repo_a + .commit(tree, "Alice ", "Equiv test") + .unwrap(); + + let leaf_a = repo_a.get_leaf(ra.index).unwrap().unwrap(); + let mut leaf = Leaf::new( + tree, + "main", + "Alice ", + leaf_a.time_unix, + "Equiv test", + ); + leaf.prev_idx = NO_PARENT; + let rb = repo_b.commit_raw(leaf, "main").unwrap(); + + assert_eq!(ra.index, rb.index); + assert_eq!(ra.hash, rb.hash); + assert_eq!(ra.seal_name, rb.seal_name); + assert_eq!(ra.timeline, rb.timeline); + assert_eq!( + repo_a.store.get_timeline_head("main").unwrap(), + repo_b.store.get_timeline_head("main").unwrap() + ); + assert_eq!( + repo_a.store.get_meta("mmr.size").unwrap(), + repo_b.store.get_meta("mmr.size").unwrap() + ); + assert_eq!( + repo_a.store.get_leaf(ra.index).unwrap(), + repo_b.store.get_leaf(rb.index).unwrap() + ); + } + #[test] fn commit_chain() { let (_dir, mut repo) = setup_repo(); @@ -1229,24 +1433,13 @@ mod tests { let _ = a; // b is a sibling of a — give it `prev_idx = r` directly via commit_raw. - let mut b_leaf = crate::leaf::Leaf::new( - B3Hash::digest(b"b"), - "main", - "A", - 42, - "b (sibling)", - ); + let mut b_leaf = + crate::leaf::Leaf::new(B3Hash::digest(b"b"), "main", "A", 42, "b (sibling)"); b_leaf.prev_idx = r.index; let b = repo.commit_raw(b_leaf, "main").unwrap(); // m is a merge: prev = current head (b), merge parent = a. - let mut m_leaf = crate::leaf::Leaf::new( - B3Hash::digest(b"m"), - "main", - "A", - 43, - "merge", - ); + let mut m_leaf = crate::leaf::Leaf::new(B3Hash::digest(b"m"), "main", "A", 43, "merge"); m_leaf.prev_idx = b.index; m_leaf.merge_idxs = vec![a.index]; let m = repo.commit_raw(m_leaf, "main").unwrap(); diff --git a/src/review.rs b/src/review.rs index c5817c1..6eb7c8c 100644 --- a/src/review.rs +++ b/src/review.rs @@ -9,7 +9,6 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::cas::FileCas; use crate::config; use crate::fsmerkle::{self, FsStore, NodeKind}; use crate::fuse::{FuseEngine, Strategy}; @@ -38,18 +37,22 @@ impl std::fmt::Display for ReviewStatus { } } -impl ReviewStatus { - pub fn from_str(s: &str) -> Option { +impl std::str::FromStr for ReviewStatus { + type Err = (); + + fn from_str(s: &str) -> Result { match s { - "open" => Some(Self::Open), - "approved" => Some(Self::Approved), - "changes-requested" => Some(Self::ChangesRequested), - "merged" => Some(Self::Merged), - "closed" => Some(Self::Closed), - _ => None, + "open" => Ok(Self::Open), + "approved" => Ok(Self::Approved), + "changes-requested" => Ok(Self::ChangesRequested), + "merged" => Ok(Self::Merged), + "closed" => Ok(Self::Closed), + _ => Err(()), } } +} +impl ReviewStatus { pub fn symbol(self) -> &'static str { match self { ReviewStatus::Open => "O", @@ -184,7 +187,7 @@ pub fn list_reviews(repo: &Repo, filter: &ReviewFilter) -> Result, R if let Some(status) = filter.status { reviews.retain(|r| r.status == status); } - reviews.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + reviews.sort_by_key(|r| std::cmp::Reverse(r.updated_at)); Ok(reviews) } @@ -290,7 +293,10 @@ pub fn merge_review(repo: &mut Repo, review_id: u64) -> Result() + .unwrap_or(Strategy::Auto); // Get current head trees for both timelines let source_head = repo @@ -533,18 +539,27 @@ mod tests { #[test] fn review_status_from_str() { - assert_eq!(ReviewStatus::from_str("open"), Some(ReviewStatus::Open)); assert_eq!( - ReviewStatus::from_str("approved"), + "open".parse::().ok(), + Some(ReviewStatus::Open) + ); + assert_eq!( + "approved".parse::().ok(), Some(ReviewStatus::Approved) ); assert_eq!( - ReviewStatus::from_str("changes-requested"), + "changes-requested".parse::().ok(), Some(ReviewStatus::ChangesRequested) ); - assert_eq!(ReviewStatus::from_str("merged"), Some(ReviewStatus::Merged)); - assert_eq!(ReviewStatus::from_str("closed"), Some(ReviewStatus::Closed)); - assert_eq!(ReviewStatus::from_str("invalid"), None); + assert_eq!( + "merged".parse::().ok(), + Some(ReviewStatus::Merged) + ); + assert_eq!( + "closed".parse::().ok(), + Some(ReviewStatus::Closed) + ); + assert_eq!("invalid".parse::().ok(), None); } #[test] diff --git a/src/seal.rs b/src/seal.rs index c412bd3..af280a9 100644 --- a/src/seal.rs +++ b/src/seal.rs @@ -218,7 +218,7 @@ pub fn matches_seal_name(full_name: &str, query: &str) -> bool { return true; } // Match partial name (prefix of the word portion) - let word_part = full_name.rsplitn(2, '-').nth(1).unwrap_or(""); + let word_part = full_name.rsplit_once('-').map(|x| x.0).unwrap_or(""); word_part.starts_with(query) || full_name.starts_with(query) } diff --git a/src/shelf.rs b/src/shelf.rs index 6b16fd7..0f804a2 100644 --- a/src/shelf.rs +++ b/src/shelf.rs @@ -92,7 +92,8 @@ impl ShelfManager { } } - fs::write(&path, lines.join("\n")).map_err(ShelfError::Io)?; + crate::atomic_io::atomic_write(&path, lines.join("\n").as_bytes()) + .map_err(ShelfError::Io)?; Ok(()) } @@ -119,28 +120,28 @@ impl ShelfManager { } else if let Some(rest) = line.strip_prefix("created_at ") { shelf.created_at = rest.parse().unwrap_or(0); } else if let Some(rest) = line.strip_prefix("staged ") { - if let Some((hash_str, path)) = rest.split_once(' ') { - if let Some(hash) = B3Hash::from_hex(hash_str) { - shelf.staged_files.insert(path.to_string(), hash); - } + if let Some((hash_str, path)) = rest.split_once(' ') + && let Some(hash) = B3Hash::from_hex(hash_str) + { + shelf.staged_files.insert(path.to_string(), hash); } } else if let Some(rest) = line.strip_prefix("modified ") { - if let Some((hash_str, path)) = rest.split_once(' ') { - if let Some(hash) = B3Hash::from_hex(hash_str) { - shelf.workspace_changes.push(WorkspaceChange::Modified { - path: path.to_string(), - hash, - }); - } + if let Some((hash_str, path)) = rest.split_once(' ') + && let Some(hash) = B3Hash::from_hex(hash_str) + { + shelf.workspace_changes.push(WorkspaceChange::Modified { + path: path.to_string(), + hash, + }); } } else if let Some(rest) = line.strip_prefix("untracked ") { - if let Some((hash_str, path)) = rest.split_once(' ') { - if let Some(hash) = B3Hash::from_hex(hash_str) { - shelf.workspace_changes.push(WorkspaceChange::Untracked { - path: path.to_string(), - hash, - }); - } + if let Some((hash_str, path)) = rest.split_once(' ') + && let Some(hash) = B3Hash::from_hex(hash_str) + { + shelf.workspace_changes.push(WorkspaceChange::Untracked { + path: path.to_string(), + hash, + }); } } else if let Some(rest) = line.strip_prefix("deleted ") { shelf.workspace_changes.push(WorkspaceChange::Deleted { @@ -210,6 +211,23 @@ mod tests { (dir, mgr) } + #[test] + fn load_tolerates_truncated_file() { + // A truncated shelf file (crash mid-write, pre-atomic-write era) + // must parse the intact lines without panicking. + let (_dir, mgr) = setup(); + let hash = B3Hash::digest(b"content"); + let content = format!( + "timeline feature\ncreated_at 1700000000\nstaged {} file.txt\nmodified abc1", + hash + ); + fs::write(mgr.shelf_path("feature"), content).unwrap(); + + let loaded = mgr.load_shelf("feature").unwrap().unwrap(); + assert_eq!(loaded.timeline, "feature"); + assert!(loaded.staged_files.contains_key("file.txt")); + } + #[test] fn save_and_load() { let (_dir, mgr) = setup(); @@ -274,10 +292,12 @@ mod tests { .workspace_changes .iter() .any(|c| matches!(c, WorkspaceChange::Untracked { path, hash } if path == "scratch/notes.md" && *hash == h_unt))); - assert!(loaded - .workspace_changes - .iter() - .any(|c| matches!(c, WorkspaceChange::Deleted { path } if path == ".gitignore"))); + assert!( + loaded + .workspace_changes + .iter() + .any(|c| matches!(c, WorkspaceChange::Deleted { path } if path == ".gitignore")) + ); } #[test] diff --git a/src/ssh_transport.rs b/src/ssh_transport.rs index 2536839..2476979 100644 --- a/src/ssh_transport.rs +++ b/src/ssh_transport.rs @@ -22,8 +22,8 @@ use std::io::{Read, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use crate::git_remote::{ - extract_pack_from_upload_pack, parse_discovery, parse_packfile, - select_branch_from_discovery, FetchResult, GitRemoteError, + FetchResult, GitRemoteError, extract_pack_from_upload_pack, parse_discovery, parse_packfile, + select_branch_from_discovery, }; use crate::progress; use crate::remote::RemoteBranch; @@ -70,22 +70,22 @@ impl SshTarget { // to disambiguate from `host:port/path`-style URLs that don't carry // a scheme. Within the scp form the path may be absolute // (`git@host:/abs/path`) — that's still scp, not host:port. - if !url.contains("://") { - if let Some((userhost, path)) = url.split_once(':') { - let (user, host) = match userhost.split_once('@') { - Some((u, h)) => (u.to_string(), h.to_string()), - None => return None, // bare `host:path` is ambiguous; require user@. - }; - if host.is_empty() || path.is_empty() { - return None; - } - return Some(SshTarget { - user, - host, - port: None, - repo_path: path.to_string(), - }); + if !url.contains("://") + && let Some((userhost, path)) = url.split_once(':') + { + let (user, host) = match userhost.split_once('@') { + Some((u, h)) => (u.to_string(), h.to_string()), + None => return None, // bare `host:path` is ambiguous; require user@. + }; + if host.is_empty() || path.is_empty() { + return None; } + return Some(SshTarget { + user, + host, + port: None, + repo_path: path.to_string(), + }); } None } @@ -258,10 +258,8 @@ impl SshClient { return Err(GitRemoteError::Io(format!("ssh: {}", stderr_text))); } }; - let discovery = - crate::git_remote::parse_discovery(&adv_bytes).map_err(|e| { - GitRemoteError::Protocol(format!("receive-pack advertisement: {}", e)) - })?; + let discovery = crate::git_remote::parse_discovery(&adv_bytes) + .map_err(|e| GitRemoteError::Protocol(format!("receive-pack advertisement: {}", e)))?; let target_ref = format!("refs/heads/{}", branch); let old_sha1 = discovery @@ -343,8 +341,7 @@ impl SshClient { .write_all(b"0000") .map_err(|e| GitRemoteError::Io(e.to_string()))?; - let mut object_refs: Vec<&crate::git_export::GitObject> = - export.objects.values().collect(); + let mut object_refs: Vec<&crate::git_export::GitObject> = export.objects.values().collect(); // Stable order — receivers don't care, but determinism helps debugging. object_refs.sort_by_key(|o| o.sha1); let pack = git_pack_writer::write_pack(&object_refs) diff --git a/src/store.rs b/src/store.rs index 5676043..d6adc5b 100644 --- a/src/store.rs +++ b/src/store.rs @@ -48,7 +48,14 @@ impl_from_redb!( impl Store { pub fn open(path: &Path) -> Result { - let db = Database::create(path)?; + let db = Database::create(path).map_err(|e| match e { + redb::DatabaseError::DatabaseAlreadyOpen => StoreError( + "repository store is in use by another ivaldi process; \ + retry when it finishes" + .into(), + ), + other => StoreError::from(other), + })?; let w = db.begin_write()?; { let _ = w.open_table(LEAVES)?; @@ -288,7 +295,8 @@ impl Store { let w = self.db.begin_write()?; { w.open_table(LEAVES)?.insert(idx, canonical)?; - w.open_table(TIMELINE_HEADS)?.insert(timeline, timeline_head)?; + w.open_table(TIMELINE_HEADS)? + .insert(timeline, timeline_head)?; w.open_table(SEAL_NAME_TO_HASH)? .insert(seal_name, seal_hash.as_bytes().as_slice())?; w.open_table(HASH_TO_SEAL_NAME)? diff --git a/src/submodule.rs b/src/submodule.rs index db49273..bcf4967 100644 --- a/src/submodule.rs +++ b/src/submodule.rs @@ -46,16 +46,16 @@ pub fn parse_gitmodules(work_dir: &Path) -> Vec { url: String::new(), branch: None, }); - } else if let Some(ref mut m) = current { - if let Some((key, value)) = line.split_once('=') { - let key = key.trim(); - let value = value.trim(); - match key { - "path" => m.path = value.to_string(), - "url" => m.url = value.to_string(), - "branch" => m.branch = Some(value.to_string()), - _ => {} - } + } else if let Some(ref mut m) = current + && let Some((key, value)) = line.split_once('=') + { + let key = key.trim(); + let value = value.trim(); + match key { + "path" => m.path = value.to_string(), + "url" => m.url = value.to_string(), + "branch" => m.branch = Some(value.to_string()), + _ => {} } } } diff --git a/src/switch_journal.rs b/src/switch_journal.rs new file mode 100644 index 0000000..9b95823 --- /dev/null +++ b/src/switch_journal.rs @@ -0,0 +1,89 @@ +//! Crash journal for timeline switches. +//! +//! A timeline switch is multi-step: shelve the current timeline's dirty +//! state, rewrite HEAD, materialize the target tree, restore the target's +//! shelf. A crash mid-sequence used to leave the working tree half +//! transitioned with nothing recording that fact. The journal file +//! (`.ivaldi/SWITCH_IN_PROGRESS`) is written after the shelve phase (the +//! only non-idempotent part) and removed after the switch completes; while +//! it exists, mutating commands refuse to run and `timeline switch` offers +//! to complete or roll back the transition. + +use std::path::Path; + +pub const JOURNAL_FILE: &str = "SWITCH_IN_PROGRESS"; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SwitchJournal { + /// Timeline the interrupted switch was leaving. + pub from: String, + /// Timeline the interrupted switch was entering. + pub to: String, + /// Whether a shelf for `from` was saved (vs. removed-as-clean). + pub shelf_saved: bool, + /// Unix time the switch started (diagnostic only). + pub started_at: i64, +} + +pub fn write(ivaldi_dir: &Path, journal: &SwitchJournal) -> std::io::Result<()> { + let data = + serde_json::to_string_pretty(journal).map_err(|e| std::io::Error::other(e.to_string()))?; + crate::atomic_io::atomic_write(&ivaldi_dir.join(JOURNAL_FILE), data.as_bytes()) +} + +/// Load the journal if one exists. Corrupt JSON is an error (conservative: +/// the user should inspect rather than have it silently ignored). +pub fn load(ivaldi_dir: &Path) -> std::io::Result> { + let path = ivaldi_dir.join(JOURNAL_FILE); + match std::fs::read_to_string(&path) { + Ok(data) => serde_json::from_str(&data) + .map(Some) + .map_err(|e| std::io::Error::other(format!("corrupt {}: {}", JOURNAL_FILE, e))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +pub fn clear(ivaldi_dir: &Path) -> std::io::Result<()> { + match std::fs::remove_file(ivaldi_dir.join(JOURNAL_FILE)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_and_clear() { + let dir = tempfile::tempdir().unwrap(); + assert!(load(dir.path()).unwrap().is_none()); + + let journal = SwitchJournal { + from: "main".into(), + to: "feature".into(), + shelf_saved: true, + started_at: 1700000000, + }; + write(dir.path(), &journal).unwrap(); + + let loaded = load(dir.path()).unwrap().unwrap(); + assert_eq!(loaded.from, "main"); + assert_eq!(loaded.to, "feature"); + assert!(loaded.shelf_saved); + + clear(dir.path()).unwrap(); + assert!(load(dir.path()).unwrap().is_none()); + // Clearing again is fine. + clear(dir.path()).unwrap(); + } + + #[test] + fn corrupt_journal_is_an_error_not_none() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(JOURNAL_FILE), "{truncated").unwrap(); + assert!(load(dir.path()).is_err()); + } +} diff --git a/src/sync.rs b/src/sync.rs deleted file mode 100644 index cfc9db3..0000000 --- a/src/sync.rs +++ /dev/null @@ -1,2872 +0,0 @@ -//! Sync operations for Ivaldi VCS — download, upload, scout, harvest. -//! -//! Bridges Ivaldi's internal BLAKE3-based storage with GitHub's SHA1-based -//! Git objects. SHA1 is used ONLY for API communication — never internally. - -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::fs; -use std::path::Path; - -use crate::cas::FileCas; -use crate::fsmerkle::FsStore; -use crate::git_remote::{self, FetchResult, SmartHttpClient}; -use crate::github::{CommitIdentity, GitHubClient, GitHubError, TreeEntryCreate, TreeResponse}; -use crate::hash::B3Hash; -use crate::ignore; -use crate::leaf::Leaf; -use crate::portal::{Portal, Transport}; -use crate::remote::{HashMapping, RemoteBranch}; -use crate::repo::Repo; -use crate::ssh_transport::SshClient; - -/// Read-side dispatch: pick HTTPS or SSH based on a portal's transport, and -/// expose the small surface that `scout` / `harvest` / `sync` need. -/// -/// Construct via [`RemoteFetcher::for_portal`]. The HTTPS variant carries an -/// optional auth token (matching `SmartHttpClient::new`), while the SSH -/// variant carries the resolved `SshTarget` from `portal.transport()`. -pub enum RemoteFetcher { - Https { - token: Option, - }, - Ssh { - target: crate::ssh_transport::SshTarget, - }, -} - -impl RemoteFetcher { - /// Build the fetcher matching a portal's transport. The token is used - /// only by the HTTPS variant. - pub fn for_portal(portal: &Portal, token: Option<&str>) -> Self { - match portal.transport() { - Transport::Ssh(target) => RemoteFetcher::Ssh { target }, - // P2P portals can't be served by HTTPS scout/harvest/sync; - // those callers should branch on the portal first. Falling - // back to HTTPS gives a coherent error path. - Transport::Peer(_) | Transport::Https => RemoteFetcher::Https { - token: token.map(str::to_string), - }, - } - } - - /// List branches of the remote, name-only (no SHAs). - pub fn list_branches( - &self, - owner: &str, - repo_name: &str, - ) -> Result, SyncError> { - match self { - RemoteFetcher::Https { token } => SmartHttpClient::new(token.as_deref()) - .list_branches(owner, repo_name) - .map_err(|e| SyncError::Other(e.to_string())), - RemoteFetcher::Ssh { target } => SshClient::new(target.clone()) - .list_branch_refs() - .map(|refs| refs.into_iter().map(|b| b.name).collect()) - .map_err(|e| SyncError::Other(e.to_string())), - } - } - - /// List branches with SHAs (for sync-state classification). - pub fn list_branch_refs( - &self, - owner: &str, - repo_name: &str, - ) -> Result, SyncError> { - match self { - RemoteFetcher::Https { token } => SmartHttpClient::new(token.as_deref()) - .list_branch_refs(owner, repo_name) - .map_err(|e| SyncError::Other(e.to_string())), - RemoteFetcher::Ssh { target } => SshClient::new(target.clone()) - .list_branch_refs() - .map_err(|e| SyncError::Other(e.to_string())), - } - } - - /// Fetch a branch's full pack. - pub fn fetch_repo( - &self, - owner: &str, - repo_name: &str, - branch: Option<&str>, - ) -> Result { - match self { - RemoteFetcher::Https { token } => SmartHttpClient::new(token.as_deref()) - .fetch_repo(owner, repo_name, branch) - .map_err(|e| SyncError::Other(e.to_string())), - RemoteFetcher::Ssh { target } => SshClient::new(target.clone()) - .fetch_repo(branch) - .map_err(|e| SyncError::Other(e.to_string())), - } - } -} - -/// Result of a download (clone) operation. -#[derive(Debug)] -pub struct DownloadResult { - pub files_downloaded: usize, - pub commits_imported: usize, - pub timelines_created: Vec, -} - -/// Result of an upload (push) operation. -#[derive(Debug)] -pub struct UploadResult { - pub files_uploaded: usize, - pub commit_sha: String, - pub branch: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RemoteTimelineState { - NotDownloaded, - UpToDate, - OutOfSync, - LocalOnly, -} - -#[derive(Debug, Clone)] -pub struct RemoteTimelineInfo { - pub name: String, - pub remote_sha: String, - pub state: RemoteTimelineState, -} - -/// Download a repository from GitHub into a local Ivaldi repo. -pub fn download( - client: &GitHubClient, - owner: &str, - repo_name: &str, - target_dir: &Path, - branch: Option<&str>, -) -> Result { - download_with_fetch(target_dir, owner, repo_name, |branch| { - SmartHttpClient::new(client.token()) - .fetch_repo(owner, repo_name, branch) - .map_err(|e| SyncError::Other(e.to_string())) - }, branch) -} - -/// Download a repository from any SSH-reachable Git host into a local -/// Ivaldi repo. `display_name` is used for "Downloading X..." messaging -/// and the local portal entry (e.g. `git@github.com:owner/repo.git`). -pub fn download_ssh( - target: &crate::ssh_transport::SshTarget, - target_dir: &Path, - branch: Option<&str>, -) -> Result { - let (owner, repo_name) = derive_owner_repo_from_path(&target.repo_path); - let target_clone = target.clone(); - download_with_fetch(target_dir, &owner, &repo_name, move |branch| { - crate::ssh_transport::SshClient::new(target_clone.clone()) - .fetch_repo(branch) - .map_err(|e| SyncError::Other(e.to_string())) - }, branch) -} - -/// Best-effort split of a remote repo path like `owner/repo.git` into -/// (owner, repo). For paths that don't fit `owner/repo` (e.g. nested -/// subgroups like `team/subteam/repo.git` on GitLab), we keep the last two -/// segments as (owner, repo) and discard the prefix — Ivaldi's local model -/// is two-level only, and the portal entry will round-trip the original -/// path. -fn derive_owner_repo_from_path(path: &str) -> (String, String) { - let trimmed = path.trim_start_matches('/').trim_end_matches('/'); - let stripped = trimmed.strip_suffix(".git").unwrap_or(trimmed); - let parts: Vec<&str> = stripped.split('/').filter(|s| !s.is_empty()).collect(); - match parts.as_slice() { - [] => ("local".to_string(), "repo".to_string()), - [single] => ("local".to_string(), (*single).to_string()), - many => ( - many[many.len() - 2].to_string(), - many[many.len() - 1].to_string(), - ), - } -} - -/// Common orchestration: ensure target dir, run the supplied fetch closure, -/// import the resulting `FetchResult`, materialize, return DownloadResult. -fn download_with_fetch( - target_dir: &Path, - owner: &str, - repo_name: &str, - fetch: F, - branch: Option<&str>, -) -> Result -where - F: FnOnce( - Option<&str>, - ) -> Result, -{ - if target_dir.exists() - && target_dir - .read_dir() - .map(|mut d| d.next().is_some()) - .unwrap_or(false) - { - return Err(SyncError::Other(format!( - "directory '{}' already exists and is not empty", - target_dir.display() - ))); - } - let created_target = ensure_download_target(target_dir)?; - - eprintln!("Downloading {}/{}...", owner, repo_name); - let result = (|| -> Result { - let remote = fetch(branch)?; - - crate::forge::forge(target_dir).map_err(|e| SyncError::Other(e.to_string()))?; - let ivaldi_dir = target_dir.join(".ivaldi"); - - let portal_mgr = crate::portal::PortalManager::new(&ivaldi_dir); - let portal = crate::portal::Portal::parse(&format!("{}/{}", owner, repo_name)).unwrap(); - let _ = portal_mgr.add(&portal); - - let mut cfg = crate::config::Config::new(); - cfg.set("portal.default", &format!("{}/{}", owner, repo_name)); - cfg.save(&ivaldi_dir.join("config")).ok(); - - let mut repo = Repo::open(target_dir).map_err(|e| SyncError::Other(e.to_string()))?; - let import = git_remote::import_fetch_result(&mut repo, &remote) - .map_err(|e| SyncError::Other(e.to_string()))?; - - // forge() initialised HEAD to a hardcoded "main"; point it at the - // branch we actually fetched so `whereami` and `timeline list` agree - // with the working tree. Also materialise the on-disk ref file so the - // timeline shows up in tools that scan refs/heads. - let ref_path = ivaldi_dir.join("refs/heads").join(&remote.branch); - if let Some(parent) = ref_path.parent() { - fs::create_dir_all(parent).map_err(|e| SyncError::Other(e.to_string()))?; - } - if !ref_path.exists() { - fs::write(&ref_path, "").map_err(|e| SyncError::Other(e.to_string()))?; - } - crate::forge::write_head( - &ivaldi_dir, - &crate::forge::HeadRef::Timeline(remote.branch.clone()), - ) - .map_err(|e| SyncError::Other(e.to_string()))?; - - let cas = FileCas::new(ivaldi_dir.join("objects")) - .map_err(|e| SyncError::Other(e.to_string()))?; - let store = FsStore::new(&cas); - let file_count = if repo - .get_timeline_head(&remote.branch) - .map_err(|e| SyncError::Other(e.to_string()))? - .is_some() - { - checkout_tree_to_workspace(&repo, &store, &remote.branch)? - } else { - 0 - }; - - eprintln!( - "Downloaded {} files, imported {} commits from {}/{}", - file_count, import.commits_imported, owner, repo_name - ); - - Ok(DownloadResult { - files_downloaded: file_count, - commits_imported: import.commits_imported, - timelines_created: vec![remote.branch], - }) - })(); - - if result.is_err() && created_target { - cleanup_failed_download_target(target_dir); - } - result -} - -fn ensure_download_target(target_dir: &Path) -> Result { - if target_dir.exists() { - return Ok(false); - } - fs::create_dir_all(target_dir).map_err(|e| SyncError::Other(e.to_string()))?; - Ok(true) -} - -fn cleanup_failed_download_target(target_dir: &Path) { - let _ = fs::remove_dir_all(target_dir); -} - -/// Upload blobs in parallel, skipping those already mapped. -/// -/// Returns `TreeEntryCreate` entries for the GitHub tree API. -fn upload_blobs_parallel( - client: &GitHubClient, - store: &FsStore<'_>, - files: &BTreeMap, - hash_mapping: &mut HashMapping, - owner: &str, - repo_name: &str, -) -> Result, SyncError> { - // Defense-in-depth: reject security-blocked files before upload - for (path, _) in files { - if crate::ignore::is_security_blocked(path) { - return Err(SyncError::Other(format!( - "refusing to upload security-blocked file: {}", - path - ))); - } - } - - // Partition files into already-mapped (skip) and need-upload - let mut tree_entries = Vec::new(); - let mut to_upload: Vec<(String, B3Hash)> = Vec::new(); - - for (path, blob_hash) in files { - if let Some(sha1) = hash_mapping.get_sha1(*blob_hash) { - // Already uploaded — reuse SHA1 - tree_entries.push(TreeEntryCreate { - path: path.clone(), - mode: "100644".into(), - entry_type: "blob".into(), - sha: sha1.to_string(), - }); - } else { - to_upload.push((path.clone(), *blob_hash)); - } - } - - let skipped = files.len() - to_upload.len(); - if skipped > 0 { - eprintln!("Skipped {} already-uploaded blobs", skipped); - } - - if to_upload.is_empty() { - return Ok(tree_entries); - } - - // Pre-load all blob content (CAS is not Sync, so load before spawning threads) - let mut upload_items: Vec<(String, B3Hash, Vec)> = Vec::new(); - for (path, blob_hash) in &to_upload { - let (_, content) = store - .load_blob(*blob_hash) - .map_err(|e| SyncError::Other(e.to_string()))?; - upload_items.push((path.clone(), *blob_hash, content)); - } - - // Upload in parallel using std::thread::scope (ureq is sync) - let pb = crate::progress::file_bar(upload_items.len() as u64, "Uploading"); - let results: Vec> = std::thread::scope(|s| { - let chunk_size = (upload_items.len() / 4).max(1); - let mut handles = Vec::new(); - - for chunk in upload_items.chunks(chunk_size) { - let pb = &pb; - let handle = s.spawn(move || { - let mut results = Vec::new(); - for (path, blob_hash, content) in chunk { - match client.create_blob(owner, repo_name, content) { - Ok(sha) => { - pb.inc(1); - results.push(Ok((path.clone(), sha, *blob_hash))); - } - Err(e) => results.push(Err(SyncError::GitHub(e))), - } - } - results - }); - handles.push(handle); - } - - handles - .into_iter() - .flat_map(|h| h.join().unwrap_or_default()) - .collect() - }); - pb.finish_with_message(format!("{} blobs uploaded", results.len())); - - for result in results { - let (path, sha, blob_hash) = result?; - hash_mapping.insert(&sha, blob_hash); - tree_entries.push(TreeEntryCreate { - path, - mode: "100644".into(), - entry_type: "blob".into(), - sha, - }); - } - - Ok(tree_entries) -} - -/// Upload (push) the current timeline to GitHub. -pub fn upload( - client: &GitHubClient, - repo: &Repo, - owner: &str, - repo_name: &str, - branch: Option<&str>, - force: bool, -) -> Result { - if !client.is_authenticated() { - return Err(SyncError::GitHub(GitHubError::AuthRequired)); - } - - let timeline = repo - .current_timeline() - .map_err(|e| SyncError::Other(e.to_string()))?; - let branch_name = branch.unwrap_or(&timeline); - - // Get head leaf - let head_idx = repo - .get_timeline_head(&timeline) - .map_err(|e| SyncError::Other(e.to_string()))? - .ok_or_else(|| SyncError::Other("no commits to upload".into()))?; - - let cas = FileCas::new(repo.ivaldi_dir.join("objects")) - .map_err(|e| SyncError::Other(e.to_string()))?; - let store = FsStore::new(&cas); - - let mut hash_mapping = HashMapping::new(&repo.ivaldi_dir); - - // GitHub's Git Data API returns 409 on every endpoint (blobs included) when - // the repo has no initial commit. Detect that up front and seed the repo - // via the Contents API so the rest of the upload can proceed. - let existing_branches = client - .list_branches(owner, repo_name) - .map_err(SyncError::GitHub)?; - let mut bootstrapped = false; - if existing_branches.is_empty() { - let default_branch = client - .get_repo(owner, repo_name) - .map(|info| info.default_branch) - .unwrap_or_default(); - let seed_branch = if default_branch.is_empty() { - branch_name - } else { - default_branch.as_str() - }; - client - .create_file_contents( - owner, - repo_name, - ".ivaldi-bootstrap", - seed_branch, - b"Ivaldi bootstrap placeholder. Safe to remove after first upload.\n", - "chore: initialize repository for Ivaldi", - ) - .map_err(SyncError::GitHub)?; - bootstrapped = true; - } - - let existing_branch_sha = client - .list_branches(owner, repo_name) - .ok() - .and_then(|branches| { - branches - .iter() - .find(|b| b.name == branch_name) - .map(|b| b.commit.sha.clone()) - }); - - // After a bootstrap, the seed commit on the branch is not a real ancestor - // of our local history, so treat this like a force-push for parent - // resolution and ref update to replace the placeholder commit. - let effective_force = force || bootstrapped; - let is_new_branch = existing_branch_sha.is_none(); - - // Walk back from head to the deepest already-mapped ancestor (or root) and - // collect the chronological list of unpushed leaves. Each one is replayed - // as its own GitHub commit so local history is preserved on the remote. - let replay_indices = collect_unpushed_leaves(repo, head_idx, &hash_mapping) - .map_err(|e| SyncError::Other(e.to_string()))?; - - if replay_indices.is_empty() { - // Head is already mapped; nothing to push beyond a possible ref move. - let head_leaf = repo - .get_leaf(head_idx) - .map_err(|e| SyncError::Other(e.to_string()))? - .ok_or_else(|| SyncError::Other("corrupt: head leaf not found".into()))?; - let head_sha = hash_mapping - .get_sha1(head_leaf.hash()) - .map(|s| s.to_string()) - .ok_or_else(|| SyncError::Other("head leaf unexpectedly unmapped".into()))?; - return Ok(UploadResult { - files_uploaded: 0, - commit_sha: head_sha, - branch: branch_name.to_string(), - }); - } - - let mut last_commit_sha = String::new(); - let mut total_blobs_uploaded = 0usize; - - for (i, &leaf_idx) in replay_indices.iter().enumerate() { - let leaf = repo - .get_leaf(leaf_idx) - .map_err(|e| SyncError::Other(e.to_string()))? - .ok_or_else(|| SyncError::Other(format!("corrupt: leaf {} not found", leaf_idx)))?; - - let mut files = BTreeMap::new(); - collect_tree_files(&store, leaf.tree_root, "", &mut files) - .map_err(|e| SyncError::Other(e.to_string()))?; - - let blobs_before = hash_mapping.len(); - let tree_entries = - upload_blobs_parallel(client, &store, &files, &mut hash_mapping, owner, repo_name)?; - total_blobs_uploaded += hash_mapping.len().saturating_sub(blobs_before); - - let tree_sha = client - .create_tree(owner, repo_name, tree_entries, None) - .map_err(SyncError::GitHub)?; - - // Parents: for the first leaf in the chain, defer to existing parent - // resolution (existing branch tip / mapped ancestor). For every later - // leaf, the parent is whatever we just created in the previous - // iteration plus any merge parents already mapped. - let parents = if i == 0 { - resolve_github_parent( - repo, - &leaf, - &hash_mapping, - existing_branch_sha.as_deref(), - effective_force, - ) - } else { - let mut p = vec![last_commit_sha.clone()]; - for &midx in &leaf.merge_idxs { - if let Ok(Some(merge_leaf)) = repo.get_leaf(midx) { - if let Some(sha) = hash_mapping.get_sha1(merge_leaf.hash()) { - let s = sha.to_string(); - if !p.contains(&s) { - p.push(s); - } - } - } - } - p - }; - - let author_id = identity_for_author(&leaf); - let committer_id = identity_for_committer(&leaf); - - let commit_sha = client - .create_commit( - owner, - repo_name, - &leaf.message, - &tree_sha, - &parents, - Some(&author_id), - Some(&committer_id), - ) - .map_err(SyncError::GitHub)?; - - hash_mapping.insert(&commit_sha, leaf.hash()); - last_commit_sha = commit_sha; - } - - hash_mapping - .save() - .map_err(|e| SyncError::Other(e.to_string()))?; - - if is_new_branch { - client - .create_ref(owner, repo_name, branch_name, &last_commit_sha) - .map_err(SyncError::GitHub)?; - } else { - client - .update_ref( - owner, - repo_name, - branch_name, - &last_commit_sha, - effective_force, - ) - .map_err(SyncError::GitHub)?; - } - - Ok(UploadResult { - files_uploaded: total_blobs_uploaded, - commit_sha: last_commit_sha, - branch: branch_name.to_string(), - }) -} - -/// Walk from `head_idx` backwards along `prev_idx` and return the chronological -/// list of leaves whose BLAKE3 is NOT yet in `hash_mapping`. The list is empty -/// if the head is already mapped. -fn collect_unpushed_leaves( - repo: &Repo, - head_idx: u64, - hash_mapping: &HashMapping, -) -> Result, crate::repo::RepoError> { - let mut chain = Vec::new(); - let mut cur = Some(head_idx); - while let Some(idx) = cur { - let leaf = match repo.get_leaf(idx)? { - Some(l) => l, - None => break, - }; - if hash_mapping.get_sha1(leaf.hash()).is_some() { - // First mapped ancestor — stop here. - break; - } - chain.push(idx); - cur = if leaf.has_parent() { - Some(leaf.prev_idx) - } else { - None - }; - } - chain.reverse(); - Ok(chain) -} - -/// Build the `author` identity for a GitHub commit from a leaf. -/// -/// Prefers a per-leaf timezone offset stored in `meta["git.author_tz"]` (set -/// during import); falls back to UTC. -fn identity_for_author(leaf: &Leaf) -> CommitIdentity { - let (name, email) = split_author(&leaf.author); - let tz = leaf - .meta - .get("git.author_tz") - .map(String::as_str) - .unwrap_or("+0000"); - CommitIdentity { - name, - email, - date: format_rfc3339(leaf.time_unix, tz), - } -} - -/// Build the `committer` identity for a GitHub commit from a leaf. -/// -/// Prefers per-leaf committer info stored in `meta` during import; otherwise -/// reuses the author (matches Git's default when the user only sets one). -fn identity_for_committer(leaf: &Leaf) -> CommitIdentity { - if let (Some(committer), Some(time_str)) = ( - leaf.meta.get("git.committer"), - leaf.meta.get("git.committer_time"), - ) { - let time = time_str.parse::().unwrap_or(leaf.time_unix); - let tz = leaf - .meta - .get("git.committer_tz") - .map(String::as_str) - .unwrap_or("+0000"); - let (name, email) = split_author(committer); - return CommitIdentity { - name, - email, - date: format_rfc3339(time, tz), - }; - } - identity_for_author(leaf) -} - -/// Split a `"Name "` string. Tolerates malformed input by returning the -/// whole string as the name and an empty email. -fn split_author(s: &str) -> (String, String) { - if let Some(open) = s.rfind(" <") { - if let Some(close) = s[open..].find('>') { - let name = s[..open].trim().to_string(); - let email = s[open + 2..open + close].to_string(); - return (name, email); - } - } - (s.to_string(), String::new()) -} - -/// Format a unix-second timestamp + git-style timezone offset (e.g. `"+0000"`, -/// `"-0530"`) as RFC 3339 (`YYYY-MM-DDTHH:MM:SS±HH:MM`). -/// -/// The offset is applied to the unix instant before splitting into civil -/// time so `1700000000` + `+0100` formats as `2023-11-14T23:13:20+01:00`. -fn format_rfc3339(unix_seconds: i64, git_tz: &str) -> String { - let (sign, hours, minutes) = parse_git_tz(git_tz); - let offset_seconds = sign * (hours as i64 * 3600 + minutes as i64 * 60); - let local = unix_seconds + offset_seconds; - let (y, mo, d, h, mi, s) = civil_from_unix(local); - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}", - y, - mo, - d, - h, - mi, - s, - if sign >= 0 { '+' } else { '-' }, - hours, - minutes, - ) -} - -/// Parse `"+HHMM"` / `"-HHMM"` into (sign, hours, minutes). Defaults to UTC -/// (`+0000`) on any parse error. -fn parse_git_tz(s: &str) -> (i64, u32, u32) { - let bytes = s.as_bytes(); - if bytes.len() != 5 { - return (1, 0, 0); - } - let sign: i64 = match bytes[0] { - b'+' => 1, - b'-' => -1, - _ => return (1, 0, 0), - }; - let h: u32 = std::str::from_utf8(&bytes[1..3]) - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - let m: u32 = std::str::from_utf8(&bytes[3..5]) - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - (sign, h, m) -} - -/// Convert a unix-second timestamp into civil (year, month, day, hour, min, sec). -/// -/// Implements Howard Hinnant's days_from_civil inverse for portability without -/// pulling in a full date crate. -fn civil_from_unix(unix_seconds: i64) -> (i32, u32, u32, u32, u32, u32) { - let days = unix_seconds.div_euclid(86_400); - let secs_of_day = unix_seconds.rem_euclid(86_400) as u32; - let h = secs_of_day / 3600; - let mi = (secs_of_day % 3600) / 60; - let s = secs_of_day % 60; - - // Days since 1970-01-01 → civil date (Hinnant). - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; // [0, 146_096] - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] - let mp = (5 * doy + 2) / 153; // [0, 11] - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] - let year = (y + if m <= 2 { 1 } else { 0 }) as i32; - (year, m, d, h, mi, s) -} - -/// Scout — list remote branches without downloading. Routes through the -/// portal's transport (HTTPS or SSH). -pub fn scout( - client: &GitHubClient, - portal: &Portal, -) -> Result, SyncError> { - RemoteFetcher::for_portal(portal, client.token()) - .list_branches(&portal.owner, &portal.repo) -} - -pub fn scout_with_status( - client: &GitHubClient, - repo: &Repo, - portal: &Portal, -) -> Result, SyncError> { - let branches = RemoteFetcher::for_portal(portal, client.token()) - .list_branch_refs(&portal.owner, &portal.repo)?; - let mapping = HashMapping::new(&repo.ivaldi_dir); - - Ok(branches - .into_iter() - .map(|branch| RemoteTimelineInfo { - name: branch.name.clone(), - remote_sha: branch.sha1.clone(), - state: timeline_sync_state(repo, &mapping, &branch), - }) - .collect()) -} - -/// Harvest — download specific branches with full history. -pub fn harvest( - client: &GitHubClient, - repo: &mut Repo, - portal: &Portal, - timeline_names: &[String], -) -> Result, SyncError> { - let fetcher = RemoteFetcher::for_portal(portal, client.token()); - let branches = fetcher.list_branch_refs(&portal.owner, &portal.repo)?; - let mapping = HashMapping::new(&repo.ivaldi_dir); - - let mut harvested = Vec::new(); - - for target_name in timeline_names { - let branch = branches - .iter() - .find(|b| &b.name == target_name) - .ok_or_else(|| { - SyncError::Other(format!("remote timeline '{}' not found", target_name)) - })?; - - eprintln!("Harvesting timeline '{}'...", target_name); - match timeline_sync_state(repo, &mapping, branch) { - RemoteTimelineState::NotDownloaded => eprintln!(" Local state: not downloaded"), - RemoteTimelineState::UpToDate => eprintln!(" Local state: up to date"), - RemoteTimelineState::OutOfSync => eprintln!(" Local state: out of sync"), - RemoteTimelineState::LocalOnly => eprintln!(" Local state: local only"), - } - - let fetch = fetcher.fetch_repo(&portal.owner, &portal.repo, Some(target_name))?; - let import = git_remote::import_fetch_result(repo, &fetch) - .map_err(|e| SyncError::Other(e.to_string()))?; - if import.commits_skipped > 0 { - eprintln!( - " {} new commits imported ({} already present)", - import.commits_imported, import.commits_skipped - ); - } else { - eprintln!(" {} commits imported", import.commits_imported); - } - - harvested.push(target_name.clone()); - } - - Ok(harvested) -} - -fn timeline_sync_state( - repo: &Repo, - mapping: &HashMapping, - branch: &RemoteBranch, -) -> RemoteTimelineState { - let Ok(Some(head_idx)) = repo.get_timeline_head(&branch.name) else { - return RemoteTimelineState::NotDownloaded; - }; - let Ok(Some(head_leaf)) = repo.get_leaf(head_idx) else { - return RemoteTimelineState::LocalOnly; - }; - - match mapping.get_sha1(head_leaf.hash()) { - Some(sha) if sha == branch.sha1 => RemoteTimelineState::UpToDate, - Some(_) => RemoteTimelineState::OutOfSync, - None => RemoteTimelineState::LocalOnly, - } -} - -// Helper to collect files from tree -fn collect_tree_files( - store: &FsStore<'_>, - tree_hash: B3Hash, - prefix: &str, - files: &mut BTreeMap, -) -> Result<(), crate::fsmerkle::FsMerkleError> { - let tree = store.load_tree(tree_hash)?; - for entry in &tree.entries { - let path = if prefix.is_empty() { - entry.name.clone() - } else { - format!("{}/{}", prefix, entry.name) - }; - match entry.kind { - crate::fsmerkle::NodeKind::Blob => { - files.insert(path, entry.hash); - } - crate::fsmerkle::NodeKind::Tree => { - collect_tree_files(store, entry.hash, &path, files)?; - } - } - } - Ok(()) -} - -/// Result of a full history import. -#[derive(Debug)] -pub struct ImportResult { - pub commits_imported: usize, - pub commits_skipped: usize, - pub blobs_downloaded: usize, - pub timeline: String, -} - -/// Parse ISO 8601 date string to unix timestamp (no chrono dependency). -/// -/// Supports formats: `2024-01-15T10:30:00Z`, `2024-01-15T10:30:00+00:00`, -/// `2024-01-15T10:30:00-05:00`. -pub fn parse_iso8601_to_unix(s: &str) -> Option { - let s = s.trim(); - if s.len() < 19 { - return None; - } - - // Split date and time at 'T' - let (date_part, rest) = s.split_once('T')?; - let date_parts: Vec<&str> = date_part.split('-').collect(); - if date_parts.len() != 3 { - return None; - } - let year: i64 = date_parts[0].parse().ok()?; - let month: i64 = date_parts[1].parse().ok()?; - let day: i64 = date_parts[2].parse().ok()?; - - // Parse time, stripping timezone - let (time_str, tz_offset_secs) = if rest.ends_with('Z') { - (&rest[..rest.len() - 1], 0i64) - } else if let Some(plus_pos) = rest[8..].find('+') { - let idx = 8 + plus_pos; - let tz = &rest[idx + 1..]; - let tz_parts: Vec<&str> = tz.split(':').collect(); - let hours: i64 = tz_parts.first()?.parse().ok()?; - let mins: i64 = tz_parts.get(1).and_then(|m| m.parse().ok()).unwrap_or(0); - (&rest[..idx], hours * 3600 + mins * 60) - } else if let Some(minus_pos) = rest[8..].find('-') { - let idx = 8 + minus_pos; - let tz = &rest[idx + 1..]; - let tz_parts: Vec<&str> = tz.split(':').collect(); - let hours: i64 = tz_parts.first()?.parse().ok()?; - let mins: i64 = tz_parts.get(1).and_then(|m| m.parse().ok()).unwrap_or(0); - (&rest[..idx], -(hours * 3600 + mins * 60)) - } else { - (rest, 0i64) - }; - - let time_parts: Vec<&str> = time_str.split(':').collect(); - if time_parts.len() < 2 { - return None; - } - let hour: i64 = time_parts[0].parse().ok()?; - let min: i64 = time_parts[1].parse().ok()?; - let sec: i64 = time_parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); - - // Convert to unix timestamp (days since epoch) - // Simplified algorithm for dates after 1970 - let mut days: i64 = 0; - for y in 1970..year { - days += if is_leap_year(y) { 366 } else { 365 }; - } - let month_days = [ - 31, - if is_leap_year(year) { 29 } else { 28 }, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31, - ]; - for m in 0..(month - 1) as usize { - if m < 12 { - days += month_days[m] as i64; - } - } - days += day - 1; - - Some(days * 86400 + hour * 3600 + min * 60 + sec - tz_offset_secs) -} - -fn is_leap_year(y: i64) -> bool { - (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 -} - -/// Import full commit history from GitHub into an Ivaldi repo. -/// -/// Walks commits oldest-first, creates Ivaldi leaves preserving parent chains, -/// author info, timestamps, and tree content. -/// -/// `branch` is the remote GitHub branch name used for API calls. -/// `local_timeline` optionally overrides the local timeline name to store commits under -/// (defaults to `branch` if `None`). This is used when importing into a temp timeline -/// during diverged sync. -pub fn import_full_history( - client: &GitHubClient, - repo: &mut Repo, - owner: &str, - repo_name: &str, - branch: &str, - depth: usize, -) -> Result { - import_full_history_into(client, repo, owner, repo_name, branch, branch, depth) -} - -/// Like `import_full_history` but stores commits under `local_timeline` instead of `branch`. -fn import_full_history_into( - client: &GitHubClient, - repo: &mut Repo, - owner: &str, - repo_name: &str, - remote_branch: &str, - local_timeline: &str, - depth: usize, -) -> Result { - // Fetch commits (newest-first from GitHub) - let mut commits = client - .list_commits(owner, repo_name, remote_branch, depth) - .map_err(SyncError::GitHub)?; - - // Reverse to oldest-first for correct parent ordering - commits.reverse(); - - let cas = FileCas::new(repo.ivaldi_dir.join("objects")) - .map_err(|e| SyncError::Other(e.to_string()))?; - let store = FsStore::new(&cas); - let mut hash_mapping = HashMapping::new(&repo.ivaldi_dir); - - // Track SHA1 → leaf index for parent resolution - let mut sha_to_idx: HashMap = HashMap::new(); - // Cache tree SHA → Ivaldi tree hash to avoid re-downloading identical trees - let mut tree_cache: HashMap = HashMap::new(); - - let mut commits_imported = 0usize; - let mut commits_skipped = 0usize; - let mut blobs_downloaded = 0usize; - - // Ensure timeline exists - if repo - .get_timeline_head(local_timeline) - .map_err(|e| SyncError::Other(e.to_string()))? - .is_none() - { - // Create timeline ref directory/file but no head yet - let ref_path = repo.ivaldi_dir.join("refs/heads").join(local_timeline); - if let Some(parent) = ref_path.parent() { - fs::create_dir_all(parent).ok(); - } - fs::write(&ref_path, "").ok(); - } - - // Identify unskipped commits (those not already mapped) - let unskipped: Vec = commits - .iter() - .enumerate() - .filter(|(_, c)| hash_mapping.get_blake3(&c.sha).is_none()) - .map(|(i, _)| i) - .collect(); - - // Collect unique tree SHAs from unskipped commits - let unique_tree_shas: Vec = { - let mut seen = std::collections::HashSet::new(); - unskipped - .iter() - .map(|&i| commits[i].commit.tree.sha.clone()) - .filter(|sha| seen.insert(sha.clone())) - .collect() - }; - - // --- Phase 2: Parallel tree pre-fetch --- - let prefetched_trees: HashMap = if !unique_tree_shas.is_empty() { - let pb_trees = crate::progress::file_bar(unique_tree_shas.len() as u64, "Fetching trees"); - let results: Vec<(String, Result)> = std::thread::scope(|s| { - let chunk_size = (unique_tree_shas.len() / 8).max(1); - let mut handles = Vec::new(); - for chunk in unique_tree_shas.chunks(chunk_size) { - let pb_trees = &pb_trees; - let handle = s.spawn(move || { - let mut results = Vec::new(); - for sha in chunk { - let r = client.get_tree(owner, repo_name, sha); - pb_trees.inc(1); - results.push((sha.clone(), r)); - } - results - }); - handles.push(handle); - } - handles - .into_iter() - .flat_map(|h| h.join().unwrap_or_default()) - .collect() - }); - pb_trees.finish_with_message(format!("{} trees fetched", unique_tree_shas.len())); - - let mut map = HashMap::new(); - for (sha, result) in results { - match result { - Ok(tree) => { - map.insert(sha, tree); - } - Err(e) => { - crate::logging::warn(&format!("failed to pre-fetch tree {}: {}", sha, e)); - } - } - } - map - } else { - HashMap::new() - }; - - // --- Phase 3: Global blob batch download --- - // Collect all unique blobs from pre-fetched trees that we don't already have - let mut blobs_to_download: Vec<(String, String, String)> = Vec::new(); // (path, sha1, commit_sha) - let mut seen_blob_shas: std::collections::HashSet = std::collections::HashSet::new(); - - for &idx in &unskipped { - let commit = &commits[idx]; - let tree_sha = &commit.commit.tree.sha; - if let Some(tree) = prefetched_trees.get(tree_sha) { - for entry in &tree.tree { - if entry.entry_type == "blob" - && hash_mapping.get_blake3(&entry.sha).is_none() - && seen_blob_shas.insert(entry.sha.clone()) - { - blobs_to_download.push(( - entry.path.clone(), - entry.sha.clone(), - commit.sha.clone(), - )); - } - } - } - } - - if !blobs_to_download.is_empty() { - let pb_blobs = - crate::progress::file_bar(blobs_to_download.len() as u64, "Downloading blobs"); - let blob_results: Vec), String>> = - std::thread::scope(|s| { - let chunk_size = (blobs_to_download.len() / 8).max(1); - let mut handles = Vec::new(); - for chunk in blobs_to_download.chunks(chunk_size) { - let pb_blobs = &pb_blobs; - let handle = s.spawn(move || { - let mut results = Vec::new(); - for (path, sha1, commit_sha) in chunk { - match client.download_file(owner, repo_name, path, commit_sha) { - Ok(content) => { - pb_blobs.inc(1); - results.push(Ok((path.clone(), sha1.clone(), content))); - } - Err(e) => { - pb_blobs.inc(1); - results - .push(Err(format!("failed to download {}: {}", path, e))); - } - } - } - results - }); - handles.push(handle); - } - handles - .into_iter() - .flat_map(|h| h.join().unwrap_or_default()) - .collect() - }); - pb_blobs.finish_with_message(format!("{} blobs downloaded", blobs_to_download.len())); - - // Store in CAS and update hash_mapping (serial — CAS is not Sync) - for result in blob_results { - match result { - Ok((_, sha1, content)) => { - let (b3_hash, _) = store - .put_blob(&content) - .map_err(|e| SyncError::Other(e.to_string()))?; - hash_mapping.insert(&sha1, b3_hash); - blobs_downloaded += 1; - } - Err(msg) => { - crate::logging::warn(&msg); - } - } - } - } - - // --- Phase 4: Commit loop using build_tree_from_hash_map --- - let total = commits.len(); - let pb = crate::progress::file_bar(total as u64, "Importing commits"); - - for commit in &commits { - pb.inc(1); - - // Skip if already mapped - if hash_mapping.get_blake3(&commit.sha).is_some() { - // Still populate sha_to_idx from existing data for parent resolution - if let Some(b3) = hash_mapping.get_blake3(&commit.sha) { - // Search for leaf with this hash - for idx in 0..repo.commit_count() { - if let Ok(Some(leaf)) = repo.get_leaf(idx) { - if leaf.hash() == b3 { - sha_to_idx.insert(commit.sha.clone(), idx); - break; - } - } - } - } - commits_skipped += 1; - continue; - } - - // Build tree using hash-based approach (Phase 4 optimization) - let tree_sha = &commit.commit.tree.sha; - let ivaldi_tree_hash = if let Some(&cached) = tree_cache.get(tree_sha) { - cached - } else { - // Look up pre-fetched tree, fall back to live fetch - let tree = match prefetched_trees.get(tree_sha) { - Some(t) => t.clone(), - None => client - .get_tree(owner, repo_name, tree_sha) - .map_err(SyncError::GitHub)?, - }; - - // Build hash map from tree entries — pure HashMap lookups, zero disk I/O - let mut hash_file_map: BTreeMap = BTreeMap::new(); - for entry in &tree.tree { - if entry.entry_type == "blob" { - if let Some(b3) = hash_mapping.get_blake3(&entry.sha) { - hash_file_map.insert(entry.path.clone(), b3); - } - // else: blob wasn't downloaded (error during batch) — skip - } - } - - // Build Merkle tree from hashes only — NO blob content reads - let tree_hash = store - .build_tree_from_hash_map(&hash_file_map) - .map_err(|e| SyncError::Other(e.to_string()))?; - tree_cache.insert(tree_sha.clone(), tree_hash); - tree_hash - }; - - // Parse author and timestamp - let author = format!( - "{} <{}>", - commit.commit.author.name, commit.commit.author.email - ); - let time_unix = commit - .commit - .author - .date - .as_deref() - .and_then(parse_iso8601_to_unix) - .unwrap_or(0); - - // Resolve parent indices - let prev_idx = if !commit.parents.is_empty() { - sha_to_idx - .get(&commit.parents[0].sha) - .copied() - .unwrap_or(crate::leaf::NO_PARENT) - } else { - crate::leaf::NO_PARENT - }; - - let merge_idxs: Vec = commit - .parents - .iter() - .skip(1) - .filter_map(|p| sha_to_idx.get(&p.sha).copied()) - .collect(); - - // Build leaf - let mut leaf = Leaf::new( - ivaldi_tree_hash, - local_timeline, - &author, - time_unix, - &commit.commit.message, - ); - leaf.prev_idx = prev_idx; - leaf.merge_idxs = merge_idxs; - - // Commit raw - let result = repo - .commit_raw(leaf, local_timeline) - .map_err(|e| SyncError::Other(e.to_string()))?; - - // Record mappings - hash_mapping.insert(&commit.sha, result.hash); - sha_to_idx.insert(commit.sha.clone(), result.index); - commits_imported += 1; - } - pb.finish_with_message(format!( - "{} commits imported, {} skipped", - commits_imported, commits_skipped - )); - - // Save hash mapping - hash_mapping - .save() - .map_err(|e| SyncError::Other(e.to_string()))?; - - Ok(ImportResult { - commits_imported, - commits_skipped, - blobs_downloaded, - timeline: local_timeline.to_string(), - }) -} - -/// Result of a sync (delta update) operation. -#[derive(Debug)] -pub struct SyncResult { - pub added: Vec, - pub modified: Vec, - pub deleted: Vec, - pub no_changes: bool, - pub was_fast_forward: bool, - pub was_fused: bool, - pub conflicts: Vec, -} - -/// Sync — smart incremental update of a local timeline from remote. -/// -/// Detects whether the local and remote have diverged: -/// - **Up to date:** no new remote commits → no-op -/// - **Fast-forward:** remote has new commits, local hasn't diverged → import + advance -/// - **Diverged:** both have new commits → auto-fuse (Ivaldi's auto-merge philosophy) -pub fn sync_timeline( - client: &GitHubClient, - repo: &mut Repo, - owner: &str, - repo_name: &str, - timeline: &str, -) -> Result { - let branches = client - .list_branches(owner, repo_name) - .map_err(SyncError::GitHub)?; - let branch = branches - .iter() - .find(|b| b.name == timeline) - .ok_or_else(|| SyncError::Other(format!("remote branch '{}' not found", timeline)))?; - - let mut hash_mapping = HashMapping::new(&repo.ivaldi_dir); - - // Fetch remote commits - let remote_commits = client - .list_commits(owner, repo_name, timeline, 0) - .map_err(SyncError::GitHub)?; - - if remote_commits.is_empty() { - return Ok(SyncResult { - added: vec![], - modified: vec![], - deleted: vec![], - no_changes: true, - was_fast_forward: false, - was_fused: false, - conflicts: vec![], - }); - } - - // Get local head BEFORE import so we can constrain ancestor search - let local_head_idx = repo - .get_timeline_head(timeline) - .map_err(|e| SyncError::Other(e.to_string()))?; - - // Build set of leaf indices reachable from local timeline head. - // This prevents matching commits from OTHER timelines that happen to - // be in the hash_mapping (e.g. after uploading a feature branch whose - // commits later appear on main via a merge). - let local_reachable: BTreeSet = { - let mut reachable = BTreeSet::new(); - if let Some(head) = local_head_idx { - let mut cur = Some(head); - while let Some(idx) = cur { - reachable.insert(idx); - if let Ok(Some(leaf)) = repo.get_leaf(idx) { - // Follow both prev_idx and merge parents - for &midx in &leaf.merge_idxs { - // Shallow: just add direct merge parents - reachable.insert(midx); - } - cur = if leaf.has_parent() { - Some(leaf.prev_idx) - } else { - None - }; - } else { - break; - } - } - } - reachable - }; - - // Find common ancestor: walk remote commits newest→oldest, check hash mapping. - // Only accept leaves that are reachable from the local timeline head. - let mut common_ancestor_sha: Option = None; - let mut common_ancestor_idx: Option = None; - // Track commits with stale mappings so we skip them on re-search - let mut stale_shas: BTreeSet = BTreeSet::new(); - - for commit in &remote_commits { - if stale_shas.contains(&commit.sha) { - continue; - } - if let Some(b3) = hash_mapping.get_blake3(&commit.sha) { - // Find leaf index with this hash - for idx in 0..repo.commit_count() { - if let Ok(Some(leaf)) = repo.get_leaf(idx) { - if leaf.hash() == b3 && local_reachable.contains(&idx) { - common_ancestor_sha = Some(commit.sha.clone()); - common_ancestor_idx = Some(idx); - break; - } - } - } - if common_ancestor_idx.is_some() { - break; - } - } - } - - // Stale-mapping detection: when the common ancestor IS the remote tip - // (i.e., sync would say "up to date"), verify that the local tree - // actually matches the remote tree. A mismatch means a previous buggy - // sync created a wrong fuse commit and mapped the remote tip to it. - if common_ancestor_sha.as_ref() == Some(&remote_commits[0].sha) { - if let Some(ca_idx) = common_ancestor_idx { - let remote_tip_tree_sha = &remote_commits[0].commit.tree.sha; - let remote_tree = client - .get_tree(owner, repo_name, remote_tip_tree_sha) - .map_err(SyncError::GitHub)?; - let remote_paths: BTreeSet<&str> = remote_tree - .tree - .iter() - .filter(|e| e.entry_type == "blob") - .map(|e| e.path.as_str()) - .collect(); - - let verify_cas = FileCas::new(repo.ivaldi_dir.join("objects")) - .map_err(|e| SyncError::Other(e.to_string()))?; - let verify_store = FsStore::new(&verify_cas); - let local_files = get_tree_files(repo, &verify_store, ca_idx)?; - let local_paths: BTreeSet<&str> = local_files.keys().map(|s| s.as_str()).collect(); - - if remote_paths != local_paths { - // Stale mapping: remove it and re-search for the real ancestor - let stale_sha = remote_commits[0].sha.clone(); - hash_mapping.remove_sha1(&stale_sha); - hash_mapping - .save() - .map_err(|e| SyncError::Other(e.to_string()))?; - stale_shas.insert(stale_sha); - common_ancestor_sha = None; - common_ancestor_idx = None; - - for commit in &remote_commits { - if stale_shas.contains(&commit.sha) { - continue; - } - if let Some(b3) = hash_mapping.get_blake3(&commit.sha) { - for idx in 0..repo.commit_count() { - if let Ok(Some(leaf)) = repo.get_leaf(idx) { - if leaf.hash() == b3 && local_reachable.contains(&idx) { - common_ancestor_sha = Some(commit.sha.clone()); - common_ancestor_idx = Some(idx); - break; - } - } - } - if common_ancestor_idx.is_some() { - break; - } - } - } - } - } - } - - // Count new remote commits (those before the common ancestor in the list) - let new_remote_count = if let Some(ref ca_sha) = common_ancestor_sha { - remote_commits - .iter() - .take_while(|c| c.sha != *ca_sha) - .count() - } else { - remote_commits.len() - }; - let new_local_count = match (local_head_idx, common_ancestor_idx) { - (Some(head), Some(ancestor)) => { - // Walk from head back to ancestor, counting steps - let mut count = 0u64; - let mut cur = Some(head); - while let Some(idx) = cur { - if idx == ancestor { - break; - } - if let Ok(Some(leaf)) = repo.get_leaf(idx) { - count += 1; - cur = if leaf.has_parent() { - Some(leaf.prev_idx) - } else { - None - }; - } else { - break; - } - } - count - } - (Some(_), None) => { - // No common ancestor: all local commits are "new" - let history = repo - .walk_history(timeline) - .map_err(|e| SyncError::Other(e.to_string()))?; - history.len() as u64 - } - _ => 0, - }; - - if new_remote_count == 0 { - return Ok(SyncResult { - added: vec![], - modified: vec![], - deleted: vec![], - no_changes: true, - was_fast_forward: false, - was_fused: false, - conflicts: vec![], - }); - } - - // Classify: fast-forward or diverged - let is_fast_forward = new_local_count == 0; - - if is_fast_forward { - // Fast-forward: import remote commits + update workspace - let _import = import_full_history(client, repo, owner, repo_name, timeline, 0)?; - - // Compute file changes for the result - let cas = FileCas::new(repo.ivaldi_dir.join("objects")) - .map_err(|e| SyncError::Other(e.to_string()))?; - let store = FsStore::new(&cas); - - let (added, modified, deleted) = - compute_workspace_delta(repo, &store, timeline, common_ancestor_idx)?; - - // Update workspace files - checkout_tree_to_workspace(repo, &store, timeline)?; - - return Ok(SyncResult { - added, - modified, - deleted, - no_changes: false, - was_fast_forward: true, - was_fused: false, - conflicts: vec![], - }); - } - - // Diverged: import remote commits into temp timeline, then auto-fuse - let temp_timeline = format!("__sync_{}", timeline); - - // Create temp timeline from common ancestor if known - if let Some(ancestor_idx) = common_ancestor_idx { - // Create temp timeline pointing at common ancestor - let ref_path = repo.ivaldi_dir.join("refs/heads").join(&temp_timeline); - if let Some(parent) = ref_path.parent() { - fs::create_dir_all(parent).ok(); - } - fs::write(&ref_path, "").ok(); - repo.store - .set_timeline_head(&temp_timeline, ancestor_idx) - .map_err(|e| SyncError::Other(format!("store: {}", e)))?; - } - - // Import remote history into temp timeline (fetch from real remote branch) - let _import = - import_full_history_into(client, repo, owner, repo_name, timeline, &temp_timeline, 0)?; - - // Get file sets for three-way merge - let cas = FileCas::new(repo.ivaldi_dir.join("objects")) - .map_err(|e| SyncError::Other(e.to_string()))?; - let store = FsStore::new(&cas); - - let base_files = if let Some(ancestor_idx) = common_ancestor_idx { - get_tree_files(repo, &store, ancestor_idx)? - } else { - BTreeMap::new() - }; - - let our_files = if let Some(head_idx) = local_head_idx { - get_tree_files(repo, &store, head_idx)? - } else { - BTreeMap::new() - }; - - let their_head_idx = repo - .get_timeline_head(&temp_timeline) - .map_err(|e| SyncError::Other(e.to_string()))?; - let their_files = if let Some(idx) = their_head_idx { - get_tree_files(repo, &store, idx)? - } else { - BTreeMap::new() - }; - - // Auto-fuse - let fuse_result = crate::fuse::FuseEngine::fuse( - &store, - &base_files, - &our_files, - &their_files, - crate::fuse::Strategy::Auto, - ); - - if fuse_result.success { - // Build merged tree - let merged_tree = store - .build_tree_from_hash_map(&fuse_result.merged_files) - .map_err(|e| SyncError::Other(e.to_string()))?; - - // Create fuse commit - let our_head = local_head_idx.unwrap_or(crate::leaf::NO_PARENT); - let their_head = their_head_idx.unwrap_or(crate::leaf::NO_PARENT); - - let mut fuse_leaf = Leaf::new( - merged_tree, - timeline, - "ivaldi-sync", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64, - &format!( - "Fused sync from {}/{} (branch: {})", - owner, repo_name, timeline - ), - ); - fuse_leaf.prev_idx = our_head; - if their_head != crate::leaf::NO_PARENT { - fuse_leaf.merge_idxs = vec![their_head]; - } - - repo.commit_raw(fuse_leaf, timeline) - .map_err(|e| SyncError::Other(e.to_string()))?; - - // Update workspace - checkout_tree_to_workspace(repo, &store, timeline)?; - - // Map remote tip SHA - let mut hash_mapping = HashMapping::new(&repo.ivaldi_dir); - hash_mapping.insert( - &branch.commit.sha, - repo.get_leaf( - repo.get_timeline_head(timeline) - .map_err(|e| SyncError::Other(e.to_string()))? - .unwrap(), - ) - .map_err(|e| SyncError::Other(e.to_string()))? - .unwrap() - .hash(), - ); - hash_mapping - .save() - .map_err(|e| SyncError::Other(e.to_string()))?; - - // Clean up temp timeline - let _ = repo.store.remove_timeline_head(&temp_timeline); - let _ = fs::remove_file(repo.ivaldi_dir.join("refs/heads").join(&temp_timeline)); - - let (added, modified, deleted) = - compute_file_changes(&base_files, &fuse_result.merged_files); - - Ok(SyncResult { - added, - modified, - deleted, - no_changes: false, - was_fast_forward: false, - was_fused: true, - conflicts: vec![], - }) - } else { - // Conflicts — save merge state, report - let conflicts: Vec = fuse_result - .conflicts - .iter() - .map(|c| c.path.clone()) - .collect(); - - let merge_state = crate::repo::MergeState { - source_timeline: temp_timeline.clone(), - target_timeline: timeline.to_string(), - strategy: "auto".into(), - conflicts: conflicts.clone(), - }; - repo.save_merge_state(&merge_state) - .map_err(|e| SyncError::Other(e.to_string()))?; - - Ok(SyncResult { - added: vec![], - modified: vec![], - deleted: vec![], - no_changes: false, - was_fast_forward: false, - was_fused: false, - conflicts, - }) - } -} - -/// Get the file set (path → B3Hash) from a leaf's tree. -fn get_tree_files( - repo: &Repo, - store: &FsStore<'_>, - leaf_idx: u64, -) -> Result, SyncError> { - let leaf = repo - .get_leaf(leaf_idx) - .map_err(|e| SyncError::Other(e.to_string()))? - .ok_or_else(|| SyncError::Other(format!("leaf {} not found", leaf_idx)))?; - - let mut files = BTreeMap::new(); - collect_tree_files(store, leaf.tree_root, "", &mut files) - .map_err(|e| SyncError::Other(e.to_string()))?; - Ok(files) -} - -/// Compute workspace delta between current head and a prior ancestor. -fn compute_workspace_delta( - repo: &Repo, - store: &FsStore<'_>, - timeline: &str, - ancestor_idx: Option, -) -> Result<(Vec, Vec, Vec), SyncError> { - let old_files = if let Some(idx) = ancestor_idx { - get_tree_files(repo, store, idx)? - } else { - BTreeMap::new() - }; - - let new_head = repo - .get_timeline_head(timeline) - .map_err(|e| SyncError::Other(e.to_string()))?; - let new_files = if let Some(idx) = new_head { - get_tree_files(repo, store, idx)? - } else { - BTreeMap::new() - }; - - Ok(compute_file_changes(&old_files, &new_files)) -} - -/// Compute added/modified/deleted file lists between two file sets. -fn compute_file_changes( - old: &BTreeMap, - new: &BTreeMap, -) -> (Vec, Vec, Vec) { - let mut added = Vec::new(); - let mut modified = Vec::new(); - let mut deleted = Vec::new(); - - for (path, hash) in new { - match old.get(path) { - None => added.push(path.clone()), - Some(old_hash) if old_hash != hash => modified.push(path.clone()), - _ => {} - } - } - for path in old.keys() { - if !new.contains_key(path) { - deleted.push(path.clone()); - } - } - - added.sort(); - modified.sort(); - deleted.sort(); - (added, modified, deleted) -} - -/// Checkout the tip tree of a timeline to the workspace directory. -/// -/// Writes all files from the target tree, deletes workspace files that are -/// no longer in the tree (respecting `.ivaldiignore`), and cleans up empty -/// parent directories left behind. Returns the number of files in the -/// target tree. -fn checkout_tree_to_workspace( - repo: &Repo, - store: &FsStore<'_>, - timeline: &str, -) -> Result { - let head_idx = repo - .get_timeline_head(timeline) - .map_err(|e| SyncError::Other(e.to_string()))? - .ok_or_else(|| SyncError::Other("no head to checkout".into()))?; - - let head_leaf = repo - .get_leaf(head_idx) - .map_err(|e| SyncError::Other(e.to_string()))? - .ok_or_else(|| SyncError::Other("corrupt head leaf".into()))?; - - let mut files = BTreeMap::new(); - collect_tree_files(store, head_leaf.tree_root, "", &mut files) - .map_err(|e| SyncError::Other(e.to_string()))?; - - // Write / update files from the target tree - for (path, blob_hash) in &files { - let (_, content) = store - .load_blob(*blob_hash) - .map_err(|e| SyncError::Other(e.to_string()))?; - let file_path = repo.work_dir.join(path); - - let should_write = if file_path.exists() { - let existing = fs::read(&file_path).map_err(|e| SyncError::Other(e.to_string()))?; - existing != content - } else { - true - }; - - if should_write { - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).ok(); - } - fs::write(&file_path, &content).map_err(|e| SyncError::Other(e.to_string()))?; - } - } - - // Delete workspace files that are no longer in the target tree - let ignore_cache = ignore::load_pattern_cache(&repo.work_dir); - let current_files = scan_workspace_files(&repo.work_dir, "", &ignore_cache); - let target_set: BTreeSet<&str> = files.keys().map(|s| s.as_str()).collect(); - - for path in ¤t_files { - if !target_set.contains(path.as_str()) { - let full_path = repo.work_dir.join(path); - let _ = fs::remove_file(&full_path); - // Clean up empty parent directories - let mut dir = full_path.parent(); - while let Some(d) = dir { - if d == repo.work_dir { - break; - } - if fs::read_dir(d) - .map(|mut r| r.next().is_none()) - .unwrap_or(false) - { - let _ = fs::remove_dir(d); - dir = d.parent(); - } else { - break; - } - } - } - } - - let count = files.len(); - Ok(count) -} - -/// Recursively scan workspace files, respecting ignore patterns and -/// skipping the `.ivaldi/` directory. Returns sorted relative paths. -fn scan_workspace_files( - root: &Path, - prefix: &str, - ignore_cache: &ignore::PatternCache, -) -> Vec { - let mut out = Vec::new(); - scan_workspace_dir(root, root, prefix, ignore_cache, &mut out); - out.sort(); - out -} - -fn scan_workspace_dir( - root: &Path, - dir: &Path, - prefix: &str, - ignore_cache: &ignore::PatternCache, - out: &mut Vec, -) { - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - let rel = if prefix.is_empty() { - name.clone() - } else { - format!("{}/{}", prefix, name) - }; - - // Skip .ivaldi directory - if rel == ".ivaldi" || rel.starts_with(".ivaldi/") { - continue; - } - - if ignore_cache.is_ignored(&rel) { - continue; - } - - let ft = match entry.file_type() { - Ok(ft) => ft, - Err(_) => continue, - }; - - if ft.is_dir() { - scan_workspace_dir(root, &entry.path(), &rel, ignore_cache, out); - } else if ft.is_file() { - out.push(rel); - } - } -} - -/// Resolve the GitHub parent SHA(s) for a new commit. -/// -/// Priority: -/// 1. Branch already exists on GitHub AND not force-pushing → use its tip SHA -/// 2. Walk Ivaldi leaf chain via `prev_idx` backwards until a mapped commit is found, -/// AND collect mapped merge parents. Returns ALL resolved parents so GitHub -/// gets correct merge topology. -/// 3. Fallback → no parents mapped → root commit -fn resolve_github_parent( - repo: &Repo, - head_leaf: &crate::leaf::Leaf, - hash_mapping: &HashMapping, - existing_branch_sha: Option<&str>, - force: bool, -) -> Vec { - // Priority 1: branch already exists on GitHub AND not force-pushing - if !force { - if let Some(sha) = existing_branch_sha { - return vec![sha.to_string()]; - } - } - - let mut parents = Vec::new(); - - // Walk prev_idx chain backwards until we find a mapped ancestor - if head_leaf.has_parent() { - let mut current_idx = head_leaf.prev_idx; - let mut depth = 0u32; - const MAX_WALK_DEPTH: u32 = 1000; - - while depth < MAX_WALK_DEPTH { - if let Ok(Some(ancestor)) = repo.get_leaf(current_idx) { - let ancestor_blake3 = ancestor.hash(); - if let Some(sha1) = hash_mapping.get_sha1(ancestor_blake3) { - parents.push(sha1.to_string()); - break; - } - // Keep walking if this ancestor also has a parent - if ancestor.has_parent() { - current_idx = ancestor.prev_idx; - depth += 1; - } else { - break; - } - } else { - break; - } - } - } - - // Also resolve merge parents (from fuse operations) - for &merge_idx in &head_leaf.merge_idxs { - // Walk each merge parent chain backwards too - let mut current_idx = merge_idx; - let mut depth = 0u32; - const MAX_WALK_DEPTH: u32 = 1000; - - while depth < MAX_WALK_DEPTH { - if let Ok(Some(ancestor)) = repo.get_leaf(current_idx) { - let ancestor_blake3 = ancestor.hash(); - if let Some(sha1) = hash_mapping.get_sha1(ancestor_blake3) { - let sha1_str = sha1.to_string(); - if !parents.contains(&sha1_str) { - parents.push(sha1_str); - } - break; - } - if ancestor.has_parent() { - current_idx = ancestor.prev_idx; - depth += 1; - } else { - break; - } - } else { - break; - } - } - } - - if parents.is_empty() && head_leaf.has_parent() { - eprintln!("Warning: no GitHub SHA1 mapping found in ancestor chain — creating root commit",); - } - - parents -} - -#[derive(Debug, thiserror::Error)] -pub enum SyncError { - #[error("GitHub error: {0}")] - GitHub(#[from] GitHubError), - #[error("{0}")] - Other(String), -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cas::Cas; - use crate::hash::B3Hash; - use crate::leaf::Leaf; - use std::fs; - - // -- ISO 8601 parsing tests -- - - #[test] - fn parse_iso8601_utc() { - let ts = parse_iso8601_to_unix("2024-01-15T10:30:00Z").unwrap(); - assert_eq!(ts, 1705314600); - } - - #[test] - fn parse_iso8601_positive_offset() { - let ts = parse_iso8601_to_unix("2024-01-15T10:30:00+05:30").unwrap(); - // 10:30 at +05:30 = 05:00 UTC → 1705314600 - 5*3600 - 30*60 - assert_eq!(ts, 1705294800); - } - - #[test] - fn ensure_download_target_creates_missing_directory() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("clone-target"); - assert!(!target.exists()); - - let created = ensure_download_target(&target).unwrap(); - - assert!(created); - assert!(target.exists()); - } - - #[test] - fn ensure_download_target_keeps_existing_directory() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("existing"); - fs::create_dir_all(&target).unwrap(); - - let created = ensure_download_target(&target).unwrap(); - - assert!(!created); - assert!(target.exists()); - } - - #[test] - fn cleanup_failed_download_target_removes_directory_tree() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("partial"); - fs::create_dir_all(target.join(".ivaldi")).unwrap(); - fs::write(target.join(".ivaldi").join("config"), "partial").unwrap(); - - cleanup_failed_download_target(&target); - - assert!(!target.exists()); - } - - #[test] - fn parse_iso8601_negative_offset() { - let ts = parse_iso8601_to_unix("2024-01-15T10:30:00-05:00").unwrap(); - // 10:30 at -05:00 = 15:30 UTC → 1705314600 + 5*3600 - assert_eq!(ts, 1705332600); - } - - #[test] - fn parse_iso8601_epoch() { - let ts = parse_iso8601_to_unix("1970-01-01T00:00:00Z").unwrap(); - assert_eq!(ts, 0); - } - - #[test] - fn parse_iso8601_invalid() { - assert!(parse_iso8601_to_unix("not a date").is_none()); - assert!(parse_iso8601_to_unix("").is_none()); - } - - // -- ImportResult structure test -- - - #[test] - fn import_result_structure() { - let r = ImportResult { - commits_imported: 50, - commits_skipped: 5, - blobs_downloaded: 200, - timeline: "main".into(), - }; - assert_eq!(r.commits_imported, 50); - assert_eq!(r.timeline, "main"); - } - - // -- SyncResult new fields -- - - #[test] - fn sync_result_new_fields() { - let r = SyncResult { - added: vec![], - modified: vec![], - deleted: vec![], - no_changes: true, - was_fast_forward: false, - was_fused: false, - conflicts: vec![], - }; - assert!(r.no_changes); - assert!(!r.was_fast_forward); - assert!(!r.was_fused); - assert!(r.conflicts.is_empty()); - } - - #[test] - fn download_result_structure() { - let r = DownloadResult { - files_downloaded: 10, - commits_imported: 1, - timelines_created: vec!["main".into()], - }; - assert_eq!(r.files_downloaded, 10); - } - - #[test] - fn upload_result_structure() { - let r = UploadResult { - files_uploaded: 5, - commit_sha: "abc123".into(), - branch: "main".into(), - }; - assert_eq!(r.branch, "main"); - } - - #[test] - fn resolve_parent_existing_branch_takes_priority() { - // When a branch already exists on GitHub, use its tip SHA - let dir = tempfile::tempdir().unwrap(); - let work_dir = dir.path(); - crate::forge::forge(work_dir).unwrap(); - let repo = Repo::open(work_dir).unwrap(); - - let leaf = Leaf::new(B3Hash::digest(b"tree"), "feature", "author", 1000, "msg"); - let mapping = HashMapping::new(&repo.ivaldi_dir); - - let parents = resolve_github_parent( - &repo, - &leaf, - &mapping, - Some("existing_sha_on_github"), - false, - ); - assert_eq!(parents, vec!["existing_sha_on_github"]); - } - - #[test] - fn resolve_parent_new_branch_with_mapped_parent() { - // New branch where the parent leaf has a known GitHub SHA mapping - let dir = tempfile::tempdir().unwrap(); - let work_dir = dir.path(); - crate::forge::forge(work_dir).unwrap(); - let mut repo = Repo::open(work_dir).unwrap(); - - // Create a parent commit on main - let parent_tree = B3Hash::digest(b"parent tree"); - repo.commit(parent_tree, "author", "parent commit").unwrap(); - - // Create a child commit (simulating branch) - let child_tree = B3Hash::digest(b"child tree"); - repo.commit(child_tree, "author", "child commit").unwrap(); - - // Get the child leaf - let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); - let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); - - // Get the parent leaf and map its BLAKE3 hash to a fake GitHub SHA1 - let parent_leaf = repo.get_leaf(head_leaf.prev_idx).unwrap().unwrap(); - let parent_blake3 = parent_leaf.hash(); - let fake_github_sha = "aabbccdd00112233445566778899aabbccddeeff"; - - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - mapping.insert(fake_github_sha, parent_blake3); - - // No existing branch on GitHub (None) → should resolve via hash mapping - let parents = resolve_github_parent(&repo, &head_leaf, &mapping, None, false); - assert_eq!(parents, vec![fake_github_sha]); - } - - #[test] - fn resolve_parent_new_branch_unmapped_parent_returns_empty() { - // New branch where the parent leaf has no GitHub SHA mapping → root commit - let dir = tempfile::tempdir().unwrap(); - let work_dir = dir.path(); - crate::forge::forge(work_dir).unwrap(); - let mut repo = Repo::open(work_dir).unwrap(); - - // Two commits on main - repo.commit(B3Hash::digest(b"t1"), "author", "first") - .unwrap(); - repo.commit(B3Hash::digest(b"t2"), "author", "second") - .unwrap(); - - let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); - let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); - assert!(head_leaf.has_parent()); - - // Empty mapping — parent was never uploaded - let mapping = HashMapping::new(&repo.ivaldi_dir); - - let parents = resolve_github_parent(&repo, &head_leaf, &mapping, None, false); - assert!( - parents.is_empty(), - "should be root commit when parent not mapped" - ); - } - - #[test] - fn resolve_parent_no_parent_leaf_returns_empty() { - // First commit on a timeline (no parent) → root commit - let dir = tempfile::tempdir().unwrap(); - let work_dir = dir.path(); - crate::forge::forge(work_dir).unwrap(); - let mut repo = Repo::open(work_dir).unwrap(); - - repo.commit(B3Hash::digest(b"tree"), "author", "initial") - .unwrap(); - - let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); - let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); - assert!(!head_leaf.has_parent()); - - let mapping = HashMapping::new(&repo.ivaldi_dir); - - let parents = resolve_github_parent(&repo, &head_leaf, &mapping, None, false); - assert!(parents.is_empty(), "first commit should have no parent"); - } - - #[test] - fn resolve_parent_existing_branch_overrides_mapping() { - // Even if parent leaf is mapped, existing branch SHA takes priority - let dir = tempfile::tempdir().unwrap(); - let work_dir = dir.path(); - crate::forge::forge(work_dir).unwrap(); - let mut repo = Repo::open(work_dir).unwrap(); - - repo.commit(B3Hash::digest(b"t1"), "author", "first") - .unwrap(); - repo.commit(B3Hash::digest(b"t2"), "author", "second") - .unwrap(); - - let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); - let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); - - let parent_leaf = repo.get_leaf(head_leaf.prev_idx).unwrap().unwrap(); - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - mapping.insert("mapped_parent_sha", parent_leaf.hash()); - - // Existing branch SHA should win over the mapping - let parents = - resolve_github_parent(&repo, &head_leaf, &mapping, Some("branch_tip_sha"), false); - assert_eq!(parents, vec!["branch_tip_sha"]); - } - - #[test] - fn resolve_parent_force_skips_existing_branch() { - // When force=true and existing branch SHA is provided, should walk leaf chain instead - let dir = tempfile::tempdir().unwrap(); - let work_dir = dir.path(); - crate::forge::forge(work_dir).unwrap(); - let mut repo = Repo::open(work_dir).unwrap(); - - repo.commit(B3Hash::digest(b"t1"), "author", "first") - .unwrap(); - repo.commit(B3Hash::digest(b"t2"), "author", "second") - .unwrap(); - - let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); - let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); - - let parent_leaf = repo.get_leaf(head_leaf.prev_idx).unwrap().unwrap(); - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - mapping.insert("mapped_parent_sha", parent_leaf.hash()); - - // force=true → should skip existing branch tip and walk leaf chain - let parents = resolve_github_parent( - &repo, - &head_leaf, - &mapping, - Some("old_broken_tip_sha"), - true, - ); - assert_eq!( - parents, - vec!["mapped_parent_sha"], - "force should skip existing branch tip and use mapped parent" - ); - } - - #[test] - fn resolve_parent_force_with_mapped_parent() { - // force=true + mapped parent → returns parent SHA from mapping, not existing branch tip - let dir = tempfile::tempdir().unwrap(); - let work_dir = dir.path(); - crate::forge::forge(work_dir).unwrap(); - let mut repo = Repo::open(work_dir).unwrap(); - - // Create parent (simulates synced main) - repo.commit(B3Hash::digest(b"main-tree"), "author", "synced main") - .unwrap(); - let main_head = repo.get_timeline_head("main").unwrap().unwrap(); - let main_leaf = repo.get_leaf(main_head).unwrap().unwrap(); - - // Create child on top (simulates feature after fuse) - repo.commit(B3Hash::digest(b"feature-tree"), "author", "feature work") - .unwrap(); - let feature_head = repo.get_timeline_head("main").unwrap().unwrap(); - let feature_leaf = repo.get_leaf(feature_head).unwrap().unwrap(); - - // Map main's leaf BLAKE3 → a GitHub SHA (as sync_timeline would do) - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - let main_github_sha = "1111111111222222222233333333334444444444"; - mapping.insert(main_github_sha, main_leaf.hash()); - - // force=true with an existing broken branch tip - let parents = resolve_github_parent( - &repo, - &feature_leaf, - &mapping, - Some("old_broken_branch_tip"), - true, - ); - assert_eq!( - parents, - vec![main_github_sha], - "force upload should resolve parent via leaf chain, not existing branch tip" - ); - } - - #[test] - fn resolve_parent_walks_backwards_through_unmapped() { - // Scenario: A → B → C (head), only A is mapped to GitHub - // Should walk C→B→A and find A's mapping - let dir = tempfile::tempdir().unwrap(); - crate::forge::forge(dir.path()).unwrap(); - let mut repo = Repo::open(dir.path()).unwrap(); - - // Commit A (will be mapped) - repo.commit(B3Hash::digest(b"t1"), "author", "commit A") - .unwrap(); - let a_leaf = repo.get_leaf(0).unwrap().unwrap(); - - // Commit B (unmapped) - repo.commit(B3Hash::digest(b"t2"), "author", "commit B") - .unwrap(); - - // Commit C (unmapped, this is the head) - repo.commit(B3Hash::digest(b"t3"), "author", "commit C") - .unwrap(); - - let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); - let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); - assert_eq!(head_leaf.prev_idx, 1); // points to B - - // Only map A - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - let a_sha = "aaaa111122223333444455556666777788889999"; - mapping.insert(a_sha, a_leaf.hash()); - - let parents = resolve_github_parent(&repo, &head_leaf, &mapping, None, false); - assert_eq!(parents, vec![a_sha], "should walk past B to find mapped A"); - } - - #[test] - fn resolve_parent_merge_returns_both_parents() { - // Scenario: merge commit with prev_idx=A (mapped) and merge_idx=B (mapped) - // Should return both SHA1s - let dir = tempfile::tempdir().unwrap(); - crate::forge::forge(dir.path()).unwrap(); - let mut repo = Repo::open(dir.path()).unwrap(); - - // Branch point - repo.commit(B3Hash::digest(b"base"), "author", "base") - .unwrap(); - - // "main" commit - repo.commit(B3Hash::digest(b"main-work"), "author", "main work") - .unwrap(); - let main_leaf = repo.get_leaf(1).unwrap().unwrap(); - - // "feature" commit (simulate by raw commit with prev=base) - let mut feat_leaf_data = - Leaf::new(B3Hash::digest(b"feat"), "main", "author", 1000, "feature"); - feat_leaf_data.prev_idx = 0; - let feat_result = repo.commit_raw(feat_leaf_data, "main").unwrap(); - let feat_leaf = repo.get_leaf(feat_result.index).unwrap().unwrap(); - - // Merge commit: prev_idx=main(1), merge_idxs=[feature(2)] - let mut merge_leaf_data = - Leaf::new(B3Hash::digest(b"merged"), "main", "author", 2000, "merge"); - merge_leaf_data.prev_idx = 1; - merge_leaf_data.merge_idxs = vec![feat_result.index]; - let merge_result = repo.commit_raw(merge_leaf_data, "main").unwrap(); - let merge_leaf = repo.get_leaf(merge_result.index).unwrap().unwrap(); - - // Map both parents - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - let main_sha = "1111111111111111111111111111111111111111"; - let feat_sha = "2222222222222222222222222222222222222222"; - mapping.insert(main_sha, main_leaf.hash()); - mapping.insert(feat_sha, feat_leaf.hash()); - - let parents = resolve_github_parent(&repo, &merge_leaf, &mapping, None, false); - assert_eq!(parents.len(), 2, "merge commit should resolve both parents"); - assert!(parents.contains(&main_sha.to_string())); - assert!(parents.contains(&feat_sha.to_string())); - } - - #[test] - fn resolve_parent_merge_walks_merge_parent_chain() { - // Scenario: merge commit with merge_idx pointing to unmapped commit - // whose parent IS mapped → should walk the merge parent chain - let dir = tempfile::tempdir().unwrap(); - crate::forge::forge(dir.path()).unwrap(); - let mut repo = Repo::open(dir.path()).unwrap(); - - // A (mapped) - repo.commit(B3Hash::digest(b"a"), "author", "A").unwrap(); - let a_leaf = repo.get_leaf(0).unwrap().unwrap(); - - // B (unmapped, parent=A) - repo.commit(B3Hash::digest(b"b"), "author", "B").unwrap(); - - // C (the merge parent, unmapped, parent=B) - repo.commit(B3Hash::digest(b"c"), "author", "C").unwrap(); - - // D (merge commit: prev=C, merge_idxs=[]) - // But we'll make a separate commit to be the "other branch" - let mut other = Leaf::new(B3Hash::digest(b"other"), "main", "author", 1000, "other"); - other.prev_idx = 0; // parent=A - let other_result = repo.commit_raw(other, "main").unwrap(); - - // Merge: prev=2(C), merge_idxs=[3(other)] - let mut merge = Leaf::new(B3Hash::digest(b"merge"), "main", "author", 2000, "merge"); - merge.prev_idx = 2; - merge.merge_idxs = vec![other_result.index]; - let merge_result = repo.commit_raw(merge, "main").unwrap(); - let merge_leaf = repo.get_leaf(merge_result.index).unwrap().unwrap(); - - // Only A is mapped - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - let a_sha = "aaaa000011112222333344445555666677778888"; - mapping.insert(a_sha, a_leaf.hash()); - - let parents = resolve_github_parent(&repo, &merge_leaf, &mapping, None, false); - // prev chain: C→B→A (mapped) → found - // merge chain: other→A (mapped) → found, but A is already in parents - assert_eq!(parents.len(), 1, "both chains converge on A"); - assert_eq!(parents[0], a_sha); - } - - // -- checkout_tree_to_workspace regression tests -- - - /// Helper: build a tree in the CAS from a map of path→content, commit it, - /// and return the repo + CAS for checkout testing. - fn setup_checkout_repo(dir: &Path, files: &BTreeMap>) -> (Repo, FileCas) { - crate::forge::forge(dir).unwrap(); - let mut repo = Repo::open(dir).unwrap(); - let ivaldi_dir = dir.join(".ivaldi"); - let cas = FileCas::new(ivaldi_dir.join("objects")).unwrap(); - let store = FsStore::new(&cas); - let tree_hash = store.build_tree_from_map(files).unwrap(); - repo.commit(tree_hash, "test-author", "test commit") - .unwrap(); - (repo, cas) - } - - #[test] - fn checkout_writes_new_files() { - let dir = tempfile::tempdir().unwrap(); - let mut files = BTreeMap::new(); - files.insert("a.txt".into(), b"hello a".to_vec()); - files.insert("b.txt".into(), b"hello b".to_vec()); - let (repo, cas) = setup_checkout_repo(dir.path(), &files); - let store = FsStore::new(&cas); - - let count = checkout_tree_to_workspace(&repo, &store, "main").unwrap(); - assert_eq!(count, 2); - assert_eq!( - fs::read_to_string(dir.path().join("a.txt")).unwrap(), - "hello a" - ); - assert_eq!( - fs::read_to_string(dir.path().join("b.txt")).unwrap(), - "hello b" - ); - } - - #[test] - fn checkout_deletes_removed_files() { - let dir = tempfile::tempdir().unwrap(); - - // Initial commit with A, B, C - let mut files = BTreeMap::new(); - files.insert("a.txt".into(), b"aaa".to_vec()); - files.insert("b.txt".into(), b"bbb".to_vec()); - files.insert("c.txt".into(), b"ccc".to_vec()); - let (mut repo, cas) = setup_checkout_repo(dir.path(), &files); - let store = FsStore::new(&cas); - - // Checkout first commit — all three files present - checkout_tree_to_workspace(&repo, &store, "main").unwrap(); - assert!(dir.path().join("c.txt").exists()); - - // Second commit with only A, B (C removed) - let mut files2 = BTreeMap::new(); - files2.insert("a.txt".into(), b"aaa".to_vec()); - files2.insert("b.txt".into(), b"bbb".to_vec()); - let tree2 = store.build_tree_from_map(&files2).unwrap(); - repo.commit(tree2, "test-author", "remove c").unwrap(); - - // Checkout second commit — C should be deleted - let count = checkout_tree_to_workspace(&repo, &store, "main").unwrap(); - assert_eq!(count, 2); - assert!(dir.path().join("a.txt").exists()); - assert!(dir.path().join("b.txt").exists()); - assert!( - !dir.path().join("c.txt").exists(), - "c.txt should be deleted" - ); - } - - #[test] - fn checkout_handles_modified_files() { - let dir = tempfile::tempdir().unwrap(); - - // Initial commit - let mut files = BTreeMap::new(); - files.insert("doc.txt".into(), b"version 1".to_vec()); - let (mut repo, cas) = setup_checkout_repo(dir.path(), &files); - let store = FsStore::new(&cas); - checkout_tree_to_workspace(&repo, &store, "main").unwrap(); - assert_eq!( - fs::read_to_string(dir.path().join("doc.txt")).unwrap(), - "version 1" - ); - - // Second commit with modified content - let mut files2 = BTreeMap::new(); - files2.insert("doc.txt".into(), b"version 2".to_vec()); - let tree2 = store.build_tree_from_map(&files2).unwrap(); - repo.commit(tree2, "test-author", "update doc").unwrap(); - - checkout_tree_to_workspace(&repo, &store, "main").unwrap(); - assert_eq!( - fs::read_to_string(dir.path().join("doc.txt")).unwrap(), - "version 2" - ); - } - - #[test] - fn checkout_preserves_ignored_files() { - let dir = tempfile::tempdir().unwrap(); - - // Create .ivaldiignore before forging so it's present - let ignore_path = dir.path().join(".ivaldiignore"); - fs::write(&ignore_path, "secret.key\n").unwrap(); - - // Initial commit with one tracked file - let mut files = BTreeMap::new(); - files.insert("a.txt".into(), b"tracked".to_vec()); - let (repo, cas) = setup_checkout_repo(dir.path(), &files); - let store = FsStore::new(&cas); - - // Place an ignored file in the workspace - fs::write(dir.path().join("secret.key"), "private data").unwrap(); - - // Re-write .ivaldiignore (forge may overwrite) - fs::write(&ignore_path, "secret.key\n").unwrap(); - - checkout_tree_to_workspace(&repo, &store, "main").unwrap(); - - // Ignored file should still be there - assert!( - dir.path().join("secret.key").exists(), - "ignored file should not be deleted by checkout" - ); - assert_eq!( - fs::read_to_string(dir.path().join("secret.key")).unwrap(), - "private data" - ); - } - - #[test] - fn checkout_cleans_empty_parent_dirs() { - let dir = tempfile::tempdir().unwrap(); - - // Commit with a file in a subdirectory - let mut files = BTreeMap::new(); - files.insert("a.txt".into(), b"root file".to_vec()); - files.insert("sub/deep.txt".into(), b"deep file".to_vec()); - let (mut repo, cas) = setup_checkout_repo(dir.path(), &files); - let store = FsStore::new(&cas); - checkout_tree_to_workspace(&repo, &store, "main").unwrap(); - assert!(dir.path().join("sub/deep.txt").exists()); - - // Second commit without the subdirectory file - let mut files2 = BTreeMap::new(); - files2.insert("a.txt".into(), b"root file".to_vec()); - let tree2 = store.build_tree_from_map(&files2).unwrap(); - repo.commit(tree2, "test-author", "remove sub/deep.txt") - .unwrap(); - - checkout_tree_to_workspace(&repo, &store, "main").unwrap(); - assert!(!dir.path().join("sub/deep.txt").exists()); - assert!( - !dir.path().join("sub").exists(), - "empty sub/ dir should be cleaned up" - ); - } - - #[test] - fn compute_delta_ignores_cross_timeline_ancestors() { - // Regression: when a feature branch was uploaded and later merged on - // GitHub, sync_timeline would pick the feature branch commit as the - // common ancestor. Because that leaf lives on a different local - // timeline, the divergence detector would over-count local commits, - // falsely triggering a fuse that deleted the merged files. - // - // The fix constrains the common-ancestor search to leaves reachable - // from the LOCAL timeline's head. - let dir = tempfile::tempdir().unwrap(); - crate::forge::forge(dir.path()).unwrap(); - let mut repo = Repo::open(dir.path()).unwrap(); - - // Commit on main (simulates the initial synced state) - let main_tree = B3Hash::digest(b"main-tree"); - repo.commit(main_tree, "author", "initial main").unwrap(); - let main_head = repo.get_timeline_head("main").unwrap().unwrap(); - - // Create a feature timeline with a different commit - let feat_tree = B3Hash::digest(b"feat-tree"); - let mut feat_leaf = Leaf::new(feat_tree, "feature", "author", 2000, "feat work"); - feat_leaf.prev_idx = crate::leaf::NO_PARENT; - let feat_result = repo.commit_raw(feat_leaf, "feature").unwrap(); - - // Map both to fake GitHub SHAs (simulating upload of both) - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - let main_sha = "aaaa111122223333444455556666777788889999"; - let feat_sha = "bbbb111122223333444455556666777788889999"; - let main_leaf = repo.get_leaf(main_head).unwrap().unwrap(); - mapping.insert(main_sha, main_leaf.hash()); - let feat_leaf_stored = repo.get_leaf(feat_result.index).unwrap().unwrap(); - mapping.insert(feat_sha, feat_leaf_stored.hash()); - - // Build local_reachable from main's head - let local_reachable: BTreeSet = { - let mut reachable = BTreeSet::new(); - let mut cur = Some(main_head); - while let Some(idx) = cur { - reachable.insert(idx); - if let Ok(Some(leaf)) = repo.get_leaf(idx) { - cur = if leaf.has_parent() { - Some(leaf.prev_idx) - } else { - None - }; - } else { - break; - } - } - reachable - }; - - // Feature leaf should NOT be in main's reachable set - assert!( - !local_reachable.contains(&feat_result.index), - "feature commit must not be reachable from main" - ); - // Main leaf SHOULD be reachable - assert!( - local_reachable.contains(&main_head), - "main head must be in its own reachable set" - ); - } - - #[test] - fn upload_rejects_security_blocked_files() { - use crate::cas::MemoryCas; - - let dir = tempfile::tempdir().unwrap(); - let ivaldi_dir = dir.path().join(".ivaldi"); - std::fs::create_dir_all(&ivaldi_dir).unwrap(); - - let cas = MemoryCas::new(); - let store = FsStore::new(&cas); - - // Build a file map containing a .env file - let mut files = BTreeMap::new(); - let content = b"SECRET=abc"; - let canonical = crate::fsmerkle::BlobNode::canonical_bytes(content); - let hash = B3Hash::digest(&canonical); - cas.put(hash, &canonical).unwrap(); - files.insert(".env".to_string(), hash); - - let client = GitHubClient::new(); - let mut mapping = HashMapping::new(&ivaldi_dir); - - let result = upload_blobs_parallel(&client, &store, &files, &mut mapping, "owner", "repo"); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("security-blocked"), - "expected security-blocked error, got: {err}" - ); - } - - // -- Commit fidelity helpers -- - - #[test] - fn split_author_separates_name_and_email() { - let (n, e) = split_author("Jane Doe "); - assert_eq!(n, "Jane Doe"); - assert_eq!(e, "jane@example.com"); - } - - #[test] - fn split_author_handles_missing_email() { - let (n, e) = split_author("Jane Doe"); - assert_eq!(n, "Jane Doe"); - assert_eq!(e, ""); - } - - #[test] - fn format_rfc3339_utc() { - // 1700000000 → 2023-11-14T22:13:20 UTC - assert_eq!( - format_rfc3339(1_700_000_000, "+0000"), - "2023-11-14T22:13:20+00:00" - ); - } - - #[test] - fn format_rfc3339_positive_offset_shifts_civil_time() { - // +01:00 shifts the wall clock forward by an hour. - assert_eq!( - format_rfc3339(1_700_000_000, "+0100"), - "2023-11-14T23:13:20+01:00" - ); - } - - #[test] - fn format_rfc3339_negative_offset_shifts_civil_time() { - // -05:30 shifts the wall clock backwards by 5h30m. - assert_eq!( - format_rfc3339(1_700_000_000, "-0530"), - "2023-11-14T16:43:20-05:30" - ); - } - - #[test] - fn format_rfc3339_epoch() { - assert_eq!( - format_rfc3339(0, "+0000"), - "1970-01-01T00:00:00+00:00" - ); - } - - #[test] - fn identity_for_author_uses_leaf_meta_tz_when_present() { - let mut leaf = Leaf::new( - B3Hash::digest(b"t"), - "main", - "Jane Doe ", - 1_700_000_000, - "msg", - ); - leaf.meta.insert("git.author_tz".into(), "+0530".into()); - let id = identity_for_author(&leaf); - assert_eq!(id.name, "Jane Doe"); - assert_eq!(id.email, "jane@example.com"); - assert!(id.date.ends_with("+05:30"), "got: {}", id.date); - } - - #[test] - fn identity_for_committer_prefers_leaf_meta() { - let mut leaf = Leaf::new( - B3Hash::digest(b"t"), - "main", - "Author ", - 1_700_000_000, - "msg", - ); - leaf.meta - .insert("git.committer".into(), "Bob ".into()); - leaf.meta - .insert("git.committer_time".into(), "1700001000".into()); - leaf.meta.insert("git.committer_tz".into(), "+0100".into()); - - let id = identity_for_committer(&leaf); - assert_eq!(id.name, "Bob"); - assert_eq!(id.email, "bob@x.com"); - assert!(id.date.ends_with("+01:00"), "got: {}", id.date); - // 1700001000 = 2023-11-14T22:30:00Z → at +01:00 = 23:30:00 - assert_eq!(id.date, "2023-11-14T23:30:00+01:00"); - } - - #[test] - fn identity_for_committer_falls_back_to_author_when_meta_missing() { - let leaf = Leaf::new( - B3Hash::digest(b"t"), - "main", - "Solo ", - 1_700_000_000, - "msg", - ); - let id = identity_for_committer(&leaf); - assert_eq!(id.name, "Solo"); - assert_eq!(id.email, "solo@x.com"); - } - - // -- Multi-commit walk -- - - #[test] - fn collect_unpushed_leaves_returns_full_chain_when_nothing_mapped() { - let dir = tempfile::tempdir().unwrap(); - crate::forge::forge(dir.path()).unwrap(); - let mut repo = Repo::open(dir.path()).unwrap(); - - let a = repo - .commit(B3Hash::digest(b"ta"), "author ", "A") - .unwrap(); - let b = repo - .commit(B3Hash::digest(b"tb"), "author ", "B") - .unwrap(); - let c = repo - .commit(B3Hash::digest(b"tc"), "author ", "C") - .unwrap(); - - let mapping = HashMapping::new(&repo.ivaldi_dir); - let chain = collect_unpushed_leaves(&repo, c.index, &mapping).unwrap(); - // Chronological: A, B, C - assert_eq!(chain, vec![a.index, b.index, c.index]); - } - - #[test] - fn collect_unpushed_leaves_stops_at_mapped_ancestor() { - let dir = tempfile::tempdir().unwrap(); - crate::forge::forge(dir.path()).unwrap(); - let mut repo = Repo::open(dir.path()).unwrap(); - - let a = repo - .commit(B3Hash::digest(b"ta"), "author ", "A") - .unwrap(); - let b = repo - .commit(B3Hash::digest(b"tb"), "author ", "B") - .unwrap(); - let c = repo - .commit(B3Hash::digest(b"tc"), "author ", "C") - .unwrap(); - - // Pretend A was already pushed to GitHub. - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - let a_leaf = repo.get_leaf(a.index).unwrap().unwrap(); - mapping.insert("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", a_leaf.hash()); - - let chain = collect_unpushed_leaves(&repo, c.index, &mapping).unwrap(); - // Replay only the unpushed suffix in chronological order. - assert_eq!(chain, vec![b.index, c.index]); - } - - #[test] - fn collect_unpushed_leaves_empty_when_head_already_mapped() { - let dir = tempfile::tempdir().unwrap(); - crate::forge::forge(dir.path()).unwrap(); - let mut repo = Repo::open(dir.path()).unwrap(); - - let a = repo - .commit(B3Hash::digest(b"ta"), "author ", "A") - .unwrap(); - let mut mapping = HashMapping::new(&repo.ivaldi_dir); - let a_leaf = repo.get_leaf(a.index).unwrap().unwrap(); - mapping.insert("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", a_leaf.hash()); - - let chain = collect_unpushed_leaves(&repo, a.index, &mapping).unwrap(); - assert!(chain.is_empty()); - } -} diff --git a/src/sync/import.rs b/src/sync/import.rs new file mode 100644 index 0000000..a11f538 --- /dev/null +++ b/src/sync/import.rs @@ -0,0 +1,504 @@ +//! Full commit-history import from GitHub into an Ivaldi repo. + +use std::collections::{BTreeMap, HashMap}; +use std::fs; + +use crate::cas::FileCas; +use crate::fsmerkle::FsStore; +use crate::github::{CommitInfo, GitHubClient, GitHubError, TreeResponse}; +use crate::hash::B3Hash; +use crate::leaf::Leaf; +use crate::remote::HashMapping; +use crate::repo::Repo; + +use super::SyncError; + +/// Result of downloading one blob: `(path, sha1, content)` on success, or +/// an error message on failure. +type BlobDownloadResult = Result<(String, String, Vec), String>; + +/// Result of a full history import. +#[derive(Debug)] +pub struct ImportResult { + pub commits_imported: usize, + pub commits_skipped: usize, + pub blobs_downloaded: usize, + pub timeline: String, +} + +/// Parse ISO 8601 date string to unix timestamp (no chrono dependency). +/// +/// Supports formats: `2024-01-15T10:30:00Z`, `2024-01-15T10:30:00+00:00`, +/// `2024-01-15T10:30:00-05:00`. +pub fn parse_iso8601_to_unix(s: &str) -> Option { + let s = s.trim(); + if s.len() < 19 { + return None; + } + + // Split date and time at 'T' + let (date_part, rest) = s.split_once('T')?; + let date_parts: Vec<&str> = date_part.split('-').collect(); + if date_parts.len() != 3 { + return None; + } + let year: i64 = date_parts[0].parse().ok()?; + let month: i64 = date_parts[1].parse().ok()?; + let day: i64 = date_parts[2].parse().ok()?; + + // Parse time, stripping timezone + let (time_str, tz_offset_secs) = if let Some(stripped) = rest.strip_suffix('Z') { + (stripped, 0i64) + } else if let Some(plus_pos) = rest[8..].find('+') { + let idx = 8 + plus_pos; + let tz = &rest[idx + 1..]; + let tz_parts: Vec<&str> = tz.split(':').collect(); + let hours: i64 = tz_parts.first()?.parse().ok()?; + let mins: i64 = tz_parts.get(1).and_then(|m| m.parse().ok()).unwrap_or(0); + (&rest[..idx], hours * 3600 + mins * 60) + } else if let Some(minus_pos) = rest[8..].find('-') { + let idx = 8 + minus_pos; + let tz = &rest[idx + 1..]; + let tz_parts: Vec<&str> = tz.split(':').collect(); + let hours: i64 = tz_parts.first()?.parse().ok()?; + let mins: i64 = tz_parts.get(1).and_then(|m| m.parse().ok()).unwrap_or(0); + (&rest[..idx], -(hours * 3600 + mins * 60)) + } else { + (rest, 0i64) + }; + + let time_parts: Vec<&str> = time_str.split(':').collect(); + if time_parts.len() < 2 { + return None; + } + let hour: i64 = time_parts[0].parse().ok()?; + let min: i64 = time_parts[1].parse().ok()?; + let sec: i64 = time_parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + + // Convert to unix timestamp (days since epoch) + // Simplified algorithm for dates after 1970 + let mut days: i64 = 0; + for y in 1970..year { + days += if is_leap_year(y) { 366 } else { 365 }; + } + let month_days = [ + 31, + if is_leap_year(year) { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + for md in month_days.iter().take((month - 1) as usize) { + days += *md as i64; + } + days += day - 1; + + Some(days * 86400 + hour * 3600 + min * 60 + sec - tz_offset_secs) +} + +fn is_leap_year(y: i64) -> bool { + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +} + +/// Import full commit history from GitHub into an Ivaldi repo. +/// +/// Walks commits oldest-first, creates Ivaldi leaves preserving parent chains, +/// author info, timestamps, and tree content. +/// +/// `branch` is the remote GitHub branch name used for API calls. +/// `local_timeline` optionally overrides the local timeline name to store commits under +/// (defaults to `branch` if `None`). This is used when importing into a temp timeline +/// during diverged sync. +pub fn import_full_history( + client: &GitHubClient, + repo: &mut Repo, + owner: &str, + repo_name: &str, + branch: &str, + depth: usize, +) -> Result { + import_full_history_into(client, repo, owner, repo_name, branch, branch, depth) +} + +/// Like `import_full_history` but stores commits under `local_timeline` instead of `branch`. +pub(super) fn import_full_history_into( + client: &GitHubClient, + repo: &mut Repo, + owner: &str, + repo_name: &str, + remote_branch: &str, + local_timeline: &str, + depth: usize, +) -> Result { + // Fetch commits (newest-first from GitHub), then reverse to oldest-first + // for correct parent ordering. + let mut commits = client.list_commits(owner, repo_name, remote_branch, depth)?; + commits.reverse(); + + let cas = FileCas::new(repo.ivaldi_dir.join("objects"))?; + let store = FsStore::new(&cas); + let mut hash_mapping = HashMapping::new(&repo.ivaldi_dir); + + // --- Phase 1: ensure timeline ref + diff commits against the mapping --- + ensure_timeline_ref(repo, local_timeline)?; + let (unskipped, unique_tree_shas) = collect_unimported_commits(&commits, &hash_mapping); + + // --- Phase 2: Parallel tree pre-fetch --- + let prefetched_trees = prefetch_trees(client, owner, repo_name, &unique_tree_shas); + + // --- Phase 3: Global blob batch download --- + let blobs_to_download = + collect_blobs_to_download(&commits, &unskipped, &prefetched_trees, &hash_mapping); + let blobs_downloaded = download_and_store_blobs( + client, + owner, + repo_name, + &blobs_to_download, + &store, + &mut hash_mapping, + )?; + + // --- Phase 4: Commit loop using build_tree_from_hash_map --- + // Track SHA1 → leaf index for parent resolution + let mut sha_to_idx: HashMap = HashMap::new(); + // Cache tree SHA → Ivaldi tree hash to avoid re-downloading identical trees + let mut tree_cache: HashMap = HashMap::new(); + + let mut commits_imported = 0usize; + let mut commits_skipped = 0usize; + + let pb = crate::progress::file_bar(commits.len() as u64, "Importing commits"); + for commit in &commits { + pb.inc(1); + + // Skip if already mapped — still populate sha_to_idx from existing + // data for parent resolution. + if let Some(b3) = hash_mapping.get_blake3(&commit.sha) { + if let Some(idx) = find_leaf_idx_by_hash(repo, b3) { + sha_to_idx.insert(commit.sha.clone(), idx); + } + commits_skipped += 1; + continue; + } + + // Build tree using hash-based approach (Phase 4 optimization) + let tree_sha = &commit.commit.tree.sha; + let ivaldi_tree_hash = if let Some(&cached) = tree_cache.get(tree_sha) { + cached + } else { + let tree_hash = ivaldi_tree_for_commit( + client, + owner, + repo_name, + &store, + tree_sha, + &prefetched_trees, + &hash_mapping, + )?; + tree_cache.insert(tree_sha.clone(), tree_hash); + tree_hash + }; + + let leaf = build_import_leaf(commit, ivaldi_tree_hash, local_timeline, &sha_to_idx); + let result = repo.commit_raw(leaf, local_timeline)?; + + // Record mappings + hash_mapping.insert(&commit.sha, result.hash); + sha_to_idx.insert(commit.sha.clone(), result.index); + commits_imported += 1; + } + pb.finish_with_message(format!( + "{} commits imported, {} skipped", + commits_imported, commits_skipped + )); + + // Save hash mapping + hash_mapping.save()?; + + Ok(ImportResult { + commits_imported, + commits_skipped, + blobs_downloaded, + timeline: local_timeline.to_string(), + }) +} + +/// Import phase 1a: make sure the on-disk ref marker file for the timeline +/// exists (with no head yet) so it shows up in tools that scan refs/heads. +fn ensure_timeline_ref(repo: &Repo, local_timeline: &str) -> Result<(), SyncError> { + if repo.get_timeline_head(local_timeline)?.is_none() { + let ref_path = repo.ivaldi_dir.join("refs/heads").join(local_timeline); + if let Some(parent) = ref_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&ref_path, "")?; + } + Ok(()) +} + +/// Import phase 1b: identify commits not already in the hash mapping +/// (by list index) and the unique tree SHAs they reference. +fn collect_unimported_commits( + commits: &[CommitInfo], + hash_mapping: &HashMapping, +) -> (Vec, Vec) { + let unskipped: Vec = commits + .iter() + .enumerate() + .filter(|(_, c)| hash_mapping.get_blake3(&c.sha).is_none()) + .map(|(i, _)| i) + .collect(); + + let unique_tree_shas: Vec = { + let mut seen = std::collections::HashSet::new(); + unskipped + .iter() + .map(|&i| commits[i].commit.tree.sha.clone()) + .filter(|sha| seen.insert(sha.clone())) + .collect() + }; + + (unskipped, unique_tree_shas) +} + +/// Import phase 2: pre-fetch all unique trees in parallel. Failed fetches are +/// logged and fall back to a live fetch (or blob skip) during the commit loop. +fn prefetch_trees( + client: &GitHubClient, + owner: &str, + repo_name: &str, + unique_tree_shas: &[String], +) -> HashMap { + if unique_tree_shas.is_empty() { + return HashMap::new(); + } + + let pb_trees = crate::progress::file_bar(unique_tree_shas.len() as u64, "Fetching trees"); + let results: Vec<(String, Result)> = std::thread::scope(|s| { + let chunk_size = (unique_tree_shas.len() / 8).max(1); + let mut handles = Vec::new(); + for chunk in unique_tree_shas.chunks(chunk_size) { + let pb_trees = &pb_trees; + let handle = s.spawn(move || { + let mut results = Vec::new(); + for sha in chunk { + let r = client.get_tree(owner, repo_name, sha); + pb_trees.inc(1); + results.push((sha.clone(), r)); + } + results + }); + handles.push(handle); + } + handles + .into_iter() + .flat_map(|h| h.join().unwrap_or_default()) + .collect() + }); + pb_trees.finish_with_message(format!("{} trees fetched", unique_tree_shas.len())); + + let mut map = HashMap::new(); + for (sha, result) in results { + match result { + Ok(tree) => { + map.insert(sha, tree); + } + Err(e) => { + crate::logging::warn(&format!("failed to pre-fetch tree {}: {}", sha, e)); + } + } + } + map +} + +/// Import phase 3a: collect all unique blobs from pre-fetched trees that we +/// don't already have, as `(path, sha1, commit_sha)` download requests. +fn collect_blobs_to_download( + commits: &[CommitInfo], + unskipped: &[usize], + prefetched_trees: &HashMap, + hash_mapping: &HashMapping, +) -> Vec<(String, String, String)> { + let mut blobs_to_download: Vec<(String, String, String)> = Vec::new(); + let mut seen_blob_shas: std::collections::HashSet = std::collections::HashSet::new(); + + for &idx in unskipped { + let commit = &commits[idx]; + let tree_sha = &commit.commit.tree.sha; + if let Some(tree) = prefetched_trees.get(tree_sha) { + for entry in &tree.tree { + if entry.entry_type == "blob" + && hash_mapping.get_blake3(&entry.sha).is_none() + && seen_blob_shas.insert(entry.sha.clone()) + { + blobs_to_download.push(( + entry.path.clone(), + entry.sha.clone(), + commit.sha.clone(), + )); + } + } + } + } + + blobs_to_download +} + +/// Import phase 3b: download the requested blobs in parallel, then store them +/// in the CAS and record their SHA1 → BLAKE3 mapping (serial — the CAS is not +/// `Sync`). Failed downloads are logged and skipped. Returns the number of +/// blobs stored. +fn download_and_store_blobs( + client: &GitHubClient, + owner: &str, + repo_name: &str, + blobs_to_download: &[(String, String, String)], + store: &FsStore<'_>, + hash_mapping: &mut HashMapping, +) -> Result { + if blobs_to_download.is_empty() { + return Ok(0); + } + + let pb_blobs = crate::progress::file_bar(blobs_to_download.len() as u64, "Downloading blobs"); + let blob_results: Vec = std::thread::scope(|s| { + let chunk_size = (blobs_to_download.len() / 8).max(1); + let mut handles = Vec::new(); + for chunk in blobs_to_download.chunks(chunk_size) { + let pb_blobs = &pb_blobs; + let handle = s.spawn(move || { + let mut results = Vec::new(); + for (path, sha1, commit_sha) in chunk { + match client.download_file(owner, repo_name, path, commit_sha) { + Ok(content) => { + pb_blobs.inc(1); + results.push(Ok((path.clone(), sha1.clone(), content))); + } + Err(e) => { + pb_blobs.inc(1); + results.push(Err(format!("failed to download {}: {}", path, e))); + } + } + } + results + }); + handles.push(handle); + } + handles + .into_iter() + .flat_map(|h| h.join().unwrap_or_default()) + .collect() + }); + pb_blobs.finish_with_message(format!("{} blobs downloaded", blobs_to_download.len())); + + let mut blobs_downloaded = 0usize; + for result in blob_results { + match result { + Ok((_, sha1, content)) => { + let (b3_hash, _) = store.put_blob(&content)?; + hash_mapping.insert(&sha1, b3_hash); + blobs_downloaded += 1; + } + Err(msg) => { + crate::logging::warn(&msg); + } + } + } + Ok(blobs_downloaded) +} + +/// Linear scan for the leaf whose BLAKE3 hash equals `b3`. +fn find_leaf_idx_by_hash(repo: &Repo, b3: B3Hash) -> Option { + (0..repo.commit_count()) + .find(|&idx| matches!(repo.get_leaf(idx), Ok(Some(leaf)) if leaf.hash() == b3)) +} + +/// Import phase 4 helper: resolve a commit's Git tree to an Ivaldi Merkle +/// tree using only the hash mapping — pure lookups, NO blob content reads. +/// Falls back to a live tree fetch when the pre-fetch failed. +fn ivaldi_tree_for_commit( + client: &GitHubClient, + owner: &str, + repo_name: &str, + store: &FsStore<'_>, + tree_sha: &str, + prefetched_trees: &HashMap, + hash_mapping: &HashMapping, +) -> Result { + // Look up pre-fetched tree, fall back to live fetch + let tree = match prefetched_trees.get(tree_sha) { + Some(t) => t.clone(), + None => client.get_tree(owner, repo_name, tree_sha)?, + }; + + // Build hash map from tree entries — pure HashMap lookups, zero disk I/O + let mut hash_file_map: BTreeMap = BTreeMap::new(); + for entry in &tree.tree { + if entry.entry_type == "blob" + && let Some(b3) = hash_mapping.get_blake3(&entry.sha) + { + hash_file_map.insert(entry.path.clone(), b3); + } + // else: blob wasn't downloaded (error during batch) — skip + } + + // Build Merkle tree from hashes only — NO blob content reads + Ok(store.build_tree_from_hash_map(&hash_file_map)?) +} + +/// Import phase 4 helper: build an Ivaldi leaf mirroring a GitHub commit +/// (author, timestamp, parent indices resolved through `sha_to_idx`). +fn build_import_leaf( + commit: &CommitInfo, + tree_hash: B3Hash, + local_timeline: &str, + sha_to_idx: &HashMap, +) -> Leaf { + // Parse author and timestamp + let author = format!( + "{} <{}>", + commit.commit.author.name, commit.commit.author.email + ); + let time_unix = commit + .commit + .author + .date + .as_deref() + .and_then(parse_iso8601_to_unix) + .unwrap_or(0); + + // Resolve parent indices + let prev_idx = if !commit.parents.is_empty() { + sha_to_idx + .get(&commit.parents[0].sha) + .copied() + .unwrap_or(crate::leaf::NO_PARENT) + } else { + crate::leaf::NO_PARENT + }; + + let merge_idxs: Vec = commit + .parents + .iter() + .skip(1) + .filter_map(|p| sha_to_idx.get(&p.sha).copied()) + .collect(); + + let mut leaf = Leaf::new( + tree_hash, + local_timeline, + &author, + time_unix, + &commit.commit.message, + ); + leaf.prev_idx = prev_idx; + leaf.merge_idxs = merge_idxs; + leaf +} diff --git a/src/sync/mod.rs b/src/sync/mod.rs new file mode 100644 index 0000000..3c60ed0 --- /dev/null +++ b/src/sync/mod.rs @@ -0,0 +1,1512 @@ +//! Sync operations for Ivaldi VCS — download, upload, scout, harvest. +//! +//! Bridges Ivaldi's internal BLAKE3-based storage with GitHub's SHA1-based +//! Git objects. SHA1 is used ONLY for API communication — never internally. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +use crate::cas::FileCas; +use crate::fsmerkle::FsStore; +use crate::git_remote::{self, FetchResult, SmartHttpClient}; +use crate::github::{GitHubClient, GitHubError}; +use crate::hash::B3Hash; +use crate::ignore; +use crate::portal::{Portal, Transport}; +use crate::remote::{HashMapping, RemoteBranch}; +use crate::repo::Repo; +use crate::ssh_transport::SshClient; + +mod import; +mod timeline_sync; +mod upload; + +pub use import::{ImportResult, import_full_history, parse_iso8601_to_unix}; +pub use timeline_sync::{SyncResult, sync_timeline}; +pub use upload::{UploadResult, upload}; + +/// Workspace delta as `(added, modified, deleted)` relative paths. +type WorkspaceDelta = (Vec, Vec, Vec); + +/// Read-side dispatch: pick HTTPS or SSH based on a portal's transport, and +/// expose the small surface that `scout` / `harvest` / `sync` need. +/// +/// Construct via [`RemoteFetcher::for_portal`]. The HTTPS variant carries an +/// optional auth token (matching `SmartHttpClient::new`), while the SSH +/// variant carries the resolved `SshTarget` from `portal.transport()`. +pub enum RemoteFetcher { + Https { + token: Option, + }, + Ssh { + target: crate::ssh_transport::SshTarget, + }, +} + +impl RemoteFetcher { + /// Build the fetcher matching a portal's transport. The token is used + /// only by the HTTPS variant. + pub fn for_portal(portal: &Portal, token: Option<&str>) -> Self { + match portal.transport() { + Transport::Ssh(target) => RemoteFetcher::Ssh { target }, + // P2P portals can't be served by HTTPS scout/harvest/sync; + // those callers should branch on the portal first. Falling + // back to HTTPS gives a coherent error path. + Transport::Peer(_) | Transport::Https => RemoteFetcher::Https { + token: token.map(str::to_string), + }, + } + } + + /// List branches of the remote, name-only (no SHAs). + pub fn list_branches(&self, owner: &str, repo_name: &str) -> Result, SyncError> { + match self { + RemoteFetcher::Https { token } => SmartHttpClient::new(token.as_deref()) + .list_branches(owner, repo_name) + .map_err(SyncError::from), + RemoteFetcher::Ssh { target } => SshClient::new(target.clone()) + .list_branch_refs() + .map(|refs| refs.into_iter().map(|b| b.name).collect()) + .map_err(SyncError::from), + } + } + + /// List branches with SHAs (for sync-state classification). + pub fn list_branch_refs( + &self, + owner: &str, + repo_name: &str, + ) -> Result, SyncError> { + match self { + RemoteFetcher::Https { token } => SmartHttpClient::new(token.as_deref()) + .list_branch_refs(owner, repo_name) + .map_err(SyncError::from), + RemoteFetcher::Ssh { target } => SshClient::new(target.clone()) + .list_branch_refs() + .map_err(SyncError::from), + } + } + + /// Fetch a branch's full pack. + pub fn fetch_repo( + &self, + owner: &str, + repo_name: &str, + branch: Option<&str>, + ) -> Result { + match self { + RemoteFetcher::Https { token } => SmartHttpClient::new(token.as_deref()) + .fetch_repo(owner, repo_name, branch) + .map_err(SyncError::from), + RemoteFetcher::Ssh { target } => SshClient::new(target.clone()) + .fetch_repo(branch) + .map_err(SyncError::from), + } + } +} + +/// Result of a download (clone) operation. +#[derive(Debug)] +pub struct DownloadResult { + pub files_downloaded: usize, + pub commits_imported: usize, + pub timelines_created: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteTimelineState { + NotDownloaded, + UpToDate, + OutOfSync, + LocalOnly, +} + +#[derive(Debug, Clone)] +pub struct RemoteTimelineInfo { + pub name: String, + pub remote_sha: String, + pub state: RemoteTimelineState, +} + +/// Download a repository from GitHub into a local Ivaldi repo. +pub fn download( + client: &GitHubClient, + owner: &str, + repo_name: &str, + target_dir: &Path, + branch: Option<&str>, +) -> Result { + download_with_fetch( + target_dir, + owner, + repo_name, + |branch| { + SmartHttpClient::new(client.token()) + .fetch_repo(owner, repo_name, branch) + .map_err(SyncError::from) + }, + branch, + ) +} + +/// Download a repository from any SSH-reachable Git host into a local +/// Ivaldi repo. `display_name` is used for "Downloading X..." messaging +/// and the local portal entry (e.g. `git@github.com:owner/repo.git`). +pub fn download_ssh( + target: &crate::ssh_transport::SshTarget, + target_dir: &Path, + branch: Option<&str>, +) -> Result { + let (owner, repo_name) = derive_owner_repo_from_path(&target.repo_path); + let target_clone = target.clone(); + download_with_fetch( + target_dir, + &owner, + &repo_name, + move |branch| { + crate::ssh_transport::SshClient::new(target_clone.clone()) + .fetch_repo(branch) + .map_err(SyncError::from) + }, + branch, + ) +} + +/// Best-effort split of a remote repo path like `owner/repo.git` into +/// (owner, repo). For paths that don't fit `owner/repo` (e.g. nested +/// subgroups like `team/subteam/repo.git` on GitLab), we keep the last two +/// segments as (owner, repo) and discard the prefix — Ivaldi's local model +/// is two-level only, and the portal entry will round-trip the original +/// path. +fn derive_owner_repo_from_path(path: &str) -> (String, String) { + let trimmed = path.trim_start_matches('/').trim_end_matches('/'); + let stripped = trimmed.strip_suffix(".git").unwrap_or(trimmed); + let parts: Vec<&str> = stripped.split('/').filter(|s| !s.is_empty()).collect(); + match parts.as_slice() { + [] => ("local".to_string(), "repo".to_string()), + [single] => ("local".to_string(), (*single).to_string()), + many => ( + many[many.len() - 2].to_string(), + many[many.len() - 1].to_string(), + ), + } +} + +/// Common orchestration: ensure target dir, run the supplied fetch closure, +/// import the resulting `FetchResult`, materialize, return DownloadResult. +fn download_with_fetch( + target_dir: &Path, + owner: &str, + repo_name: &str, + fetch: F, + branch: Option<&str>, +) -> Result +where + F: FnOnce(Option<&str>) -> Result, +{ + if target_dir.exists() + && target_dir + .read_dir() + .map(|mut d| d.next().is_some()) + .unwrap_or(false) + { + return Err(SyncError::Other(format!( + "directory '{}' already exists and is not empty", + target_dir.display() + ))); + } + let created_target = ensure_download_target(target_dir)?; + + eprintln!("Downloading {}/{}...", owner, repo_name); + let result = (|| -> Result { + let remote = fetch(branch)?; + + crate::forge::forge(target_dir)?; + let ivaldi_dir = target_dir.join(".ivaldi"); + + let portal_mgr = crate::portal::PortalManager::new(&ivaldi_dir); + let portal = crate::portal::Portal::parse(&format!("{}/{}", owner, repo_name)) + .ok_or_else(|| SyncError::Other(format!("invalid portal '{}/{}'", owner, repo_name)))?; + let _ = portal_mgr.add(&portal); + + let mut cfg = crate::config::Config::new(); + cfg.set("portal.default", &format!("{}/{}", owner, repo_name)); + cfg.save(&ivaldi_dir.join("config")).ok(); + + let mut repo = Repo::open(target_dir)?; + let import = git_remote::import_fetch_result(&mut repo, &remote)?; + + // forge() initialised HEAD to a hardcoded "main"; point it at the + // branch we actually fetched so `whereami` and `timeline list` agree + // with the working tree. Also materialise the on-disk ref file so the + // timeline shows up in tools that scan refs/heads. + let ref_path = ivaldi_dir.join("refs/heads").join(&remote.branch); + if let Some(parent) = ref_path.parent() { + fs::create_dir_all(parent)?; + } + if !ref_path.exists() { + fs::write(&ref_path, "")?; + } + crate::forge::write_head( + &ivaldi_dir, + &crate::forge::HeadRef::Timeline(remote.branch.clone()), + )?; + + let cas = FileCas::new(ivaldi_dir.join("objects"))?; + let store = FsStore::new(&cas); + let file_count = if repo.get_timeline_head(&remote.branch)?.is_some() { + checkout_tree_to_workspace(&repo, &store, &remote.branch)? + } else { + 0 + }; + + eprintln!( + "Downloaded {} files, imported {} commits from {}/{}", + file_count, import.commits_imported, owner, repo_name + ); + + Ok(DownloadResult { + files_downloaded: file_count, + commits_imported: import.commits_imported, + timelines_created: vec![remote.branch], + }) + })(); + + if result.is_err() && created_target { + cleanup_failed_download_target(target_dir); + } + result +} + +fn ensure_download_target(target_dir: &Path) -> Result { + if target_dir.exists() { + return Ok(false); + } + fs::create_dir_all(target_dir)?; + Ok(true) +} + +fn cleanup_failed_download_target(target_dir: &Path) { + let _ = fs::remove_dir_all(target_dir); +} + +/// Scout — list remote branches without downloading. Routes through the +/// portal's transport (HTTPS or SSH). +pub fn scout(client: &GitHubClient, portal: &Portal) -> Result, SyncError> { + RemoteFetcher::for_portal(portal, client.token()).list_branches(&portal.owner, &portal.repo) +} + +pub fn scout_with_status( + client: &GitHubClient, + repo: &Repo, + portal: &Portal, +) -> Result, SyncError> { + let branches = RemoteFetcher::for_portal(portal, client.token()) + .list_branch_refs(&portal.owner, &portal.repo)?; + let mapping = HashMapping::new(&repo.ivaldi_dir); + + Ok(branches + .into_iter() + .map(|branch| RemoteTimelineInfo { + name: branch.name.clone(), + remote_sha: branch.sha1.clone(), + state: timeline_sync_state(repo, &mapping, &branch), + }) + .collect()) +} + +/// Harvest — download specific branches with full history. +pub fn harvest( + client: &GitHubClient, + repo: &mut Repo, + portal: &Portal, + timeline_names: &[String], +) -> Result, SyncError> { + let fetcher = RemoteFetcher::for_portal(portal, client.token()); + let branches = fetcher.list_branch_refs(&portal.owner, &portal.repo)?; + let mapping = HashMapping::new(&repo.ivaldi_dir); + + let mut harvested = Vec::new(); + + for target_name in timeline_names { + let branch = branches + .iter() + .find(|b| &b.name == target_name) + .ok_or_else(|| { + SyncError::Other(format!("remote timeline '{}' not found", target_name)) + })?; + + eprintln!("Harvesting timeline '{}'...", target_name); + match timeline_sync_state(repo, &mapping, branch) { + RemoteTimelineState::NotDownloaded => eprintln!(" Local state: not downloaded"), + RemoteTimelineState::UpToDate => eprintln!(" Local state: up to date"), + RemoteTimelineState::OutOfSync => eprintln!(" Local state: out of sync"), + RemoteTimelineState::LocalOnly => eprintln!(" Local state: local only"), + } + + let fetch = fetcher.fetch_repo(&portal.owner, &portal.repo, Some(target_name))?; + let import = git_remote::import_fetch_result(repo, &fetch)?; + if import.commits_skipped > 0 { + eprintln!( + " {} new commits imported ({} already present)", + import.commits_imported, import.commits_skipped + ); + } else { + eprintln!(" {} commits imported", import.commits_imported); + } + + harvested.push(target_name.clone()); + } + + Ok(harvested) +} + +fn timeline_sync_state( + repo: &Repo, + mapping: &HashMapping, + branch: &RemoteBranch, +) -> RemoteTimelineState { + let Ok(Some(head_idx)) = repo.get_timeline_head(&branch.name) else { + return RemoteTimelineState::NotDownloaded; + }; + let Ok(Some(head_leaf)) = repo.get_leaf(head_idx) else { + return RemoteTimelineState::LocalOnly; + }; + + match mapping.get_sha1(head_leaf.hash()) { + Some(sha) if sha == branch.sha1 => RemoteTimelineState::UpToDate, + Some(_) => RemoteTimelineState::OutOfSync, + None => RemoteTimelineState::LocalOnly, + } +} + +// Helper to collect files from tree +fn collect_tree_files( + store: &FsStore<'_>, + tree_hash: B3Hash, + prefix: &str, + files: &mut BTreeMap, +) -> Result<(), crate::fsmerkle::FsMerkleError> { + let tree = store.load_tree(tree_hash)?; + for entry in &tree.entries { + let path = if prefix.is_empty() { + entry.name.clone() + } else { + format!("{}/{}", prefix, entry.name) + }; + match entry.kind { + crate::fsmerkle::NodeKind::Blob => { + files.insert(path, entry.hash); + } + crate::fsmerkle::NodeKind::Tree => { + collect_tree_files(store, entry.hash, &path, files)?; + } + } + } + Ok(()) +} + +/// Get the file set (path → B3Hash) from a leaf's tree. +fn get_tree_files( + repo: &Repo, + store: &FsStore<'_>, + leaf_idx: u64, +) -> Result, SyncError> { + let leaf = repo + .get_leaf(leaf_idx)? + .ok_or_else(|| SyncError::Other(format!("leaf {} not found", leaf_idx)))?; + + let mut files = BTreeMap::new(); + collect_tree_files(store, leaf.tree_root, "", &mut files)?; + Ok(files) +} + +/// Compute workspace delta between current head and a prior ancestor. +fn compute_workspace_delta( + repo: &Repo, + store: &FsStore<'_>, + timeline: &str, + ancestor_idx: Option, +) -> Result { + let old_files = if let Some(idx) = ancestor_idx { + get_tree_files(repo, store, idx)? + } else { + BTreeMap::new() + }; + + let new_head = repo.get_timeline_head(timeline)?; + let new_files = if let Some(idx) = new_head { + get_tree_files(repo, store, idx)? + } else { + BTreeMap::new() + }; + + Ok(compute_file_changes(&old_files, &new_files)) +} + +/// Compute added/modified/deleted file lists between two file sets. +fn compute_file_changes( + old: &BTreeMap, + new: &BTreeMap, +) -> (Vec, Vec, Vec) { + let mut added = Vec::new(); + let mut modified = Vec::new(); + let mut deleted = Vec::new(); + + for (path, hash) in new { + match old.get(path) { + None => added.push(path.clone()), + Some(old_hash) if old_hash != hash => modified.push(path.clone()), + _ => {} + } + } + for path in old.keys() { + if !new.contains_key(path) { + deleted.push(path.clone()); + } + } + + added.sort(); + modified.sort(); + deleted.sort(); + (added, modified, deleted) +} + +/// Checkout the tip tree of a timeline to the workspace directory. +/// +/// Writes all files from the target tree, deletes workspace files that are +/// no longer in the tree (respecting `.ivaldiignore`), and cleans up empty +/// parent directories left behind. Returns the number of files in the +/// target tree. +fn checkout_tree_to_workspace( + repo: &Repo, + store: &FsStore<'_>, + timeline: &str, +) -> Result { + let head_idx = repo + .get_timeline_head(timeline)? + .ok_or_else(|| SyncError::Other("no head to checkout".into()))?; + + let head_leaf = repo + .get_leaf(head_idx)? + .ok_or_else(|| SyncError::Other("corrupt head leaf".into()))?; + + let mut files = BTreeMap::new(); + collect_tree_files(store, head_leaf.tree_root, "", &mut files)?; + + // Write / update files from the target tree + for (path, blob_hash) in &files { + let (_, content) = store.load_blob(*blob_hash)?; + let file_path = repo.work_dir.join(path); + + let should_write = if file_path.exists() { + let existing = fs::read(&file_path)?; + existing != content + } else { + true + }; + + if should_write { + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).ok(); + } + fs::write(&file_path, &content)?; + } + } + + // Delete workspace files that are no longer in the target tree + let ignore_cache = ignore::load_pattern_cache(&repo.work_dir); + let current_files = scan_workspace_files(&repo.work_dir, "", &ignore_cache); + let target_set: BTreeSet<&str> = files.keys().map(|s| s.as_str()).collect(); + + for path in ¤t_files { + if !target_set.contains(path.as_str()) { + let full_path = repo.work_dir.join(path); + let _ = fs::remove_file(&full_path); + // Clean up empty parent directories + let mut dir = full_path.parent(); + while let Some(d) = dir { + if d == repo.work_dir { + break; + } + if fs::read_dir(d) + .map(|mut r| r.next().is_none()) + .unwrap_or(false) + { + let _ = fs::remove_dir(d); + dir = d.parent(); + } else { + break; + } + } + } + } + + let count = files.len(); + Ok(count) +} + +/// Recursively scan workspace files, respecting ignore patterns and +/// skipping the `.ivaldi/` directory. Returns sorted relative paths. +fn scan_workspace_files( + root: &Path, + prefix: &str, + ignore_cache: &ignore::PatternCache, +) -> Vec { + let mut out = Vec::new(); + scan_workspace_dir(root, prefix, ignore_cache, &mut out); + out.sort(); + out +} + +fn scan_workspace_dir( + dir: &Path, + prefix: &str, + ignore_cache: &ignore::PatternCache, + out: &mut Vec, +) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let rel = if prefix.is_empty() { + name.clone() + } else { + format!("{}/{}", prefix, name) + }; + + // Skip .ivaldi directory + if rel == ".ivaldi" || rel.starts_with(".ivaldi/") { + continue; + } + + if ignore_cache.is_ignored(&rel) { + continue; + } + + let ft = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + + if ft.is_dir() { + scan_workspace_dir(&entry.path(), &rel, ignore_cache, out); + } else if ft.is_file() { + out.push(rel); + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SyncError { + #[error("GitHub error: {0}")] + GitHub(#[from] GitHubError), + #[error("repo error: {0}")] + Repo(#[from] crate::repo::RepoError), + #[error("forge error: {0}")] + Forge(#[from] crate::forge::ForgeError), + #[error("git remote error: {0}")] + GitRemote(#[from] crate::git_remote::GitRemoteError), + #[error("CAS error: {0}")] + Cas(#[from] crate::cas::CasError), + #[error("merkle tree error: {0}")] + FsMerkle(#[from] crate::fsmerkle::FsMerkleError), + #[error("remote mapping error: {0}")] + Remote(#[from] crate::remote::RemoteError), + #[error("{0}")] + Store(#[from] crate::store::StoreError), + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("{0}")] + Other(String), +} + +#[cfg(test)] +mod tests { + use super::upload::{ + collect_unpushed_leaves, format_rfc3339, identity_for_author, identity_for_committer, + resolve_github_parent, split_author, upload_blobs_parallel, + }; + use super::*; + use crate::cas::Cas; + use crate::hash::B3Hash; + use crate::leaf::Leaf; + use std::fs; + + // -- ISO 8601 parsing tests -- + + #[test] + fn parse_iso8601_utc() { + let ts = parse_iso8601_to_unix("2024-01-15T10:30:00Z").unwrap(); + assert_eq!(ts, 1705314600); + } + + #[test] + fn parse_iso8601_positive_offset() { + let ts = parse_iso8601_to_unix("2024-01-15T10:30:00+05:30").unwrap(); + // 10:30 at +05:30 = 05:00 UTC → 1705314600 - 5*3600 - 30*60 + assert_eq!(ts, 1705294800); + } + + #[test] + fn ensure_download_target_creates_missing_directory() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("clone-target"); + assert!(!target.exists()); + + let created = ensure_download_target(&target).unwrap(); + + assert!(created); + assert!(target.exists()); + } + + #[test] + fn ensure_download_target_keeps_existing_directory() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("existing"); + fs::create_dir_all(&target).unwrap(); + + let created = ensure_download_target(&target).unwrap(); + + assert!(!created); + assert!(target.exists()); + } + + #[test] + fn cleanup_failed_download_target_removes_directory_tree() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("partial"); + fs::create_dir_all(target.join(".ivaldi")).unwrap(); + fs::write(target.join(".ivaldi").join("config"), "partial").unwrap(); + + cleanup_failed_download_target(&target); + + assert!(!target.exists()); + } + + #[test] + fn parse_iso8601_negative_offset() { + let ts = parse_iso8601_to_unix("2024-01-15T10:30:00-05:00").unwrap(); + // 10:30 at -05:00 = 15:30 UTC → 1705314600 + 5*3600 + assert_eq!(ts, 1705332600); + } + + #[test] + fn parse_iso8601_epoch() { + let ts = parse_iso8601_to_unix("1970-01-01T00:00:00Z").unwrap(); + assert_eq!(ts, 0); + } + + #[test] + fn parse_iso8601_invalid() { + assert!(parse_iso8601_to_unix("not a date").is_none()); + assert!(parse_iso8601_to_unix("").is_none()); + } + + // -- ImportResult structure test -- + + #[test] + fn import_result_structure() { + let r = ImportResult { + commits_imported: 50, + commits_skipped: 5, + blobs_downloaded: 200, + timeline: "main".into(), + }; + assert_eq!(r.commits_imported, 50); + assert_eq!(r.timeline, "main"); + } + + // -- SyncResult new fields -- + + #[test] + fn sync_result_new_fields() { + let r = SyncResult { + added: vec![], + modified: vec![], + deleted: vec![], + no_changes: true, + was_fast_forward: false, + was_fused: false, + conflicts: vec![], + }; + assert!(r.no_changes); + assert!(!r.was_fast_forward); + assert!(!r.was_fused); + assert!(r.conflicts.is_empty()); + } + + #[test] + fn download_result_structure() { + let r = DownloadResult { + files_downloaded: 10, + commits_imported: 1, + timelines_created: vec!["main".into()], + }; + assert_eq!(r.files_downloaded, 10); + } + + #[test] + fn upload_result_structure() { + let r = UploadResult { + files_uploaded: 5, + commit_sha: "abc123".into(), + branch: "main".into(), + }; + assert_eq!(r.branch, "main"); + } + + #[test] + fn resolve_parent_existing_branch_takes_priority() { + // When a branch already exists on GitHub, use its tip SHA + let dir = tempfile::tempdir().unwrap(); + let work_dir = dir.path(); + crate::forge::forge(work_dir).unwrap(); + let repo = Repo::open(work_dir).unwrap(); + + let leaf = Leaf::new(B3Hash::digest(b"tree"), "feature", "author", 1000, "msg"); + let mapping = HashMapping::new(&repo.ivaldi_dir); + + let parents = resolve_github_parent( + &repo, + &leaf, + &mapping, + Some("existing_sha_on_github"), + false, + ); + assert_eq!(parents, vec!["existing_sha_on_github"]); + } + + #[test] + fn resolve_parent_new_branch_with_mapped_parent() { + // New branch where the parent leaf has a known GitHub SHA mapping + let dir = tempfile::tempdir().unwrap(); + let work_dir = dir.path(); + crate::forge::forge(work_dir).unwrap(); + let mut repo = Repo::open(work_dir).unwrap(); + + // Create a parent commit on main + let parent_tree = B3Hash::digest(b"parent tree"); + repo.commit(parent_tree, "author", "parent commit").unwrap(); + + // Create a child commit (simulating branch) + let child_tree = B3Hash::digest(b"child tree"); + repo.commit(child_tree, "author", "child commit").unwrap(); + + // Get the child leaf + let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); + let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); + + // Get the parent leaf and map its BLAKE3 hash to a fake GitHub SHA1 + let parent_leaf = repo.get_leaf(head_leaf.prev_idx).unwrap().unwrap(); + let parent_blake3 = parent_leaf.hash(); + let fake_github_sha = "aabbccdd00112233445566778899aabbccddeeff"; + + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + mapping.insert(fake_github_sha, parent_blake3); + + // No existing branch on GitHub (None) → should resolve via hash mapping + let parents = resolve_github_parent(&repo, &head_leaf, &mapping, None, false); + assert_eq!(parents, vec![fake_github_sha]); + } + + #[test] + fn resolve_parent_new_branch_unmapped_parent_returns_empty() { + // New branch where the parent leaf has no GitHub SHA mapping → root commit + let dir = tempfile::tempdir().unwrap(); + let work_dir = dir.path(); + crate::forge::forge(work_dir).unwrap(); + let mut repo = Repo::open(work_dir).unwrap(); + + // Two commits on main + repo.commit(B3Hash::digest(b"t1"), "author", "first") + .unwrap(); + repo.commit(B3Hash::digest(b"t2"), "author", "second") + .unwrap(); + + let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); + let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); + assert!(head_leaf.has_parent()); + + // Empty mapping — parent was never uploaded + let mapping = HashMapping::new(&repo.ivaldi_dir); + + let parents = resolve_github_parent(&repo, &head_leaf, &mapping, None, false); + assert!( + parents.is_empty(), + "should be root commit when parent not mapped" + ); + } + + #[test] + fn resolve_parent_no_parent_leaf_returns_empty() { + // First commit on a timeline (no parent) → root commit + let dir = tempfile::tempdir().unwrap(); + let work_dir = dir.path(); + crate::forge::forge(work_dir).unwrap(); + let mut repo = Repo::open(work_dir).unwrap(); + + repo.commit(B3Hash::digest(b"tree"), "author", "initial") + .unwrap(); + + let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); + let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); + assert!(!head_leaf.has_parent()); + + let mapping = HashMapping::new(&repo.ivaldi_dir); + + let parents = resolve_github_parent(&repo, &head_leaf, &mapping, None, false); + assert!(parents.is_empty(), "first commit should have no parent"); + } + + #[test] + fn resolve_parent_existing_branch_overrides_mapping() { + // Even if parent leaf is mapped, existing branch SHA takes priority + let dir = tempfile::tempdir().unwrap(); + let work_dir = dir.path(); + crate::forge::forge(work_dir).unwrap(); + let mut repo = Repo::open(work_dir).unwrap(); + + repo.commit(B3Hash::digest(b"t1"), "author", "first") + .unwrap(); + repo.commit(B3Hash::digest(b"t2"), "author", "second") + .unwrap(); + + let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); + let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); + + let parent_leaf = repo.get_leaf(head_leaf.prev_idx).unwrap().unwrap(); + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + mapping.insert("mapped_parent_sha", parent_leaf.hash()); + + // Existing branch SHA should win over the mapping + let parents = + resolve_github_parent(&repo, &head_leaf, &mapping, Some("branch_tip_sha"), false); + assert_eq!(parents, vec!["branch_tip_sha"]); + } + + #[test] + fn resolve_parent_force_skips_existing_branch() { + // When force=true and existing branch SHA is provided, should walk leaf chain instead + let dir = tempfile::tempdir().unwrap(); + let work_dir = dir.path(); + crate::forge::forge(work_dir).unwrap(); + let mut repo = Repo::open(work_dir).unwrap(); + + repo.commit(B3Hash::digest(b"t1"), "author", "first") + .unwrap(); + repo.commit(B3Hash::digest(b"t2"), "author", "second") + .unwrap(); + + let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); + let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); + + let parent_leaf = repo.get_leaf(head_leaf.prev_idx).unwrap().unwrap(); + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + mapping.insert("mapped_parent_sha", parent_leaf.hash()); + + // force=true → should skip existing branch tip and walk leaf chain + let parents = resolve_github_parent( + &repo, + &head_leaf, + &mapping, + Some("old_broken_tip_sha"), + true, + ); + assert_eq!( + parents, + vec!["mapped_parent_sha"], + "force should skip existing branch tip and use mapped parent" + ); + } + + #[test] + fn resolve_parent_force_with_mapped_parent() { + // force=true + mapped parent → returns parent SHA from mapping, not existing branch tip + let dir = tempfile::tempdir().unwrap(); + let work_dir = dir.path(); + crate::forge::forge(work_dir).unwrap(); + let mut repo = Repo::open(work_dir).unwrap(); + + // Create parent (simulates synced main) + repo.commit(B3Hash::digest(b"main-tree"), "author", "synced main") + .unwrap(); + let main_head = repo.get_timeline_head("main").unwrap().unwrap(); + let main_leaf = repo.get_leaf(main_head).unwrap().unwrap(); + + // Create child on top (simulates feature after fuse) + repo.commit(B3Hash::digest(b"feature-tree"), "author", "feature work") + .unwrap(); + let feature_head = repo.get_timeline_head("main").unwrap().unwrap(); + let feature_leaf = repo.get_leaf(feature_head).unwrap().unwrap(); + + // Map main's leaf BLAKE3 → a GitHub SHA (as sync_timeline would do) + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + let main_github_sha = "1111111111222222222233333333334444444444"; + mapping.insert(main_github_sha, main_leaf.hash()); + + // force=true with an existing broken branch tip + let parents = resolve_github_parent( + &repo, + &feature_leaf, + &mapping, + Some("old_broken_branch_tip"), + true, + ); + assert_eq!( + parents, + vec![main_github_sha], + "force upload should resolve parent via leaf chain, not existing branch tip" + ); + } + + #[test] + fn resolve_parent_walks_backwards_through_unmapped() { + // Scenario: A → B → C (head), only A is mapped to GitHub + // Should walk C→B→A and find A's mapping + let dir = tempfile::tempdir().unwrap(); + crate::forge::forge(dir.path()).unwrap(); + let mut repo = Repo::open(dir.path()).unwrap(); + + // Commit A (will be mapped) + repo.commit(B3Hash::digest(b"t1"), "author", "commit A") + .unwrap(); + let a_leaf = repo.get_leaf(0).unwrap().unwrap(); + + // Commit B (unmapped) + repo.commit(B3Hash::digest(b"t2"), "author", "commit B") + .unwrap(); + + // Commit C (unmapped, this is the head) + repo.commit(B3Hash::digest(b"t3"), "author", "commit C") + .unwrap(); + + let head_idx = repo.get_timeline_head("main").unwrap().unwrap(); + let head_leaf = repo.get_leaf(head_idx).unwrap().unwrap(); + assert_eq!(head_leaf.prev_idx, 1); // points to B + + // Only map A + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + let a_sha = "aaaa111122223333444455556666777788889999"; + mapping.insert(a_sha, a_leaf.hash()); + + let parents = resolve_github_parent(&repo, &head_leaf, &mapping, None, false); + assert_eq!(parents, vec![a_sha], "should walk past B to find mapped A"); + } + + #[test] + fn resolve_parent_merge_returns_both_parents() { + // Scenario: merge commit with prev_idx=A (mapped) and merge_idx=B (mapped) + // Should return both SHA1s + let dir = tempfile::tempdir().unwrap(); + crate::forge::forge(dir.path()).unwrap(); + let mut repo = Repo::open(dir.path()).unwrap(); + + // Branch point + repo.commit(B3Hash::digest(b"base"), "author", "base") + .unwrap(); + + // "main" commit + repo.commit(B3Hash::digest(b"main-work"), "author", "main work") + .unwrap(); + let main_leaf = repo.get_leaf(1).unwrap().unwrap(); + + // "feature" commit (simulate by raw commit with prev=base) + let mut feat_leaf_data = + Leaf::new(B3Hash::digest(b"feat"), "main", "author", 1000, "feature"); + feat_leaf_data.prev_idx = 0; + let feat_result = repo.commit_raw(feat_leaf_data, "main").unwrap(); + let feat_leaf = repo.get_leaf(feat_result.index).unwrap().unwrap(); + + // Merge commit: prev_idx=main(1), merge_idxs=[feature(2)] + let mut merge_leaf_data = + Leaf::new(B3Hash::digest(b"merged"), "main", "author", 2000, "merge"); + merge_leaf_data.prev_idx = 1; + merge_leaf_data.merge_idxs = vec![feat_result.index]; + let merge_result = repo.commit_raw(merge_leaf_data, "main").unwrap(); + let merge_leaf = repo.get_leaf(merge_result.index).unwrap().unwrap(); + + // Map both parents + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + let main_sha = "1111111111111111111111111111111111111111"; + let feat_sha = "2222222222222222222222222222222222222222"; + mapping.insert(main_sha, main_leaf.hash()); + mapping.insert(feat_sha, feat_leaf.hash()); + + let parents = resolve_github_parent(&repo, &merge_leaf, &mapping, None, false); + assert_eq!(parents.len(), 2, "merge commit should resolve both parents"); + assert!(parents.contains(&main_sha.to_string())); + assert!(parents.contains(&feat_sha.to_string())); + } + + #[test] + fn resolve_parent_merge_walks_merge_parent_chain() { + // Scenario: merge commit with merge_idx pointing to unmapped commit + // whose parent IS mapped → should walk the merge parent chain + let dir = tempfile::tempdir().unwrap(); + crate::forge::forge(dir.path()).unwrap(); + let mut repo = Repo::open(dir.path()).unwrap(); + + // A (mapped) + repo.commit(B3Hash::digest(b"a"), "author", "A").unwrap(); + let a_leaf = repo.get_leaf(0).unwrap().unwrap(); + + // B (unmapped, parent=A) + repo.commit(B3Hash::digest(b"b"), "author", "B").unwrap(); + + // C (the merge parent, unmapped, parent=B) + repo.commit(B3Hash::digest(b"c"), "author", "C").unwrap(); + + // D (merge commit: prev=C, merge_idxs=[]) + // But we'll make a separate commit to be the "other branch" + let mut other = Leaf::new(B3Hash::digest(b"other"), "main", "author", 1000, "other"); + other.prev_idx = 0; // parent=A + let other_result = repo.commit_raw(other, "main").unwrap(); + + // Merge: prev=2(C), merge_idxs=[3(other)] + let mut merge = Leaf::new(B3Hash::digest(b"merge"), "main", "author", 2000, "merge"); + merge.prev_idx = 2; + merge.merge_idxs = vec![other_result.index]; + let merge_result = repo.commit_raw(merge, "main").unwrap(); + let merge_leaf = repo.get_leaf(merge_result.index).unwrap().unwrap(); + + // Only A is mapped + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + let a_sha = "aaaa000011112222333344445555666677778888"; + mapping.insert(a_sha, a_leaf.hash()); + + let parents = resolve_github_parent(&repo, &merge_leaf, &mapping, None, false); + // prev chain: C→B→A (mapped) → found + // merge chain: other→A (mapped) → found, but A is already in parents + assert_eq!(parents.len(), 1, "both chains converge on A"); + assert_eq!(parents[0], a_sha); + } + + // -- checkout_tree_to_workspace regression tests -- + + /// Helper: build a tree in the CAS from a map of path→content, commit it, + /// and return the repo + CAS for checkout testing. + fn setup_checkout_repo(dir: &Path, files: &BTreeMap>) -> (Repo, FileCas) { + crate::forge::forge(dir).unwrap(); + let mut repo = Repo::open(dir).unwrap(); + let ivaldi_dir = dir.join(".ivaldi"); + let cas = FileCas::new(ivaldi_dir.join("objects")).unwrap(); + let store = FsStore::new(&cas); + let tree_hash = store.build_tree_from_map(files).unwrap(); + repo.commit(tree_hash, "test-author", "test commit") + .unwrap(); + (repo, cas) + } + + #[test] + fn checkout_writes_new_files() { + let dir = tempfile::tempdir().unwrap(); + let mut files = BTreeMap::new(); + files.insert("a.txt".into(), b"hello a".to_vec()); + files.insert("b.txt".into(), b"hello b".to_vec()); + let (repo, cas) = setup_checkout_repo(dir.path(), &files); + let store = FsStore::new(&cas); + + let count = checkout_tree_to_workspace(&repo, &store, "main").unwrap(); + assert_eq!(count, 2); + assert_eq!( + fs::read_to_string(dir.path().join("a.txt")).unwrap(), + "hello a" + ); + assert_eq!( + fs::read_to_string(dir.path().join("b.txt")).unwrap(), + "hello b" + ); + } + + #[test] + fn checkout_deletes_removed_files() { + let dir = tempfile::tempdir().unwrap(); + + // Initial commit with A, B, C + let mut files = BTreeMap::new(); + files.insert("a.txt".into(), b"aaa".to_vec()); + files.insert("b.txt".into(), b"bbb".to_vec()); + files.insert("c.txt".into(), b"ccc".to_vec()); + let (mut repo, cas) = setup_checkout_repo(dir.path(), &files); + let store = FsStore::new(&cas); + + // Checkout first commit — all three files present + checkout_tree_to_workspace(&repo, &store, "main").unwrap(); + assert!(dir.path().join("c.txt").exists()); + + // Second commit with only A, B (C removed) + let mut files2 = BTreeMap::new(); + files2.insert("a.txt".into(), b"aaa".to_vec()); + files2.insert("b.txt".into(), b"bbb".to_vec()); + let tree2 = store.build_tree_from_map(&files2).unwrap(); + repo.commit(tree2, "test-author", "remove c").unwrap(); + + // Checkout second commit — C should be deleted + let count = checkout_tree_to_workspace(&repo, &store, "main").unwrap(); + assert_eq!(count, 2); + assert!(dir.path().join("a.txt").exists()); + assert!(dir.path().join("b.txt").exists()); + assert!( + !dir.path().join("c.txt").exists(), + "c.txt should be deleted" + ); + } + + #[test] + fn checkout_handles_modified_files() { + let dir = tempfile::tempdir().unwrap(); + + // Initial commit + let mut files = BTreeMap::new(); + files.insert("doc.txt".into(), b"version 1".to_vec()); + let (mut repo, cas) = setup_checkout_repo(dir.path(), &files); + let store = FsStore::new(&cas); + checkout_tree_to_workspace(&repo, &store, "main").unwrap(); + assert_eq!( + fs::read_to_string(dir.path().join("doc.txt")).unwrap(), + "version 1" + ); + + // Second commit with modified content + let mut files2 = BTreeMap::new(); + files2.insert("doc.txt".into(), b"version 2".to_vec()); + let tree2 = store.build_tree_from_map(&files2).unwrap(); + repo.commit(tree2, "test-author", "update doc").unwrap(); + + checkout_tree_to_workspace(&repo, &store, "main").unwrap(); + assert_eq!( + fs::read_to_string(dir.path().join("doc.txt")).unwrap(), + "version 2" + ); + } + + #[test] + fn checkout_preserves_ignored_files() { + let dir = tempfile::tempdir().unwrap(); + + // Create .ivaldiignore before forging so it's present + let ignore_path = dir.path().join(".ivaldiignore"); + fs::write(&ignore_path, "secret.key\n").unwrap(); + + // Initial commit with one tracked file + let mut files = BTreeMap::new(); + files.insert("a.txt".into(), b"tracked".to_vec()); + let (repo, cas) = setup_checkout_repo(dir.path(), &files); + let store = FsStore::new(&cas); + + // Place an ignored file in the workspace + fs::write(dir.path().join("secret.key"), "private data").unwrap(); + + // Re-write .ivaldiignore (forge may overwrite) + fs::write(&ignore_path, "secret.key\n").unwrap(); + + checkout_tree_to_workspace(&repo, &store, "main").unwrap(); + + // Ignored file should still be there + assert!( + dir.path().join("secret.key").exists(), + "ignored file should not be deleted by checkout" + ); + assert_eq!( + fs::read_to_string(dir.path().join("secret.key")).unwrap(), + "private data" + ); + } + + #[test] + fn checkout_cleans_empty_parent_dirs() { + let dir = tempfile::tempdir().unwrap(); + + // Commit with a file in a subdirectory + let mut files = BTreeMap::new(); + files.insert("a.txt".into(), b"root file".to_vec()); + files.insert("sub/deep.txt".into(), b"deep file".to_vec()); + let (mut repo, cas) = setup_checkout_repo(dir.path(), &files); + let store = FsStore::new(&cas); + checkout_tree_to_workspace(&repo, &store, "main").unwrap(); + assert!(dir.path().join("sub/deep.txt").exists()); + + // Second commit without the subdirectory file + let mut files2 = BTreeMap::new(); + files2.insert("a.txt".into(), b"root file".to_vec()); + let tree2 = store.build_tree_from_map(&files2).unwrap(); + repo.commit(tree2, "test-author", "remove sub/deep.txt") + .unwrap(); + + checkout_tree_to_workspace(&repo, &store, "main").unwrap(); + assert!(!dir.path().join("sub/deep.txt").exists()); + assert!( + !dir.path().join("sub").exists(), + "empty sub/ dir should be cleaned up" + ); + } + + #[test] + fn compute_delta_ignores_cross_timeline_ancestors() { + // Regression: when a feature branch was uploaded and later merged on + // GitHub, sync_timeline would pick the feature branch commit as the + // common ancestor. Because that leaf lives on a different local + // timeline, the divergence detector would over-count local commits, + // falsely triggering a fuse that deleted the merged files. + // + // The fix constrains the common-ancestor search to leaves reachable + // from the LOCAL timeline's head. + let dir = tempfile::tempdir().unwrap(); + crate::forge::forge(dir.path()).unwrap(); + let mut repo = Repo::open(dir.path()).unwrap(); + + // Commit on main (simulates the initial synced state) + let main_tree = B3Hash::digest(b"main-tree"); + repo.commit(main_tree, "author", "initial main").unwrap(); + let main_head = repo.get_timeline_head("main").unwrap().unwrap(); + + // Create a feature timeline with a different commit + let feat_tree = B3Hash::digest(b"feat-tree"); + let mut feat_leaf = Leaf::new(feat_tree, "feature", "author", 2000, "feat work"); + feat_leaf.prev_idx = crate::leaf::NO_PARENT; + let feat_result = repo.commit_raw(feat_leaf, "feature").unwrap(); + + // Map both to fake GitHub SHAs (simulating upload of both) + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + let main_sha = "aaaa111122223333444455556666777788889999"; + let feat_sha = "bbbb111122223333444455556666777788889999"; + let main_leaf = repo.get_leaf(main_head).unwrap().unwrap(); + mapping.insert(main_sha, main_leaf.hash()); + let feat_leaf_stored = repo.get_leaf(feat_result.index).unwrap().unwrap(); + mapping.insert(feat_sha, feat_leaf_stored.hash()); + + // Build local_reachable from main's head + let local_reachable: BTreeSet = { + let mut reachable = BTreeSet::new(); + let mut cur = Some(main_head); + while let Some(idx) = cur { + reachable.insert(idx); + if let Ok(Some(leaf)) = repo.get_leaf(idx) { + cur = if leaf.has_parent() { + Some(leaf.prev_idx) + } else { + None + }; + } else { + break; + } + } + reachable + }; + + // Feature leaf should NOT be in main's reachable set + assert!( + !local_reachable.contains(&feat_result.index), + "feature commit must not be reachable from main" + ); + // Main leaf SHOULD be reachable + assert!( + local_reachable.contains(&main_head), + "main head must be in its own reachable set" + ); + } + + #[test] + fn upload_rejects_security_blocked_files() { + use crate::cas::MemoryCas; + + let dir = tempfile::tempdir().unwrap(); + let ivaldi_dir = dir.path().join(".ivaldi"); + std::fs::create_dir_all(&ivaldi_dir).unwrap(); + + let cas = MemoryCas::new(); + let store = FsStore::new(&cas); + + // Build a file map containing a .env file + let mut files = BTreeMap::new(); + let content = b"SECRET=abc"; + let canonical = crate::fsmerkle::BlobNode::canonical_bytes(content); + let hash = B3Hash::digest(&canonical); + cas.put(hash, &canonical).unwrap(); + files.insert(".env".to_string(), hash); + + let client = GitHubClient::new(); + let mut mapping = HashMapping::new(&ivaldi_dir); + + let result = upload_blobs_parallel(&client, &store, &files, &mut mapping, "owner", "repo"); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("security-blocked"), + "expected security-blocked error, got: {err}" + ); + } + + // -- Commit fidelity helpers -- + + #[test] + fn split_author_separates_name_and_email() { + let (n, e) = split_author("Jane Doe "); + assert_eq!(n, "Jane Doe"); + assert_eq!(e, "jane@example.com"); + } + + #[test] + fn split_author_handles_missing_email() { + let (n, e) = split_author("Jane Doe"); + assert_eq!(n, "Jane Doe"); + assert_eq!(e, ""); + } + + #[test] + fn format_rfc3339_utc() { + // 1700000000 → 2023-11-14T22:13:20 UTC + assert_eq!( + format_rfc3339(1_700_000_000, "+0000"), + "2023-11-14T22:13:20+00:00" + ); + } + + #[test] + fn format_rfc3339_positive_offset_shifts_civil_time() { + // +01:00 shifts the wall clock forward by an hour. + assert_eq!( + format_rfc3339(1_700_000_000, "+0100"), + "2023-11-14T23:13:20+01:00" + ); + } + + #[test] + fn format_rfc3339_negative_offset_shifts_civil_time() { + // -05:30 shifts the wall clock backwards by 5h30m. + assert_eq!( + format_rfc3339(1_700_000_000, "-0530"), + "2023-11-14T16:43:20-05:30" + ); + } + + #[test] + fn format_rfc3339_epoch() { + assert_eq!(format_rfc3339(0, "+0000"), "1970-01-01T00:00:00+00:00"); + } + + #[test] + fn identity_for_author_uses_leaf_meta_tz_when_present() { + let mut leaf = Leaf::new( + B3Hash::digest(b"t"), + "main", + "Jane Doe ", + 1_700_000_000, + "msg", + ); + leaf.meta.insert("git.author_tz".into(), "+0530".into()); + let id = identity_for_author(&leaf); + assert_eq!(id.name, "Jane Doe"); + assert_eq!(id.email, "jane@example.com"); + assert!(id.date.ends_with("+05:30"), "got: {}", id.date); + } + + #[test] + fn identity_for_committer_prefers_leaf_meta() { + let mut leaf = Leaf::new( + B3Hash::digest(b"t"), + "main", + "Author ", + 1_700_000_000, + "msg", + ); + leaf.meta + .insert("git.committer".into(), "Bob ".into()); + leaf.meta + .insert("git.committer_time".into(), "1700001000".into()); + leaf.meta.insert("git.committer_tz".into(), "+0100".into()); + + let id = identity_for_committer(&leaf); + assert_eq!(id.name, "Bob"); + assert_eq!(id.email, "bob@x.com"); + assert!(id.date.ends_with("+01:00"), "got: {}", id.date); + // 1700001000 = 2023-11-14T22:30:00Z → at +01:00 = 23:30:00 + assert_eq!(id.date, "2023-11-14T23:30:00+01:00"); + } + + #[test] + fn identity_for_committer_falls_back_to_author_when_meta_missing() { + let leaf = Leaf::new( + B3Hash::digest(b"t"), + "main", + "Solo ", + 1_700_000_000, + "msg", + ); + let id = identity_for_committer(&leaf); + assert_eq!(id.name, "Solo"); + assert_eq!(id.email, "solo@x.com"); + } + + // -- Multi-commit walk -- + + #[test] + fn collect_unpushed_leaves_returns_full_chain_when_nothing_mapped() { + let dir = tempfile::tempdir().unwrap(); + crate::forge::forge(dir.path()).unwrap(); + let mut repo = Repo::open(dir.path()).unwrap(); + + let a = repo + .commit(B3Hash::digest(b"ta"), "author ", "A") + .unwrap(); + let b = repo + .commit(B3Hash::digest(b"tb"), "author ", "B") + .unwrap(); + let c = repo + .commit(B3Hash::digest(b"tc"), "author ", "C") + .unwrap(); + + let mapping = HashMapping::new(&repo.ivaldi_dir); + let chain = collect_unpushed_leaves(&repo, c.index, &mapping).unwrap(); + // Chronological: A, B, C + assert_eq!(chain, vec![a.index, b.index, c.index]); + } + + #[test] + fn collect_unpushed_leaves_stops_at_mapped_ancestor() { + let dir = tempfile::tempdir().unwrap(); + crate::forge::forge(dir.path()).unwrap(); + let mut repo = Repo::open(dir.path()).unwrap(); + + let a = repo + .commit(B3Hash::digest(b"ta"), "author ", "A") + .unwrap(); + let b = repo + .commit(B3Hash::digest(b"tb"), "author ", "B") + .unwrap(); + let c = repo + .commit(B3Hash::digest(b"tc"), "author ", "C") + .unwrap(); + + // Pretend A was already pushed to GitHub. + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + let a_leaf = repo.get_leaf(a.index).unwrap().unwrap(); + mapping.insert("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", a_leaf.hash()); + + let chain = collect_unpushed_leaves(&repo, c.index, &mapping).unwrap(); + // Replay only the unpushed suffix in chronological order. + assert_eq!(chain, vec![b.index, c.index]); + } + + #[test] + fn collect_unpushed_leaves_empty_when_head_already_mapped() { + let dir = tempfile::tempdir().unwrap(); + crate::forge::forge(dir.path()).unwrap(); + let mut repo = Repo::open(dir.path()).unwrap(); + + let a = repo + .commit(B3Hash::digest(b"ta"), "author ", "A") + .unwrap(); + let mut mapping = HashMapping::new(&repo.ivaldi_dir); + let a_leaf = repo.get_leaf(a.index).unwrap().unwrap(); + mapping.insert("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", a_leaf.hash()); + + let chain = collect_unpushed_leaves(&repo, a.index, &mapping).unwrap(); + assert!(chain.is_empty()); + } +} diff --git a/src/sync/timeline_sync.rs b/src/sync/timeline_sync.rs new file mode 100644 index 0000000..d6a7125 --- /dev/null +++ b/src/sync/timeline_sync.rs @@ -0,0 +1,491 @@ +//! Smart incremental sync of a local timeline from a remote branch: +//! up-to-date detection, fast-forward import, and diverged auto-fuse. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; + +use crate::cas::FileCas; +use crate::fsmerkle::FsStore; +use crate::github::{CommitInfo, GitHubClient}; +use crate::leaf::Leaf; +use crate::remote::HashMapping; +use crate::repo::Repo; + +use super::import::{import_full_history, import_full_history_into}; +use super::{ + SyncError, checkout_tree_to_workspace, compute_file_changes, compute_workspace_delta, + get_tree_files, +}; + +/// Result of a sync (delta update) operation. +#[derive(Debug)] +pub struct SyncResult { + pub added: Vec, + pub modified: Vec, + pub deleted: Vec, + pub no_changes: bool, + pub was_fast_forward: bool, + pub was_fused: bool, + pub conflicts: Vec, +} + +/// Sync — smart incremental update of a local timeline from remote. +/// +/// Detects whether the local and remote have diverged: +/// - **Up to date:** no new remote commits → no-op +/// - **Fast-forward:** remote has new commits, local hasn't diverged → import + advance +/// - **Diverged:** both have new commits → auto-fuse (Ivaldi's auto-merge philosophy) +pub fn sync_timeline( + client: &GitHubClient, + repo: &mut Repo, + owner: &str, + repo_name: &str, + timeline: &str, +) -> Result { + let branches = client.list_branches(owner, repo_name)?; + let branch = branches + .iter() + .find(|b| b.name == timeline) + .ok_or_else(|| SyncError::Other(format!("remote branch '{}' not found", timeline)))?; + + let mut hash_mapping = HashMapping::new(&repo.ivaldi_dir); + + // Fetch remote commits + let remote_commits = client.list_commits(owner, repo_name, timeline, 0)?; + + if remote_commits.is_empty() { + return Ok(up_to_date_result()); + } + + // Get local head BEFORE import so we can constrain ancestor search + let local_head_idx = repo.get_timeline_head(timeline)?; + let local_reachable = collect_local_reachable(repo, local_head_idx); + + // Track commits with stale mappings so we skip them on re-search + let mut stale_shas: BTreeSet = BTreeSet::new(); + + let (mut common_ancestor_sha, mut common_ancestor_idx) = find_common_ancestor( + repo, + &remote_commits, + &hash_mapping, + &local_reachable, + &stale_shas, + ); + + // Stale-mapping detection: when the common ancestor IS the remote tip + // (i.e., sync would say "up to date"), verify that the local tree + // actually matches the remote tree. A mismatch means a previous buggy + // sync created a wrong fuse commit and mapped the remote tip to it. + if common_ancestor_sha.as_ref() == Some(&remote_commits[0].sha) + && let Some(ca_idx) = common_ancestor_idx + && remote_tip_mapping_is_stale(client, repo, owner, repo_name, &remote_commits[0], ca_idx)? + { + // Stale mapping: remove it and re-search for the real ancestor + let stale_sha = remote_commits[0].sha.clone(); + hash_mapping.remove_sha1(&stale_sha); + hash_mapping.save()?; + stale_shas.insert(stale_sha); + + (common_ancestor_sha, common_ancestor_idx) = find_common_ancestor( + repo, + &remote_commits, + &hash_mapping, + &local_reachable, + &stale_shas, + ); + } + + let new_remote_count = + count_new_remote_commits(&remote_commits, common_ancestor_sha.as_deref()); + let new_local_count = + count_new_local_commits(repo, timeline, local_head_idx, common_ancestor_idx)?; + + if new_remote_count == 0 { + return Ok(up_to_date_result()); + } + + // Classify: fast-forward or diverged + if new_local_count == 0 { + return sync_fast_forward( + client, + repo, + owner, + repo_name, + timeline, + common_ancestor_idx, + ); + } + + sync_diverged( + client, + repo, + owner, + repo_name, + timeline, + common_ancestor_idx, + &branch.commit.sha, + ) +} + +/// A `SyncResult` for the nothing-to-do case. +fn up_to_date_result() -> SyncResult { + SyncResult { + added: vec![], + modified: vec![], + deleted: vec![], + no_changes: true, + was_fast_forward: false, + was_fused: false, + conflicts: vec![], + } +} + +/// Sync step 1: build the set of leaf indices reachable from the local +/// timeline head. This prevents matching commits from OTHER timelines that +/// happen to be in the hash_mapping (e.g. after uploading a feature branch +/// whose commits later appear on main via a merge). +fn collect_local_reachable(repo: &Repo, local_head_idx: Option) -> BTreeSet { + let mut reachable = BTreeSet::new(); + if let Some(head) = local_head_idx { + let mut cur = Some(head); + while let Some(idx) = cur { + reachable.insert(idx); + if let Ok(Some(leaf)) = repo.get_leaf(idx) { + // Follow both prev_idx and merge parents + for &midx in &leaf.merge_idxs { + // Shallow: just add direct merge parents + reachable.insert(midx); + } + cur = if leaf.has_parent() { + Some(leaf.prev_idx) + } else { + None + }; + } else { + break; + } + } + } + reachable +} + +/// Sync step 2: find the common ancestor — walk remote commits +/// newest→oldest, check the hash mapping, and only accept leaves that are +/// reachable from the local timeline head. Commits in `stale_shas` are +/// skipped (their mappings were found to be wrong). +fn find_common_ancestor( + repo: &Repo, + remote_commits: &[CommitInfo], + hash_mapping: &HashMapping, + local_reachable: &BTreeSet, + stale_shas: &BTreeSet, +) -> (Option, Option) { + for commit in remote_commits { + if stale_shas.contains(&commit.sha) { + continue; + } + if let Some(b3) = hash_mapping.get_blake3(&commit.sha) { + // Find leaf index with this hash + for idx in 0..repo.commit_count() { + if let Ok(Some(leaf)) = repo.get_leaf(idx) + && leaf.hash() == b3 + && local_reachable.contains(&idx) + { + return (Some(commit.sha.clone()), Some(idx)); + } + } + } + } + (None, None) +} + +/// Sync step 3: compare the remote tip tree's path set against the local +/// tree at the supposed common ancestor. A mismatch means a previous buggy +/// sync created a wrong fuse commit and mapped the remote tip to it. +fn remote_tip_mapping_is_stale( + client: &GitHubClient, + repo: &Repo, + owner: &str, + repo_name: &str, + remote_tip: &CommitInfo, + ca_idx: u64, +) -> Result { + let remote_tree = client.get_tree(owner, repo_name, &remote_tip.commit.tree.sha)?; + let remote_paths: BTreeSet<&str> = remote_tree + .tree + .iter() + .filter(|e| e.entry_type == "blob") + .map(|e| e.path.as_str()) + .collect(); + + let verify_cas = FileCas::new(repo.ivaldi_dir.join("objects"))?; + let verify_store = FsStore::new(&verify_cas); + let local_files = get_tree_files(repo, &verify_store, ca_idx)?; + let local_paths: BTreeSet<&str> = local_files.keys().map(|s| s.as_str()).collect(); + + Ok(remote_paths != local_paths) +} + +/// Sync step 4: count new remote commits (those before the common ancestor +/// in the newest-first list). +fn count_new_remote_commits(remote_commits: &[CommitInfo], ca_sha: Option<&str>) -> usize { + match ca_sha { + Some(ca_sha) => remote_commits + .iter() + .take_while(|c| c.sha != ca_sha) + .count(), + None => remote_commits.len(), + } +} + +/// Sync step 5: count local commits since the common ancestor. +fn count_new_local_commits( + repo: &Repo, + timeline: &str, + local_head_idx: Option, + common_ancestor_idx: Option, +) -> Result { + match (local_head_idx, common_ancestor_idx) { + (Some(head), Some(ancestor)) => { + // Walk from head back to ancestor, counting steps + let mut count = 0u64; + let mut cur = Some(head); + while let Some(idx) = cur { + if idx == ancestor { + break; + } + if let Ok(Some(leaf)) = repo.get_leaf(idx) { + count += 1; + cur = if leaf.has_parent() { + Some(leaf.prev_idx) + } else { + None + }; + } else { + break; + } + } + Ok(count) + } + (Some(_), None) => { + // No common ancestor: all local commits are "new" + Ok(repo.walk_history(timeline)?.len() as u64) + } + _ => Ok(0), + } +} + +/// Sync fast-forward path: import remote commits and advance the workspace. +fn sync_fast_forward( + client: &GitHubClient, + repo: &mut Repo, + owner: &str, + repo_name: &str, + timeline: &str, + common_ancestor_idx: Option, +) -> Result { + let _import = import_full_history(client, repo, owner, repo_name, timeline, 0)?; + + // Compute file changes for the result + let cas = FileCas::new(repo.ivaldi_dir.join("objects"))?; + let store = FsStore::new(&cas); + + let (added, modified, deleted) = + compute_workspace_delta(repo, &store, timeline, common_ancestor_idx)?; + + // Update workspace files + checkout_tree_to_workspace(repo, &store, timeline)?; + + Ok(SyncResult { + added, + modified, + deleted, + no_changes: false, + was_fast_forward: true, + was_fused: false, + conflicts: vec![], + }) +} + +/// Sync diverged path: import remote commits into a temp timeline, three-way +/// fuse against the common ancestor, then clean up the temp timeline. +fn sync_diverged( + client: &GitHubClient, + repo: &mut Repo, + owner: &str, + repo_name: &str, + timeline: &str, + common_ancestor_idx: Option, + remote_tip_sha: &str, +) -> Result { + // Same value as captured before the ancestor search: nothing between + // there and here mutates this timeline's head. + let local_head_idx = repo.get_timeline_head(timeline)?; + + let temp_timeline = format!("__sync_{}", timeline); + + // Create temp timeline pointing at the common ancestor, if known + if let Some(ancestor_idx) = common_ancestor_idx { + create_temp_timeline(repo, &temp_timeline, ancestor_idx)?; + } + + // Import remote history into temp timeline (fetch from real remote branch) + let _import = + import_full_history_into(client, repo, owner, repo_name, timeline, &temp_timeline, 0)?; + + // Get file sets for three-way merge + let cas = FileCas::new(repo.ivaldi_dir.join("objects"))?; + let store = FsStore::new(&cas); + + let base_files = if let Some(ancestor_idx) = common_ancestor_idx { + get_tree_files(repo, &store, ancestor_idx)? + } else { + BTreeMap::new() + }; + + let our_files = if let Some(head_idx) = local_head_idx { + get_tree_files(repo, &store, head_idx)? + } else { + BTreeMap::new() + }; + + let their_head_idx = repo.get_timeline_head(&temp_timeline)?; + let their_files = if let Some(idx) = their_head_idx { + get_tree_files(repo, &store, idx)? + } else { + BTreeMap::new() + }; + + // Auto-fuse + let fuse_result = crate::fuse::FuseEngine::fuse( + &store, + &base_files, + &our_files, + &their_files, + crate::fuse::Strategy::Auto, + ); + + if !fuse_result.success { + // Conflicts — save merge state, report + let conflicts: Vec = fuse_result + .conflicts + .iter() + .map(|c| c.path.clone()) + .collect(); + return save_sync_conflicts(repo, &temp_timeline, timeline, conflicts); + } + + // Build merged tree + let merged_tree = store.build_tree_from_hash_map(&fuse_result.merged_files)?; + + // Create fuse commit + let our_head = local_head_idx.unwrap_or(crate::leaf::NO_PARENT); + let their_head = their_head_idx.unwrap_or(crate::leaf::NO_PARENT); + + let mut fuse_leaf = Leaf::new( + merged_tree, + timeline, + "ivaldi-sync", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + format!( + "Fused sync from {}/{} (branch: {})", + owner, repo_name, timeline + ), + ); + fuse_leaf.prev_idx = our_head; + if their_head != crate::leaf::NO_PARENT { + fuse_leaf.merge_idxs = vec![their_head]; + } + + repo.commit_raw(fuse_leaf, timeline)?; + + // Update workspace + checkout_tree_to_workspace(repo, &store, timeline)?; + + // Map remote tip SHA to the fuse commit so the next sync recognizes it + map_remote_tip_to_head(repo, timeline, remote_tip_sha)?; + + cleanup_temp_timeline(repo, &temp_timeline); + + let (added, modified, deleted) = compute_file_changes(&base_files, &fuse_result.merged_files); + + Ok(SyncResult { + added, + modified, + deleted, + no_changes: false, + was_fast_forward: false, + was_fused: true, + conflicts: vec![], + }) +} + +/// Create the temp sync timeline pointing at the common ancestor. +fn create_temp_timeline( + repo: &Repo, + temp_timeline: &str, + ancestor_idx: u64, +) -> Result<(), SyncError> { + let ref_path = repo.ivaldi_dir.join("refs/heads").join(temp_timeline); + if let Some(parent) = ref_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&ref_path, "")?; + repo.store.set_timeline_head(temp_timeline, ancestor_idx)?; + Ok(()) +} + +/// Best-effort removal of the temp sync timeline (head entry + ref file). +fn cleanup_temp_timeline(repo: &Repo, temp_timeline: &str) { + let _ = repo.store.remove_timeline_head(temp_timeline); + let _ = fs::remove_file(repo.ivaldi_dir.join("refs/heads").join(temp_timeline)); +} + +/// Map the remote tip SHA to the freshly created fuse commit at the timeline +/// head. +fn map_remote_tip_to_head( + repo: &Repo, + timeline: &str, + remote_tip_sha: &str, +) -> Result<(), SyncError> { + let head_idx = repo + .get_timeline_head(timeline)? + .ok_or_else(|| SyncError::Other("timeline head missing after merge".into()))?; + let merged_leaf = repo + .get_leaf(head_idx)? + .ok_or_else(|| SyncError::Other("merged leaf missing after merge".into()))?; + let mut hash_mapping = HashMapping::new(&repo.ivaldi_dir); + hash_mapping.insert(remote_tip_sha, merged_leaf.hash()); + hash_mapping.save()?; + Ok(()) +} + +/// Save merge state for a conflicted sync so the user can resolve and +/// continue. +fn save_sync_conflicts( + repo: &Repo, + temp_timeline: &str, + timeline: &str, + conflicts: Vec, +) -> Result { + let merge_state = crate::repo::MergeState { + source_timeline: temp_timeline.to_string(), + target_timeline: timeline.to_string(), + strategy: "auto".into(), + conflicts: conflicts.clone(), + }; + repo.save_merge_state(&merge_state)?; + + Ok(SyncResult { + added: vec![], + modified: vec![], + deleted: vec![], + no_changes: false, + was_fast_forward: false, + was_fused: false, + conflicts, + }) +} diff --git a/src/sync/upload.rs b/src/sync/upload.rs new file mode 100644 index 0000000..5361faa --- /dev/null +++ b/src/sync/upload.rs @@ -0,0 +1,626 @@ +//! Upload (push) a local timeline to GitHub. +//! +//! Replays unpushed Ivaldi leaves as individual GitHub commits so local +//! history is preserved on the remote. + +use std::collections::BTreeMap; + +use crate::cas::FileCas; +use crate::fsmerkle::FsStore; +use crate::github::{CommitIdentity, GitHubClient, GitHubError, TreeEntryCreate}; +use crate::hash::B3Hash; +use crate::leaf::Leaf; +use crate::remote::HashMapping; +use crate::repo::Repo; + +use super::{SyncError, collect_tree_files}; + +/// Result of an upload (push) operation. +#[derive(Debug)] +pub struct UploadResult { + pub files_uploaded: usize, + pub commit_sha: String, + pub branch: String, +} + +/// Upload blobs in parallel, skipping those already mapped. +/// +/// Returns `TreeEntryCreate` entries for the GitHub tree API. +pub(super) fn upload_blobs_parallel( + client: &GitHubClient, + store: &FsStore<'_>, + files: &BTreeMap, + hash_mapping: &mut HashMapping, + owner: &str, + repo_name: &str, +) -> Result, SyncError> { + // Defense-in-depth: reject security-blocked files before upload + for path in files.keys() { + if crate::ignore::is_security_blocked(path) { + return Err(SyncError::Other(format!( + "refusing to upload security-blocked file: {}", + path + ))); + } + } + + // Partition files into already-mapped (skip) and need-upload + let mut tree_entries = Vec::new(); + let mut to_upload: Vec<(String, B3Hash)> = Vec::new(); + + for (path, blob_hash) in files { + if let Some(sha1) = hash_mapping.get_sha1(*blob_hash) { + // Already uploaded — reuse SHA1 + tree_entries.push(TreeEntryCreate { + path: path.clone(), + mode: "100644".into(), + entry_type: "blob".into(), + sha: sha1.to_string(), + }); + } else { + to_upload.push((path.clone(), *blob_hash)); + } + } + + let skipped = files.len() - to_upload.len(); + if skipped > 0 { + eprintln!("Skipped {} already-uploaded blobs", skipped); + } + + if to_upload.is_empty() { + return Ok(tree_entries); + } + + // Pre-load all blob content (CAS is not Sync, so load before spawning threads) + let mut upload_items: Vec<(String, B3Hash, Vec)> = Vec::new(); + for (path, blob_hash) in &to_upload { + let (_, content) = store.load_blob(*blob_hash)?; + upload_items.push((path.clone(), *blob_hash, content)); + } + + // Upload in parallel using std::thread::scope (ureq is sync) + let pb = crate::progress::file_bar(upload_items.len() as u64, "Uploading"); + let results: Vec> = std::thread::scope(|s| { + let chunk_size = (upload_items.len() / 4).max(1); + let mut handles = Vec::new(); + + for chunk in upload_items.chunks(chunk_size) { + let pb = &pb; + let handle = s.spawn(move || { + let mut results = Vec::new(); + for (path, blob_hash, content) in chunk { + match client.create_blob(owner, repo_name, content) { + Ok(sha) => { + pb.inc(1); + results.push(Ok((path.clone(), sha, *blob_hash))); + } + Err(e) => results.push(Err(SyncError::GitHub(e))), + } + } + results + }); + handles.push(handle); + } + + handles + .into_iter() + .flat_map(|h| h.join().unwrap_or_default()) + .collect() + }); + pb.finish_with_message(format!("{} blobs uploaded", results.len())); + + for result in results { + let (path, sha, blob_hash) = result?; + hash_mapping.insert(&sha, blob_hash); + tree_entries.push(TreeEntryCreate { + path, + mode: "100644".into(), + entry_type: "blob".into(), + sha, + }); + } + + Ok(tree_entries) +} + +/// Upload (push) the current timeline to GitHub. +pub fn upload( + client: &GitHubClient, + repo: &Repo, + owner: &str, + repo_name: &str, + branch: Option<&str>, + force: bool, +) -> Result { + let (timeline, head_idx) = check_auth_and_timeline(client, repo)?; + let branch_name = branch.unwrap_or(&timeline); + + let mut hash_mapping = HashMapping::new(&repo.ivaldi_dir); + + let bootstrapped = bootstrap_if_empty(client, owner, repo_name, branch_name)?; + let existing_branch_sha = find_existing_branch_sha(client, owner, repo_name, branch_name); + + // After a bootstrap, the seed commit on the branch is not a real ancestor + // of our local history, so treat this like a force-push for parent + // resolution and ref update to replace the placeholder commit. + let effective_force = force || bootstrapped; + let is_new_branch = existing_branch_sha.is_none(); + + // Walk back from head to the deepest already-mapped ancestor (or root) and + // collect the chronological list of unpushed leaves. Each one is replayed + // as its own GitHub commit so local history is preserved on the remote. + let replay_indices = collect_unpushed_leaves(repo, head_idx, &hash_mapping)?; + + if replay_indices.is_empty() { + // Head is already mapped; nothing to push beyond a possible ref move. + return mapped_head_result(repo, head_idx, &hash_mapping, branch_name); + } + + let (last_commit_sha, total_blobs_uploaded) = replay_leaves_to_github( + client, + repo, + owner, + repo_name, + &replay_indices, + &mut hash_mapping, + ParentResolution { + existing_branch_sha: existing_branch_sha.as_deref(), + force: effective_force, + }, + )?; + + hash_mapping.save()?; + + finalize_upload_ref( + client, + owner, + repo_name, + branch_name, + &last_commit_sha, + is_new_branch, + effective_force, + )?; + + Ok(UploadResult { + files_uploaded: total_blobs_uploaded, + commit_sha: last_commit_sha, + branch: branch_name.to_string(), + }) +} + +/// Upload step 1: require authentication and resolve the current timeline +/// plus its head leaf index. +fn check_auth_and_timeline(client: &GitHubClient, repo: &Repo) -> Result<(String, u64), SyncError> { + if !client.is_authenticated() { + return Err(SyncError::GitHub(GitHubError::AuthRequired)); + } + + let timeline = repo.current_timeline()?; + let head_idx = repo + .get_timeline_head(&timeline)? + .ok_or_else(|| SyncError::Other("no commits to upload".into()))?; + Ok((timeline, head_idx)) +} + +/// Upload step 2: GitHub's Git Data API returns 409 on every endpoint (blobs +/// included) when the repo has no initial commit. Detect that up front and +/// seed the repo via the Contents API so the rest of the upload can proceed. +/// Returns `true` when a bootstrap commit was created. +fn bootstrap_if_empty( + client: &GitHubClient, + owner: &str, + repo_name: &str, + branch_name: &str, +) -> Result { + let existing_branches = client.list_branches(owner, repo_name)?; + if !existing_branches.is_empty() { + return Ok(false); + } + + let default_branch = client + .get_repo(owner, repo_name) + .map(|info| info.default_branch) + .unwrap_or_default(); + let seed_branch = if default_branch.is_empty() { + branch_name + } else { + default_branch.as_str() + }; + client.create_file_contents( + owner, + repo_name, + ".ivaldi-bootstrap", + seed_branch, + b"Ivaldi bootstrap placeholder. Safe to remove after first upload.\n", + "chore: initialize repository for Ivaldi", + )?; + Ok(true) +} + +/// Upload step 3: look up the remote tip SHA of `branch_name`, if the branch +/// already exists on GitHub. +fn find_existing_branch_sha( + client: &GitHubClient, + owner: &str, + repo_name: &str, + branch_name: &str, +) -> Option { + client + .list_branches(owner, repo_name) + .ok() + .and_then(|branches| { + branches + .iter() + .find(|b| b.name == branch_name) + .map(|b| b.commit.sha.clone()) + }) +} + +/// Build the `UploadResult` for a head that is already mapped on the remote +/// (nothing to push beyond a possible ref move). +fn mapped_head_result( + repo: &Repo, + head_idx: u64, + hash_mapping: &HashMapping, + branch_name: &str, +) -> Result { + let head_leaf = repo + .get_leaf(head_idx)? + .ok_or_else(|| SyncError::Other("corrupt: head leaf not found".into()))?; + let head_sha = hash_mapping + .get_sha1(head_leaf.hash()) + .map(|s| s.to_string()) + .ok_or_else(|| SyncError::Other("head leaf unexpectedly unmapped".into()))?; + Ok(UploadResult { + files_uploaded: 0, + commit_sha: head_sha, + branch: branch_name.to_string(), + }) +} + +/// Parent-resolution inputs for the FIRST replayed leaf — later leaves chain +/// onto the commit created in the previous iteration. +struct ParentResolution<'a> { + existing_branch_sha: Option<&'a str>, + force: bool, +} + +/// Upload step 4: replay each unpushed leaf as its own GitHub commit +/// (blobs → tree → commit), recording SHA1 mappings as we go. Returns the +/// SHA of the last commit created and the number of blobs uploaded. +fn replay_leaves_to_github( + client: &GitHubClient, + repo: &Repo, + owner: &str, + repo_name: &str, + replay_indices: &[u64], + hash_mapping: &mut HashMapping, + parent_resolution: ParentResolution<'_>, +) -> Result<(String, usize), SyncError> { + let cas = FileCas::new(repo.ivaldi_dir.join("objects"))?; + let store = FsStore::new(&cas); + + let mut last_commit_sha = String::new(); + let mut total_blobs_uploaded = 0usize; + + for (i, &leaf_idx) in replay_indices.iter().enumerate() { + let leaf = repo + .get_leaf(leaf_idx)? + .ok_or_else(|| SyncError::Other(format!("corrupt: leaf {} not found", leaf_idx)))?; + + let mut files = BTreeMap::new(); + collect_tree_files(&store, leaf.tree_root, "", &mut files)?; + + let blobs_before = hash_mapping.len(); + let tree_entries = + upload_blobs_parallel(client, &store, &files, hash_mapping, owner, repo_name)?; + total_blobs_uploaded += hash_mapping.len().saturating_sub(blobs_before); + + let tree_sha = client.create_tree(owner, repo_name, tree_entries, None)?; + + // Parents: for the first leaf in the chain, defer to existing parent + // resolution (existing branch tip / mapped ancestor). For every later + // leaf, the parent is whatever we just created in the previous + // iteration plus any merge parents already mapped. + let parents = if i == 0 { + resolve_github_parent( + repo, + &leaf, + hash_mapping, + parent_resolution.existing_branch_sha, + parent_resolution.force, + ) + } else { + let mut p = vec![last_commit_sha.clone()]; + for &midx in &leaf.merge_idxs { + if let Ok(Some(merge_leaf)) = repo.get_leaf(midx) + && let Some(sha) = hash_mapping.get_sha1(merge_leaf.hash()) + { + let s = sha.to_string(); + if !p.contains(&s) { + p.push(s); + } + } + } + p + }; + + let author_id = identity_for_author(&leaf); + let committer_id = identity_for_committer(&leaf); + + let commit_sha = client.create_commit( + owner, + repo_name, + &leaf.message, + &tree_sha, + &parents, + Some(&author_id), + Some(&committer_id), + )?; + + hash_mapping.insert(&commit_sha, leaf.hash()); + last_commit_sha = commit_sha; + } + + Ok((last_commit_sha, total_blobs_uploaded)) +} + +/// Upload step 5: point the remote branch ref at the newly created tip. +fn finalize_upload_ref( + client: &GitHubClient, + owner: &str, + repo_name: &str, + branch_name: &str, + last_commit_sha: &str, + is_new_branch: bool, + force: bool, +) -> Result<(), SyncError> { + if is_new_branch { + client.create_ref(owner, repo_name, branch_name, last_commit_sha)?; + } else { + client.update_ref(owner, repo_name, branch_name, last_commit_sha, force)?; + } + Ok(()) +} + +/// Walk from `head_idx` backwards along `prev_idx` and return the chronological +/// list of leaves whose BLAKE3 is NOT yet in `hash_mapping`. The list is empty +/// if the head is already mapped. +pub(super) fn collect_unpushed_leaves( + repo: &Repo, + head_idx: u64, + hash_mapping: &HashMapping, +) -> Result, crate::repo::RepoError> { + let mut chain = Vec::new(); + let mut cur = Some(head_idx); + while let Some(idx) = cur { + let leaf = match repo.get_leaf(idx)? { + Some(l) => l, + None => break, + }; + if hash_mapping.get_sha1(leaf.hash()).is_some() { + // First mapped ancestor — stop here. + break; + } + chain.push(idx); + cur = if leaf.has_parent() { + Some(leaf.prev_idx) + } else { + None + }; + } + chain.reverse(); + Ok(chain) +} + +/// Build the `author` identity for a GitHub commit from a leaf. +/// +/// Prefers a per-leaf timezone offset stored in `meta["git.author_tz"]` (set +/// during import); falls back to UTC. +pub(super) fn identity_for_author(leaf: &Leaf) -> CommitIdentity { + let (name, email) = split_author(&leaf.author); + let tz = leaf + .meta + .get("git.author_tz") + .map(String::as_str) + .unwrap_or("+0000"); + CommitIdentity { + name, + email, + date: format_rfc3339(leaf.time_unix, tz), + } +} + +/// Build the `committer` identity for a GitHub commit from a leaf. +/// +/// Prefers per-leaf committer info stored in `meta` during import; otherwise +/// reuses the author (matches Git's default when the user only sets one). +pub(super) fn identity_for_committer(leaf: &Leaf) -> CommitIdentity { + if let (Some(committer), Some(time_str)) = ( + leaf.meta.get("git.committer"), + leaf.meta.get("git.committer_time"), + ) { + let time = time_str.parse::().unwrap_or(leaf.time_unix); + let tz = leaf + .meta + .get("git.committer_tz") + .map(String::as_str) + .unwrap_or("+0000"); + let (name, email) = split_author(committer); + return CommitIdentity { + name, + email, + date: format_rfc3339(time, tz), + }; + } + identity_for_author(leaf) +} + +/// Split a `"Name "` string. Tolerates malformed input by returning the +/// whole string as the name and an empty email. +pub(super) fn split_author(s: &str) -> (String, String) { + if let Some(open) = s.rfind(" <") + && let Some(close) = s[open..].find('>') + { + let name = s[..open].trim().to_string(); + let email = s[open + 2..open + close].to_string(); + return (name, email); + } + (s.to_string(), String::new()) +} + +/// Format a unix-second timestamp + git-style timezone offset (e.g. `"+0000"`, +/// `"-0530"`) as RFC 3339 (`YYYY-MM-DDTHH:MM:SS±HH:MM`). +/// +/// The offset is applied to the unix instant before splitting into civil +/// time so `1700000000` + `+0100` formats as `2023-11-14T23:13:20+01:00`. +pub(super) fn format_rfc3339(unix_seconds: i64, git_tz: &str) -> String { + let (sign, hours, minutes) = parse_git_tz(git_tz); + let offset_seconds = sign * (hours as i64 * 3600 + minutes as i64 * 60); + let local = unix_seconds + offset_seconds; + let (y, mo, d, h, mi, s) = civil_from_unix(local); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}", + y, + mo, + d, + h, + mi, + s, + if sign >= 0 { '+' } else { '-' }, + hours, + minutes, + ) +} + +/// Parse `"+HHMM"` / `"-HHMM"` into (sign, hours, minutes). Defaults to UTC +/// (`+0000`) on any parse error. +fn parse_git_tz(s: &str) -> (i64, u32, u32) { + let bytes = s.as_bytes(); + if bytes.len() != 5 { + return (1, 0, 0); + } + let sign: i64 = match bytes[0] { + b'+' => 1, + b'-' => -1, + _ => return (1, 0, 0), + }; + let h: u32 = std::str::from_utf8(&bytes[1..3]) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let m: u32 = std::str::from_utf8(&bytes[3..5]) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + (sign, h, m) +} + +/// Convert a unix-second timestamp into civil (year, month, day, hour, min, sec). +/// +/// Implements Howard Hinnant's days_from_civil inverse for portability without +/// pulling in a full date crate. +fn civil_from_unix(unix_seconds: i64) -> (i32, u32, u32, u32, u32, u32) { + let days = unix_seconds.div_euclid(86_400); + let secs_of_day = unix_seconds.rem_euclid(86_400) as u32; + let h = secs_of_day / 3600; + let mi = (secs_of_day % 3600) / 60; + let s = secs_of_day % 60; + + // Days since 1970-01-01 → civil date (Hinnant). + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146_096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + let year = (y + if m <= 2 { 1 } else { 0 }) as i32; + (year, m, d, h, mi, s) +} + +/// Resolve the GitHub parent SHA(s) for a new commit. +/// +/// Priority: +/// 1. Branch already exists on GitHub AND not force-pushing → use its tip SHA +/// 2. Walk Ivaldi leaf chain via `prev_idx` backwards until a mapped commit is found, +/// AND collect mapped merge parents. Returns ALL resolved parents so GitHub +/// gets correct merge topology. +/// 3. Fallback → no parents mapped → root commit +pub(super) fn resolve_github_parent( + repo: &Repo, + head_leaf: &crate::leaf::Leaf, + hash_mapping: &HashMapping, + existing_branch_sha: Option<&str>, + force: bool, +) -> Vec { + // Priority 1: branch already exists on GitHub AND not force-pushing + if !force && let Some(sha) = existing_branch_sha { + return vec![sha.to_string()]; + } + + let mut parents = Vec::new(); + + // Walk prev_idx chain backwards until we find a mapped ancestor + if head_leaf.has_parent() { + let mut current_idx = head_leaf.prev_idx; + let mut depth = 0u32; + const MAX_WALK_DEPTH: u32 = 1000; + + while depth < MAX_WALK_DEPTH { + if let Ok(Some(ancestor)) = repo.get_leaf(current_idx) { + let ancestor_blake3 = ancestor.hash(); + if let Some(sha1) = hash_mapping.get_sha1(ancestor_blake3) { + parents.push(sha1.to_string()); + break; + } + // Keep walking if this ancestor also has a parent + if ancestor.has_parent() { + current_idx = ancestor.prev_idx; + depth += 1; + } else { + break; + } + } else { + break; + } + } + } + + // Also resolve merge parents (from fuse operations) + for &merge_idx in &head_leaf.merge_idxs { + // Walk each merge parent chain backwards too + let mut current_idx = merge_idx; + let mut depth = 0u32; + const MAX_WALK_DEPTH: u32 = 1000; + + while depth < MAX_WALK_DEPTH { + if let Ok(Some(ancestor)) = repo.get_leaf(current_idx) { + let ancestor_blake3 = ancestor.hash(); + if let Some(sha1) = hash_mapping.get_sha1(ancestor_blake3) { + let sha1_str = sha1.to_string(); + if !parents.contains(&sha1_str) { + parents.push(sha1_str); + } + break; + } + if ancestor.has_parent() { + current_idx = ancestor.prev_idx; + depth += 1; + } else { + break; + } + } else { + break; + } + } + } + + if parents.is_empty() && head_leaf.has_parent() { + eprintln!("Warning: no GitHub SHA1 mapping found in ancestor chain — creating root commit",); + } + + parents +} diff --git a/src/tui/app.rs b/src/tui/app.rs index c155432..6827beb 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -138,87 +138,87 @@ impl App { terminal.draw(|frame| self.render(frame))?; // Poll for events with timeout (for background task polling) - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { - continue; - } + if event::poll(Duration::from_millis(100))? + && let Event::Key(key) = event::read()? + { + if key.kind != KeyEventKind::Press { + continue; + } - let has_input = self.active_view().has_active_input(); + let has_input = self.active_view().has_active_input(); - // Global keys (only when no active input) - if !has_input { - let mut handled = true; - match key.code { - KeyCode::Char('q') => break, - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - break; - } - KeyCode::Char('?') => { - self.show_help = !self.show_help; + // Global keys (only when no active input) + if !has_input { + let mut handled = true; + match key.code { + KeyCode::Char('q') => break, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + break; + } + KeyCode::Char('?') => { + self.show_help = !self.show_help; + } + KeyCode::Char('1') => self.switch_tab(TabId::Status), + KeyCode::Char('2') => self.switch_tab(TabId::Log), + KeyCode::Char('3') => self.switch_tab(TabId::Diff), + KeyCode::Char('4') => self.switch_tab(TabId::Timelines), + KeyCode::Char('5') => self.switch_tab(TabId::Remote), + KeyCode::Char('6') => self.switch_tab(TabId::Fuse), + KeyCode::Char('7') => self.switch_tab(TabId::Review), + KeyCode::Char('8') => self.switch_tab(TabId::Shelves), + KeyCode::Tab => { + let next = (self.active_tab.index() + 1) % TabId::ALL.len(); + if let Some(tab) = TabId::from_index(next) { + self.switch_tab(tab); } - KeyCode::Char('1') => self.switch_tab(TabId::Status), - KeyCode::Char('2') => self.switch_tab(TabId::Log), - KeyCode::Char('3') => self.switch_tab(TabId::Diff), - KeyCode::Char('4') => self.switch_tab(TabId::Timelines), - KeyCode::Char('5') => self.switch_tab(TabId::Remote), - KeyCode::Char('6') => self.switch_tab(TabId::Fuse), - KeyCode::Char('7') => self.switch_tab(TabId::Review), - KeyCode::Char('8') => self.switch_tab(TabId::Shelves), - KeyCode::Tab => { - let next = (self.active_tab.index() + 1) % TabId::ALL.len(); - if let Some(tab) = TabId::from_index(next) { - self.switch_tab(tab); - } + } + KeyCode::BackTab => { + let prev = if self.active_tab.index() == 0 { + TabId::ALL.len() - 1 + } else { + self.active_tab.index() - 1 + }; + if let Some(tab) = TabId::from_index(prev) { + self.switch_tab(tab); } - KeyCode::BackTab => { - let prev = if self.active_tab.index() == 0 { - TabId::ALL.len() - 1 - } else { - self.active_tab.index() - 1 - }; - if let Some(tab) = TabId::from_index(prev) { - self.switch_tab(tab); - } + } + KeyCode::Esc => { + if self.show_help { + self.show_help = false; } - KeyCode::Esc => { - if self.show_help { - self.show_help = false; - } - // Esc falls through to view if help not showing - else { - handled = false; - } + // Esc falls through to view if help not showing + else { + handled = false; } - _ => handled = false, - } - - if handled { - continue; - } - } else { - // When input is active, only handle Ctrl+C globally - if key.code == KeyCode::Char('c') - && key.modifiers.contains(KeyModifiers::CONTROL) - { - break; } + _ => handled = false, } - // Dispatch to active view - let action = match self.active_tab { - TabId::Status => self.status_view.handle_event(&key, &mut self.ctx), - TabId::Log => self.log_view.handle_event(&key, &mut self.ctx), - TabId::Diff => self.diff_view.handle_event(&key, &mut self.ctx), - TabId::Timelines => self.timeline_view.handle_event(&key, &mut self.ctx), - TabId::Remote => self.remote_view.handle_event(&key, &mut self.ctx), - TabId::Fuse => self.fuse_view.handle_event(&key, &mut self.ctx), - TabId::Review => self.review_view.handle_event(&key, &mut self.ctx), - TabId::Shelves => self.shelves_view.handle_event(&key, &mut self.ctx), - }; - - self.handle_action(action); + if handled { + continue; + } + } else { + // When input is active, only handle Ctrl+C globally + if key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + break; + } } + + // Dispatch to active view + let action = match self.active_tab { + TabId::Status => self.status_view.handle_event(&key, &mut self.ctx), + TabId::Log => self.log_view.handle_event(&key, &mut self.ctx), + TabId::Diff => self.diff_view.handle_event(&key, &mut self.ctx), + TabId::Timelines => self.timeline_view.handle_event(&key, &mut self.ctx), + TabId::Remote => self.remote_view.handle_event(&key, &mut self.ctx), + TabId::Fuse => self.fuse_view.handle_event(&key, &mut self.ctx), + TabId::Review => self.review_view.handle_event(&key, &mut self.ctx), + TabId::Shelves => self.shelves_view.handle_event(&key, &mut self.ctx), + }; + + self.handle_action(action); } // Poll background operations (remote tab) diff --git a/src/tui/components/diff_view.rs b/src/tui/components/diff_view.rs index 71e0786..60311af 100644 --- a/src/tui/components/diff_view.rs +++ b/src/tui/components/diff_view.rs @@ -28,6 +28,12 @@ pub struct DiffViewWidget { pub file_boundaries: Vec, } +impl Default for DiffViewWidget { + fn default() -> Self { + Self::new() + } +} + impl DiffViewWidget { pub fn new() -> Self { Self { diff --git a/src/tui/components/file_list.rs b/src/tui/components/file_list.rs index 6508cc5..0cb4b71 100644 --- a/src/tui/components/file_list.rs +++ b/src/tui/components/file_list.rs @@ -33,6 +33,12 @@ pub struct FileListWidget { pub offset: usize, } +impl Default for FileListWidget { + fn default() -> Self { + Self::new() + } +} + impl FileListWidget { pub fn new() -> Self { Self { diff --git a/src/tui/config_form.rs b/src/tui/config_form.rs index 73a9a5f..9714a47 100644 --- a/src/tui/config_form.rs +++ b/src/tui/config_form.rs @@ -1,11 +1,13 @@ //! Standalone ratatui form for `ivaldi config` (no args / interactive mode). //! -//! Sections: User, Appearance, Core, Remote (only shown when inside a repo). -//! Text fields edit via the shared `TextInput` widget. Bool fields are radios -//! toggled with left/right arrows. +//! The first field is the scope — repo-local or global — and toggling it +//! reloads the form from (and saves to) the corresponding config file. +//! Sections: Scope, User, Appearance, Core, Remote (Remote only in local +//! scope). Text fields edit via the shared `TextInput` widget. Radio fields +//! toggle with left/right arrows. use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::prelude::*; @@ -16,6 +18,8 @@ use crate::tui::input::TextInput; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FieldKind { + /// Local/global selector (radio; pseudo-field, never saved). + Scope, Text, Bool, } @@ -29,8 +33,10 @@ struct Field { } struct State { - path: String, - inside_repo: bool, + /// Repo-local config path; `None` when not inside a repository. + local_path: Option, + global_path: PathBuf, + use_global: bool, fields: Vec, cursor: usize, editing: Option, @@ -39,60 +45,116 @@ struct State { notice: Option, } -/// Launch the interactive config form. Writes to `target_path` on save. -pub fn run(target_path: &Path, inside_repo: bool) -> io::Result<()> { - let cfg = Config::load(target_path).unwrap_or_else(|_| Config::new()); +impl State { + fn target_path(&self) -> &Path { + if self.use_global { + &self.global_path + } else { + // Safe: use_global is forced true when local_path is None. + self.local_path.as_deref().unwrap() + } + } - let mut fields = vec![ - Field { - section: "User", - key: "user.name", - label: "name", - kind: FieldKind::Text, - value: cfg.get("user.name").unwrap_or("").to_string(), - }, - Field { - section: "User", - key: "user.email", - label: "email", - kind: FieldKind::Text, - value: cfg.get("user.email").unwrap_or("").to_string(), - }, - Field { - section: "Appearance", - key: "color.ui", - label: "color.ui", - kind: FieldKind::Bool, - value: cfg.get("color.ui").unwrap_or("true").to_string(), - }, - Field { - section: "Core", - key: "core.autoshelf", - label: "autoshelf", - kind: FieldKind::Bool, - value: cfg.get("core.autoshelf").unwrap_or("true").to_string(), - }, - ]; - if inside_repo { - fields.push(Field { - section: "Remote", - key: "portal.default", - label: "portal.default", - kind: FieldKind::Text, - value: cfg.get("portal.default").unwrap_or("").to_string(), - }); + fn scope_label(&self) -> &'static str { + if self.use_global { "global" } else { "local" } + } + + /// (Re)build the field list from the currently selected config file. + fn reload_fields(&mut self) { + let cfg = Config::load(self.target_path()).unwrap_or_else(|_| Config::new()); + let mut fields = vec![ + Field { + section: "Scope", + key: "scope", + label: "save to", + kind: FieldKind::Scope, + value: self.scope_label().to_string(), + }, + Field { + section: "User", + key: "user.name", + label: "name", + kind: FieldKind::Text, + value: cfg.get("user.name").unwrap_or("").to_string(), + }, + Field { + section: "User", + key: "user.email", + label: "email", + kind: FieldKind::Text, + value: cfg.get("user.email").unwrap_or("").to_string(), + }, + Field { + section: "Appearance", + key: "color.ui", + label: "color.ui", + kind: FieldKind::Bool, + value: cfg.get("color.ui").unwrap_or("true").to_string(), + }, + Field { + section: "Core", + key: "core.autoshelf", + label: "autoshelf", + kind: FieldKind::Bool, + value: cfg.get("core.autoshelf").unwrap_or("true").to_string(), + }, + ]; + // Per-repo concern; only meaningful in local scope. + if !self.use_global { + fields.push(Field { + section: "Remote", + key: "portal.default", + label: "portal.default", + kind: FieldKind::Text, + value: cfg.get("portal.default").unwrap_or("").to_string(), + }); + } + self.fields = fields; + self.cursor = self.cursor.min(self.fields.len() - 1); + self.dirty = false; } + /// Flip local ↔ global, reloading from the newly selected file. + fn toggle_scope(&mut self) { + if self.local_path.is_none() { + self.notice = Some( + "not inside an Ivaldi repository — only the global config is available".into(), + ); + return; + } + let had_edits = self.dirty; + self.use_global = !self.use_global; + self.reload_fields(); + self.notice = Some(format!( + "editing {} config: {}{}", + self.scope_label(), + self.target_path().display(), + if had_edits { + " (unsaved edits discarded)" + } else { + "" + } + )); + } +} + +/// Launch the interactive config form. +/// +/// `local_path` is the repo's `.ivaldi/config` when inside a repository; +/// `start_global` selects the initial scope (forced when there is no repo). +pub fn run(local_path: Option<&Path>, global_path: &Path, start_global: bool) -> io::Result<()> { let mut state = State { - path: target_path.display().to_string(), - inside_repo, - fields, + use_global: start_global || local_path.is_none(), + local_path: local_path.map(|p| p.to_path_buf()), + global_path: global_path.to_path_buf(), + fields: Vec::new(), cursor: 0, editing: None, dirty: false, saved: false, notice: None, }; + state.reload_fields(); let mut terminal = super::init_terminal()?; let outcome = event_loop(&mut terminal, &mut state); @@ -100,19 +162,21 @@ pub fn run(target_path: &Path, inside_repo: bool) -> io::Result<()> { outcome?; if state.saved { + let target = state.target_path().to_path_buf(); // Rebuild a Config from state.fields and write it out. - let mut out = Config::load(target_path).unwrap_or_else(|_| Config::new()); + let mut out = Config::load(&target).unwrap_or_else(|_| Config::new()); for f in &state.fields { - if !f.value.is_empty() { + if f.kind != FieldKind::Scope && !f.value.is_empty() { out.set(f.key, &f.value); } } - out.save(target_path) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + out.save(&target) + .map_err(|e| io::Error::other(e.to_string()))?; println!( - "{} Configuration saved to {}", + "{} Configuration saved to {} ({})", crate::color::green("\u{2713}"), - target_path.display() + target.display(), + state.scope_label() ); if let Some(author) = out.author() { println!("Author: {}", crate::color::author(&author)); @@ -121,10 +185,7 @@ pub fn run(target_path: &Path, inside_repo: bool) -> io::Result<()> { Ok(()) } -fn event_loop( - terminal: &mut Terminal, - state: &mut State, -) -> io::Result<()> { +fn event_loop(terminal: &mut Terminal, state: &mut State) -> io::Result<()> { loop { terminal .draw(|frame| draw(frame, state)) @@ -146,7 +207,8 @@ fn event_loop( KeyCode::Enter => { let new_val = input.value.clone(); let field = &mut state.fields[state.cursor]; - if field.key == "user.email" && !new_val.is_empty() && !is_email_like(&new_val) { + if field.key == "user.email" && !new_val.is_empty() && !is_email_like(&new_val) + { state.notice = Some(format!("'{}' doesn't look like an email", new_val)); // keep edit mode open so user can fix it } else if field.key == "portal.default" @@ -173,7 +235,8 @@ fn event_loop( match key.code { KeyCode::Char('q') => { if state.dirty { - state.notice = Some("unsaved changes — press 's' to save or Esc to discard".into()); + state.notice = + Some("unsaved changes — press 's' to save or Esc to discard".into()); } else { return Ok(()); } @@ -187,36 +250,43 @@ fn event_loop( state.saved = true; return Ok(()); } - KeyCode::Up | KeyCode::Char('k') => { - if state.cursor > 0 { - state.cursor -= 1; - } + KeyCode::Up | KeyCode::Char('k') if state.cursor > 0 => { + state.cursor -= 1; } - KeyCode::Down | KeyCode::Char('j') => { - if state.cursor + 1 < state.fields.len() { - state.cursor += 1; - } + KeyCode::Down | KeyCode::Char('j') if state.cursor + 1 < state.fields.len() => { + state.cursor += 1; } KeyCode::Left | KeyCode::Char('h') | KeyCode::Right | KeyCode::Char('l') => { - let field = &mut state.fields[state.cursor]; - if field.kind == FieldKind::Bool { - field.value = if field.value == "true" { "false".into() } else { "true".into() }; - state.dirty = true; - } - } - KeyCode::Enter => { - let field = &state.fields[state.cursor]; - match field.kind { - FieldKind::Text => { - state.editing = Some(TextInput::with_value(field.value.clone())); - } + match state.fields[state.cursor].kind { + FieldKind::Scope => state.toggle_scope(), FieldKind::Bool => { - let f = &mut state.fields[state.cursor]; - f.value = if f.value == "true" { "false".into() } else { "true".into() }; + let field = &mut state.fields[state.cursor]; + field.value = if field.value == "true" { + "false".into() + } else { + "true".into() + }; state.dirty = true; } + FieldKind::Text => {} } } + KeyCode::Enter => match state.fields[state.cursor].kind { + FieldKind::Scope => state.toggle_scope(), + FieldKind::Text => { + let field = &state.fields[state.cursor]; + state.editing = Some(TextInput::with_value(field.value.clone())); + } + FieldKind::Bool => { + let f = &mut state.fields[state.cursor]; + f.value = if f.value == "true" { + "false".into() + } else { + "true".into() + }; + state.dirty = true; + } + }, _ => {} } } @@ -243,10 +313,10 @@ fn draw(frame: &mut Frame, state: &State) { ]) .split(area); - let scope = if state.inside_repo { "repo-local" } else { "global" }; let header = Paragraph::new(format!( " Ivaldi Configuration ({})\n {}", - scope, state.path + state.scope_label(), + state.target_path().display() )) .block(Block::bordered().title(" Config ")); frame.render_widget(header, chunks[0]); @@ -274,6 +344,28 @@ fn draw(frame: &mut Frame, state: &State) { let marker = if focused { "▸" } else { " " }; let row = match field.kind { + FieldKind::Scope => { + let global = state.use_global; + let local_mark = if !global { "(●) local" } else { "( ) local" }; + let global_mark = if global { "(●) global" } else { "( ) global" }; + let style = if focused { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let mut spans = vec![ + Span::raw(format!(" {} ", marker)), + Span::raw(format!("{:<16}", field.label)), + Span::styled(format!("{} {}", local_mark, global_mark), style), + ]; + if state.local_path.is_none() { + spans.push(Span::styled( + " (no repo — global only)", + Style::default().add_modifier(Modifier::DIM), + )); + } + Line::from(spans) + } FieldKind::Text => { let shown = if field.value.is_empty() { "(empty)".to_string() @@ -317,23 +409,30 @@ fn draw(frame: &mut Frame, state: &State) { frame.render_widget(form, chunks[1]); // If editing a text field, draw the TextInput over the field's line. - if let Some(input) = state.editing.as_ref() { - if let Some(&(_, line_idx)) = field_rows.iter().find(|(i, _)| *i == state.cursor) { - // chunks[1] has a 1-cell border on top. - let y = chunks[1].y + 1 + line_idx as u16; - // Field label is 16 wide + marker prefix (5) + "[" (1) = 22. - let x = chunks[1].x + 1 + 5 + 16 + 1; - let width = chunks[1].width.saturating_sub(x - chunks[1].x).saturating_sub(2); - let input_area = Rect { - x, - y, - width, - height: 1, - }; - // Clear the background rectangle and render the editor. - frame.render_widget(Clear, input_area); - input.render(frame, input_area, Style::default().add_modifier(Modifier::REVERSED)); - } + if let Some(input) = state.editing.as_ref() + && let Some(&(_, line_idx)) = field_rows.iter().find(|(i, _)| *i == state.cursor) + { + // chunks[1] has a 1-cell border on top. + let y = chunks[1].y + 1 + line_idx as u16; + // Field label is 16 wide + marker prefix (5) + "[" (1) = 22. + let x = chunks[1].x + 1 + 5 + 16 + 1; + let width = chunks[1] + .width + .saturating_sub(x - chunks[1].x) + .saturating_sub(2); + let input_area = Rect { + x, + y, + width, + height: 1, + }; + // Clear the background rectangle and render the editor. + frame.render_widget(Clear, input_area); + input.render( + frame, + input_area, + Style::default().add_modifier(Modifier::REVERSED), + ); } let hint = if state.editing.is_some() { @@ -345,7 +444,6 @@ fn draw(frame: &mut Frame, state: &State) { .notice .as_deref() .unwrap_or(if state.dirty { " (modified)" } else { "" }); - let footer = Paragraph::new(format!("{}\n{}", hint, notice)) - .block(Block::bordered()); + let footer = Paragraph::new(format!("{}\n{}", hint, notice)).block(Block::bordered()); frame.render_widget(footer, chunks[2]); } diff --git a/src/tui/input.rs b/src/tui/input.rs index 02bac41..4c1f56e 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -10,6 +10,12 @@ pub struct TextInput { pub cursor: usize, } +impl Default for TextInput { + fn default() -> Self { + Self::new() + } +} + impl TextInput { pub fn new() -> Self { Self { diff --git a/src/tui/launcher.rs b/src/tui/launcher.rs index f6b2e13..f997f0d 100644 --- a/src/tui/launcher.rs +++ b/src/tui/launcher.rs @@ -312,7 +312,9 @@ fn render(frame: &mut Frame, stage: &Stage, theme: &Theme) { dir_input, focus, error, - } => render_download_form(frame, body_area, repo_input, dir_input, *focus, error, theme), + } => render_download_form( + frame, body_area, repo_input, dir_input, *focus, error, theme, + ), Stage::PathForm { title, input, @@ -411,7 +413,10 @@ fn render_download_form( y += 2; frame.render_widget( - label("Target dir (blank = derive from repo):", focus == FormFocus::Dir), + label( + "Target dir (blank = derive from repo):", + focus == FormFocus::Dir, + ), Rect { x: inner_x, y, diff --git a/src/tui/shift.rs b/src/tui/shift.rs index 469429b..9848ef9 100644 --- a/src/tui/shift.rs +++ b/src/tui/shift.rs @@ -3,6 +3,7 @@ //! Two-phase selection: //! 1. Select START commit (oldest) //! 2. Select END commit (newest) +//! //! Then review, enter message, confirm. use crossterm::event::{self, Event, KeyCode, KeyEventKind}; @@ -61,15 +62,11 @@ pub fn run_shift(entries: Vec) -> std::io::Result { } match key.code { KeyCode::Char('q') | KeyCode::Esc => break ShiftAction::Cancel, - KeyCode::Up => { - if state.cursor > 0 { - state.cursor -= 1; - } + KeyCode::Up if state.cursor > 0 => { + state.cursor -= 1; } - KeyCode::Down => { - if state.cursor + 1 < state.entries.len() { - state.cursor += 1; - } + KeyCode::Down if state.cursor + 1 < state.entries.len() => { + state.cursor += 1; } KeyCode::Enter => { match state.phase { diff --git a/src/tui/travel.rs b/src/tui/travel.rs index ac38e28..9d75c87 100644 --- a/src/tui/travel.rs +++ b/src/tui/travel.rs @@ -186,9 +186,7 @@ fn draw_travel(frame: &mut Frame, state: &mut TravelState) { }; let list_area = Rect { y: area.y + header_height, - height: area - .height - .saturating_sub(header_height + footer_height), + height: area.height.saturating_sub(header_height + footer_height), ..area }; let footer_area = Rect { @@ -278,7 +276,9 @@ fn draw_travel(frame: &mut Frame, state: &mut TravelState) { let marker = if is_cursor { "→" } else { " " }; let head_tag = if i == 0 { " [HEAD]" } else { "" }; let style = if is_cursor { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default() }; @@ -294,8 +294,7 @@ fn draw_travel(frame: &mut Frame, state: &mut TravelState) { )) .style(style), Line::from(format!(" {}", first_line(&entry.message))).style(style), - Line::from(format!(" {} · {}", entry.author, entry.time_unix)) - .style(style), + Line::from(format!(" {} · {}", entry.author, entry.time_unix)).style(style), ]; let para = Paragraph::new(lines); frame.render_widget(para, entry_area); @@ -322,6 +321,7 @@ fn first_line(s: &str) -> &str { /// Compute how many entries fit in `inner_rows` of vertical space, given /// each entry slot is `ENTRY_LINES` content + `ENTRY_SPACING` gutter. /// Pulled out as a free function so the math is testable without a Frame. +#[cfg(test)] fn viewport_entries(inner_rows: usize) -> usize { if inner_rows >= ENTRY_LINES { ((inner_rows + ENTRY_SPACING) / ENTRY_SLOT).max(1) @@ -330,6 +330,51 @@ fn viewport_entries(inner_rows: usize) -> usize { } } +fn prompt_travel_action(seal_index: u64) -> std::io::Result { + println!("\nSelected seal at index {}", seal_index); + println!("\n? What would you like to do?"); + println!(" 1. Diverge - Create new timeline from this seal"); + println!(" 2. Overwrite - Reset current timeline"); + println!(" 3. Cancel"); + print!("\nChoice: "); + use std::io::Write; + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + + match input.trim() { + "1" => { + print!("Enter new timeline name: "); + std::io::stdout().flush()?; + let mut name = String::new(); + std::io::stdin().read_line(&mut name)?; + let name = name.trim().to_string(); + if name.is_empty() { + Ok(TravelAction::Cancel) + } else { + Ok(TravelAction::Diverge { + seal_index, + new_timeline: name, + }) + } + } + "2" => { + print!("WARNING: This will remove commits. Type 'yes' to confirm: "); + std::io::stdout().flush()?; + let mut confirm = String::new(); + std::io::stdin().read_line(&mut confirm)?; + if confirm.trim() == "yes" { + Ok(TravelAction::Overwrite { seal_index }) + } else { + println!("Aborted."); + Ok(TravelAction::Cancel) + } + } + _ => Ok(TravelAction::Cancel), + } +} + #[cfg(test)] mod tests { use super::*; @@ -386,12 +431,14 @@ mod tests { // Step the cursor through the first viewport — offset stays 0. for _ in 0..6 { s.cursor += 1; - let v = s.viewport; adjust_offset(&mut s, v); + let v = s.viewport; + adjust_offset(&mut s, v); assert_eq!(s.offset, 0, "cursor still in viewport"); } // 7th press scrolls by 1 (cursor=7, offset becomes 1). s.cursor += 1; - let v = s.viewport; adjust_offset(&mut s, v); + let v = s.viewport; + adjust_offset(&mut s, v); assert_eq!(s.cursor, 7); assert_eq!(s.offset, 1); @@ -407,7 +454,8 @@ mod tests { s.viewport = 1; for expected in 1..10 { s.cursor += 1; - let v = s.viewport; adjust_offset(&mut s, v); + let v = s.viewport; + adjust_offset(&mut s, v); assert_eq!(s.cursor, expected); assert_eq!(s.offset, expected, "viewport=1 → offset tracks cursor"); } @@ -421,52 +469,8 @@ mod tests { s.cursor = 30; s.offset = 24; s.cursor = 0; - let v = s.viewport; adjust_offset(&mut s, v); + let v = s.viewport; + adjust_offset(&mut s, v); assert_eq!(s.offset, 0); } } - -fn prompt_travel_action(seal_index: u64) -> std::io::Result { - println!("\nSelected seal at index {}", seal_index); - println!("\n? What would you like to do?"); - println!(" 1. Diverge - Create new timeline from this seal"); - println!(" 2. Overwrite - Reset current timeline"); - println!(" 3. Cancel"); - print!("\nChoice: "); - use std::io::Write; - std::io::stdout().flush()?; - - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - - match input.trim() { - "1" => { - print!("Enter new timeline name: "); - std::io::stdout().flush()?; - let mut name = String::new(); - std::io::stdin().read_line(&mut name)?; - let name = name.trim().to_string(); - if name.is_empty() { - Ok(TravelAction::Cancel) - } else { - Ok(TravelAction::Diverge { - seal_index, - new_timeline: name, - }) - } - } - "2" => { - print!("WARNING: This will remove commits. Type 'yes' to confirm: "); - std::io::stdout().flush()?; - let mut confirm = String::new(); - std::io::stdin().read_line(&mut confirm)?; - if confirm.trim() == "yes" { - Ok(TravelAction::Overwrite { seal_index }) - } else { - println!("Aborted."); - Ok(TravelAction::Cancel) - } - } - _ => Ok(TravelAction::Cancel), - } -} diff --git a/src/tui/views/diff.rs b/src/tui/views/diff.rs index 511c04e..e00beb5 100644 --- a/src/tui/views/diff.rs +++ b/src/tui/views/diff.rs @@ -19,6 +19,12 @@ pub struct DiffTabView { show_staged: bool, } +impl Default for DiffTabView { + fn default() -> Self { + Self::new() + } +} + impl DiffTabView { pub fn new() -> Self { Self { @@ -171,16 +177,15 @@ impl TabView for DiffTabView { text: format!("=== deleted: {}", file_path), }); // Try to read from CAS using last known hash - if let Some(hash) = file.hash { - if let Ok(data) = ctx.repo.cas.get(hash) { - if let Ok(content) = String::from_utf8(data) { - for line in content.lines() { - diff_lines.push(DiffLine { - kind: DiffLineKind::Remove, - text: format!("-{}", line), - }); - } - } + if let Some(hash) = file.hash + && let Ok(data) = ctx.repo.cas.get(hash) + && let Ok(content) = String::from_utf8(data) + { + for line in content.lines() { + diff_lines.push(DiffLine { + kind: DiffLineKind::Remove, + text: format!("-{}", line), + }); } } } diff --git a/src/tui/views/fuse.rs b/src/tui/views/fuse.rs index 781e3f3..96024b6 100644 --- a/src/tui/views/fuse.rs +++ b/src/tui/views/fuse.rs @@ -8,7 +8,7 @@ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; use crate::fuse::Strategy; use crate::hash::B3Hash; -use crate::tui::resolver::{ConflictItem, Resolution, CHOICES}; +use crate::tui::resolver::{CHOICES, ConflictItem, Resolution}; use crate::tui::theme::Theme; use crate::tui::types::{Action, AppContext}; use crate::tui::views::TabView; @@ -48,6 +48,12 @@ pub struct FuseView { pending: Option, } +impl Default for FuseView { + fn default() -> Self { + Self::new() + } +} + impl FuseView { pub fn new() -> Self { Self { @@ -115,13 +121,8 @@ impl FuseView { let theirs = self.load_tree_map(ctx, their_tree); let store = crate::fsmerkle::FsStore::new(&ctx.repo.cas); - let result = crate::fuse::FuseEngine::fuse( - &store, - &base, - &ours, - &theirs, - self.current_strategy(), - ); + let result = + crate::fuse::FuseEngine::fuse(&store, &base, &ours, &theirs, self.current_strategy()); if result.success { return self.commit_merged(ctx, &result.merged_files, &source_name, ¤t); @@ -188,18 +189,18 @@ impl FuseView { return self.abort_resolver(ctx); } KeyCode::Up | KeyCode::Char('k') => { - if let Some(p) = self.pending.as_mut() { - if p.cursor > 0 { - p.cursor -= 1; - } + if let Some(p) = self.pending.as_mut() + && p.cursor > 0 + { + p.cursor -= 1; } return Action::Consumed; } KeyCode::Down | KeyCode::Char('j') => { - if let Some(p) = self.pending.as_mut() { - if p.cursor + 1 < CHOICES.len() { - p.cursor += 1; - } + if let Some(p) = self.pending.as_mut() + && p.cursor + 1 < CHOICES.len() + { + p.cursor += 1; } return Action::Consumed; } @@ -302,11 +303,7 @@ impl FuseView { self.commit_merged(ctx, &final_map, &p.source_name, &p.target_name) } - fn load_tree_map( - &self, - ctx: &AppContext, - tree_hash: B3Hash, - ) -> BTreeMap { + fn load_tree_map(&self, ctx: &AppContext, tree_hash: B3Hash) -> BTreeMap { let store = crate::fsmerkle::FsStore::new(&ctx.repo.cas); let mut map = BTreeMap::new(); let _ = Self::collect_tree(&store, tree_hash, "", &mut map); @@ -344,8 +341,15 @@ impl FuseView { format!(" Resolve conflicts — {}/{} decided", resolved, total), theme.title, )) - .block(Block::default().borders(Borders::ALL).title(" Fuse Resolver ")); - let header_area = Rect { height: 3.min(area.height), ..area }; + .block( + Block::default() + .borders(Borders::ALL) + .title(" Fuse Resolver "), + ); + let header_area = Rect { + height: 3.min(area.height), + ..area + }; frame.render_widget(header, header_area); if let Some(conflict) = p.conflicts.get(p.current) { @@ -373,7 +377,10 @@ impl FuseView { ), theme.warning, )), - Line::from(Span::styled(format!(" {}", conflict.description), theme.dim)), + Line::from(Span::styled( + format!(" {}", conflict.description), + theme.dim, + )), ]) .block(Block::default().borders(Borders::ALL)); frame.render_widget(text, conflict_area); @@ -401,8 +408,11 @@ impl FuseView { ListItem::new(Span::styled(text, style)) }) .collect(); - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(" Choose resolution ")); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title(" Choose resolution "), + ); frame.render_widget(list, choices_area); // Footer help. @@ -733,8 +743,7 @@ mod tests { ("d.txt".to_string(), Resolution::Skip), ]; - let (final_map, skipped) = - apply_resolutions(&store, &merged, &ours, &theirs, &resolutions); + let (final_map, skipped) = apply_resolutions(&store, &merged, &ours, &theirs, &resolutions); // Skip leaves the file unresolved and out of the committed map. assert_eq!(skipped, vec!["d.txt".to_string()]); diff --git a/src/tui/views/log.rs b/src/tui/views/log.rs index 3089ccc..2fba8ce 100644 --- a/src/tui/views/log.rs +++ b/src/tui/views/log.rs @@ -17,6 +17,12 @@ pub struct LogView { all_timelines: bool, } +impl Default for LogView { + fn default() -> Self { + Self::new() + } +} + impl LogView { pub fn new() -> Self { Self { @@ -233,7 +239,7 @@ impl TabView for LogView { } } } - all_entries.sort_by(|a, b| b.time_unix.cmp(&a.time_unix)); + all_entries.sort_by_key(|e| std::cmp::Reverse(e.time_unix)); self.entries = all_entries; } else { self.entries = ctx.repo.walk_history(&timeline).unwrap_or_default(); diff --git a/src/tui/views/remote.rs b/src/tui/views/remote.rs index 1bd3ed4..07568da 100644 --- a/src/tui/views/remote.rs +++ b/src/tui/views/remote.rs @@ -51,6 +51,12 @@ pub struct RemoteView { pub bg_receiver: Option>, } +impl Default for RemoteView { + fn default() -> Self { + Self::new() + } +} + impl RemoteView { pub fn new() -> Self { let (tx, rx) = mpsc::channel(); @@ -173,9 +179,8 @@ impl RemoteView { verification_uri: device.verification_uri.clone(), }); let result = (|| -> Result<(), String> { - let token = - GitHubClient::poll_for_token(&device.device_code, device.interval) - .map_err(|e| e.to_string())?; + let token = GitHubClient::poll_for_token(&device.device_code, device.interval) + .map_err(|e| e.to_string())?; let store = TokenStore::new().map_err(|e| e.to_string())?; store .save_token(Platform::GitHub, token) @@ -198,13 +203,14 @@ impl RemoteView { std::thread::spawn(move || { use crate::auth; use crate::portal::Platform; - let lines: Vec = [(Platform::GitHub, "GitHub"), (Platform::GitLab, "GitLab")] - .iter() - .map(|(platform, name)| match auth::resolve_auth(*platform) { - Some(method) => format!("{}: {}", name, method.description), - None => format!("{}: Not authenticated", name), - }) - .collect(); + let lines: Vec = + [(Platform::GitHub, "GitHub"), (Platform::GitLab, "GitLab")] + .iter() + .map(|(platform, name)| match auth::resolve_auth(*platform) { + Some(method) => format!("{}: {}", name, method.description), + None => format!("{}: Not authenticated", name), + }) + .collect(); let _ = tx.send(BgResult::AuthStatusDone(lines)); }); } @@ -249,10 +255,10 @@ impl RemoteView { if let Some(tx) = self.bg_sender.clone() { std::thread::spawn(move || { - let result = (|| -> Result, String> { + let result = { let client = crate::github::GitHubClient::new(); crate::sync::scout(&client, &portal_clone).map_err(|e| e.to_string()) - })(); + }; let _ = tx.send(BgResult::ScoutDone(result)); }); } @@ -461,11 +467,7 @@ impl TabView for RemoteView { // Top header: portal line + status line let header_lines = 2u16; // Optional auth panel (device-code prompt and/or status query result). - let auth_lines: u16 = self - .auth_device_prompt - .as_ref() - .map(|_| 3) - .unwrap_or(0) + let auth_lines: u16 = self.auth_device_prompt.as_ref().map(|_| 3).unwrap_or(0) + self.auth_status_lines.len() as u16; let portal_text = match &self.portal_info { diff --git a/src/tui/views/review.rs b/src/tui/views/review.rs index 77a4bcd..b2d6036 100644 --- a/src/tui/views/review.rs +++ b/src/tui/views/review.rs @@ -30,6 +30,12 @@ pub struct ReviewView { confirm_close: bool, } +impl Default for ReviewView { + fn default() -> Self { + Self::new() + } +} + impl ReviewView { pub fn new() -> Self { Self { @@ -46,7 +52,7 @@ impl ReviewView { } } - fn handle_list_event(&mut self, event: &KeyEvent, ctx: &mut AppContext) -> Action { + fn handle_list_event(&mut self, event: &KeyEvent, _ctx: &mut AppContext) -> Action { match event.code { KeyCode::Char('j') | KeyCode::Down => { if !self.reviews.is_empty() && self.cursor < self.reviews.len() - 1 { @@ -246,10 +252,10 @@ impl ReviewView { } KeyCode::Char('q') => { // Close the review - if let Some(ref review) = self.selected_review { - if review.status != ReviewStatus::Merged { - self.confirm_close = true; - } + if let Some(ref review) = self.selected_review + && review.status != ReviewStatus::Merged + { + self.confirm_close = true; } Action::Consumed } @@ -602,10 +608,10 @@ impl TabView for ReviewView { self.cursor = self.reviews.len() - 1; } // Refresh selected review if in detail/diff mode - if let Some(ref current) = self.selected_review { - if let Ok(Some(updated)) = ctx.repo.load_review(current.id) { - self.selected_review = Some(updated); - } + if let Some(ref current) = self.selected_review + && let Ok(Some(updated)) = ctx.repo.load_review(current.id) + { + self.selected_review = Some(updated); } } diff --git a/src/tui/views/shelves.rs b/src/tui/views/shelves.rs index 9bd35e5..8ebebe1 100644 --- a/src/tui/views/shelves.rs +++ b/src/tui/views/shelves.rs @@ -101,6 +101,12 @@ pub struct ShelvesView { message: Option<(String, bool)>, } +impl Default for ShelvesView { + fn default() -> Self { + Self::new() + } +} + impl ShelvesView { pub fn new() -> Self { Self { @@ -240,7 +246,7 @@ impl TabView for ShelvesView { theme.title, ))); lines.push(Line::from("")); - for (path, _) in &shelf.staged_files { + for path in shelf.staged_files.keys() { lines.push(Line::from(vec![ Span::styled(" S ", theme.help_key), Span::raw(path.clone()), @@ -276,10 +282,7 @@ impl TabView for ShelvesView { let msg = match &self.message { Some((m, true)) => Span::styled(format!(" {}", m), theme.error), Some((m, false)) => Span::styled(format!(" {}", m), theme.success), - None => Span::styled( - " Enter:expand d:drop r:refresh", - theme.dim, - ), + None => Span::styled(" Enter:expand d:drop r:refresh", theme.dim), }; frame.render_widget(Paragraph::new(msg), help_area); } @@ -287,10 +290,7 @@ impl TabView for ShelvesView { fn load_data(&mut self, ctx: &AppContext) { let mgr = ShelfManager::new(&ctx.ivaldi_dir); - let names = match mgr.list_shelves() { - Ok(n) => n, - Err(_) => Vec::new(), - }; + let names = mgr.list_shelves().unwrap_or_default(); let mut summaries: Vec = Vec::new(); for name in names { @@ -299,7 +299,7 @@ impl TabView for ShelvesView { } } // Most recently modified first. - summaries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + summaries.sort_by_key(|s| std::cmp::Reverse(s.created_at)); self.summaries = summaries; if self.cursor >= self.summaries.len() { diff --git a/src/tui/views/status.rs b/src/tui/views/status.rs index 5c43ea0..8108869 100644 --- a/src/tui/views/status.rs +++ b/src/tui/views/status.rs @@ -32,6 +32,12 @@ pub struct StatusView { message: Option, } +impl Default for StatusView { + fn default() -> Self { + Self::new() + } +} + impl StatusView { pub fn new() -> Self { Self { @@ -54,11 +60,11 @@ impl StatusView { if paths.is_empty() { // If nothing selected, gather current item - if let Some(item) = self.file_list.current_item() { - if !matches!(item.state, FileState::Staged) { - let path = item.path.clone(); - return self.do_gather(ctx, &[path]); - } + if let Some(item) = self.file_list.current_item() + && !matches!(item.state, FileState::Staged) + { + let path = item.path.clone(); + return self.do_gather(ctx, &[path]); } return Action::Consumed; } @@ -98,11 +104,11 @@ impl StatusView { if paths.is_empty() { // Ungather current - if let Some(item) = self.file_list.current_item() { - if matches!(item.state, FileState::Staged) { - staging.unstage(&item.path); - count = 1; - } + if let Some(item) = self.file_list.current_item() + && matches!(item.state, FileState::Staged) + { + staging.unstage(&item.path); + count = 1; } } else { for path in &paths { diff --git a/src/tui/views/timeline.rs b/src/tui/views/timeline.rs index dece323..ffe24d7 100644 --- a/src/tui/views/timeline.rs +++ b/src/tui/views/timeline.rs @@ -36,6 +36,12 @@ pub struct TimelineView { message: Option<(String, bool)>, } +impl Default for TimelineView { + fn default() -> Self { + Self::new() + } +} + impl TimelineView { pub fn new() -> Self { Self { @@ -170,7 +176,8 @@ impl TabView for TimelineView { KeyCode::Char('R') => { if let Some(row) = self.rows.get(self.cursor) { self.dialog_mode = Some(DialogMode::Rename); - self.dialog.show_with_value("Rename Timeline", row.name.clone()); + self.dialog + .show_with_value("Rename Timeline", row.name.clone()); } Action::Consumed } @@ -332,13 +339,19 @@ impl TimelineView { if let Err(e) = ctx.repo.create_timeline(name, Some(&parent)) { return Action::Error(format!("Create failed: {}", e)); } - if let Err(e) = ctx.repo.store_butterfly_meta(name, &parent, divergence_hash) { + if let Err(e) = ctx + .repo + .store_butterfly_meta(name, &parent, divergence_hash) + { return Action::Error(format!("Butterfly metadata failed: {}", e)); } if let Err(e) = ctx.repo.switch_timeline(name) { return Action::Error(format!("Switch failed: {}", e)); } - self.message = Some((format!("Created butterfly '{}' from '{}'", name, parent), false)); + self.message = Some(( + format!("Created butterfly '{}' from '{}'", name, parent), + false, + )); Action::Refresh } diff --git a/src/workspace.rs b/src/workspace.rs index 4424d8c..534e6ee 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -8,7 +8,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::io::Write; use std::path::{Path, PathBuf}; use crate::cas::{Cas, CasError}; @@ -131,16 +130,16 @@ impl StagingArea { fs::create_dir_all(&stage_dir)?; let stage_file = stage_dir.join("files"); - let mut file = fs::File::create(&stage_file)?; + let mut content = String::new(); for (path, hash) in &self.staged { - writeln!(file, "{} {}", hash, path)?; + content.push_str(&format!("{} {}\n", hash, path)); } for path in &self.deletions { - writeln!(file, "del {}", path)?; + content.push_str(&format!("del {}\n", path)); } - Ok(()) + crate::atomic_io::atomic_write(&stage_file, content.as_bytes()) } /// Load staging area from disk. @@ -159,10 +158,10 @@ impl StagingArea { } if let Some(rest) = line.strip_prefix("del ") { staging.stage_deletion(rest); - } else if let Some((hash_str, path)) = line.split_once(' ') { - if let Some(hash) = B3Hash::from_hex(hash_str) { - staging.stage(path, hash); - } + } else if let Some((hash_str, path)) = line.split_once(' ') + && let Some(hash) = B3Hash::from_hex(hash_str) + { + staging.stage(path, hash); } } staging @@ -228,10 +227,8 @@ impl<'a> Workspace<'a> { continue; } self.scan_dir(&entry.path(), &rel_path, ignore, files)?; - } else if file_type.is_file() { - if !ignore.is_ignored(&rel_path) { - files.push(rel_path); - } + } else if file_type.is_file() && !ignore.is_ignored(&rel_path) { + files.push(rel_path); } } @@ -249,6 +246,18 @@ impl<'a> Workspace<'a> { &mut self, paths: &[&str], allowlist: &DotfileAllowlist, + ) -> Result { + self.gather_with_progress(paths, allowlist, &mut |_| {}) + } + + /// Like [`Workspace::gather`], but invokes `on` with each gathered path + /// right after its content is stored in the CAS. Used by the CLI to drive + /// a progress bar during hashing. + pub fn gather_with_progress( + &mut self, + paths: &[&str], + allowlist: &DotfileAllowlist, + on: &mut dyn FnMut(&str), ) -> Result { let mut gathered = Vec::new(); let mut needs_confirmation = Vec::new(); @@ -280,6 +289,7 @@ impl<'a> Workspace<'a> { self.cas .put(hash, &canonical) .map_err(WorkspaceError::Cas)?; + on(path); self.staging.stage(path, hash); gathered.push(path.to_string()); @@ -323,12 +333,23 @@ impl<'a> Workspace<'a> { /// Returns a `GatherResult` with skipped dotfiles in `needs_confirmation` /// so the caller can report them to the user. pub fn gather_all(&mut self, ignore: &PatternCache) -> Result { + self.gather_all_with_progress(ignore, &mut |_| {}) + } + + /// Like [`Workspace::gather_all`], but invokes `on` with each gathered + /// path right after its content is stored in the CAS. + pub fn gather_all_with_progress( + &mut self, + ignore: &PatternCache, + on: &mut dyn FnMut(&str), + ) -> Result { let files = self.scan(ignore)?; // scan() already excludes dotfiles via is_ignored(), so no allowlist needed let allowlist = DotfileAllowlist::load(&self.ivaldi_dir); - let result = self.gather( + let result = self.gather_with_progress( &files.iter().map(|s| s.as_str()).collect::>(), &allowlist, + on, )?; // Discover dotfiles that were skipped so the caller can report them @@ -424,17 +445,14 @@ impl<'a> Workspace<'a> { /// every other seal, the parent tree must be supplied so that files not /// touched by the current staging area are inherited from the parent /// rather than silently dropped. - pub fn build_seal_tree( - &self, - parent_tree: Option, - ) -> Result { + pub fn build_seal_tree(&self, parent_tree: Option) -> Result { let store = FsStore::new(self.cas); let mut file_map: BTreeMap = BTreeMap::new(); - if let Some(parent_hash) = parent_tree { - if parent_hash != B3Hash::ZERO { - self.collect_tree_files(&store, parent_hash, "", &mut file_map)?; - } + if let Some(parent_hash) = parent_tree + && parent_hash != B3Hash::ZERO + { + self.collect_tree_files(&store, parent_hash, "", &mut file_map)?; } for path in self.staging.staged_deletions() { @@ -481,11 +499,11 @@ impl<'a> Workspace<'a> { // Build set of known files from last seal let mut known_files: BTreeMap = BTreeMap::new(); - if let Some(tree_hash) = last_tree { - if tree_hash != B3Hash::ZERO { - let store = FsStore::new(self.cas); - self.collect_tree_files(&store, tree_hash, "", &mut known_files)?; - } + if let Some(tree_hash) = last_tree + && tree_hash != B3Hash::ZERO + { + let store = FsStore::new(self.cas); + self.collect_tree_files(&store, tree_hash, "", &mut known_files)?; } let disk_set: BTreeSet<&str> = disk_files.iter().map(|s| s.as_str()).collect(); @@ -677,7 +695,10 @@ impl<'a> Workspace<'a> { }; if needs_write { // If a symlink currently occupies this path, drop it first. - if full_path.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false) + if full_path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) { let _ = fs::remove_file(full_path); } @@ -727,10 +748,10 @@ impl<'a> Workspace<'a> { ) -> Result, WorkspaceError> { let store = FsStore::new(self.cas); let mut known_files: BTreeMap = BTreeMap::new(); - if let Some(tree_hash) = base_tree { - if tree_hash != B3Hash::ZERO { - self.collect_tree_files(&store, tree_hash, "", &mut known_files)?; - } + if let Some(tree_hash) = base_tree + && tree_hash != B3Hash::ZERO + { + self.collect_tree_files(&store, tree_hash, "", &mut known_files)?; } let disk_files = self.scan(ignore)?; @@ -769,9 +790,7 @@ impl<'a> Workspace<'a> { // Files in the base tree but missing from disk are Deleted. for path in known_files.keys() { if !disk_set.contains(path.as_str()) { - changes.push(crate::shelf::WorkspaceChange::Deleted { - path: path.clone(), - }); + changes.push(crate::shelf::WorkspaceChange::Deleted { path: path.clone() }); } } @@ -790,8 +809,7 @@ impl<'a> Workspace<'a> { match change { crate::shelf::WorkspaceChange::Modified { path, hash } | crate::shelf::WorkspaceChange::Untracked { path, hash } => { - let (_, content) = - store.load_blob(*hash).map_err(WorkspaceError::FsMerkle)?; + let (_, content) = store.load_blob(*hash).map_err(WorkspaceError::FsMerkle)?; let full_path = self.work_dir.join(path); if let Some(parent) = full_path.parent() { fs::create_dir_all(parent).map_err(WorkspaceError::Io)?; @@ -861,7 +879,7 @@ impl DotfileAllowlist { pub fn save(&self) -> Result<(), std::io::Error> { let content: String = self.allowed.iter().map(|s| format!("{}\n", s)).collect(); - fs::write(&self.path, content) + crate::atomic_io::atomic_write(&self.path, content.as_bytes()) } } @@ -937,6 +955,24 @@ mod tests { assert!(loaded.is_staged("src/main.rs")); } + #[test] + fn staging_area_load_tolerates_truncated_file() { + // A crash mid-write could historically truncate the stage file. + // load() must skip bad lines without panicking. + let dir = tempfile::tempdir().unwrap(); + let ivaldi_dir = dir.path().join(".ivaldi"); + fs::create_dir_all(ivaldi_dir.join("stage")).unwrap(); + + let good_hash = B3Hash::digest(b"content"); + let content = format!("{} file.txt\ndel old.txt\nabc12", good_hash); + fs::write(ivaldi_dir.join("stage/files"), content).unwrap(); + + let loaded = StagingArea::load(&ivaldi_dir); + assert!(loaded.is_staged("file.txt")); + assert!(loaded.is_staged_for_deletion("old.txt")); + assert_eq!(loaded.len(), 2); + } + #[test] fn staging_area_deletion_and_addition_are_mutually_exclusive() { let mut staging = StagingArea::new(); @@ -1041,6 +1077,31 @@ mod tests { assert_eq!(cas.len(), 1); // Content stored in CAS } + #[test] + fn gather_with_progress_fires_callback_per_file() { + let (dir, cas) = setup_workspace(); + fs::write(dir.path().join("a.txt"), "aaa").unwrap(); + fs::write(dir.path().join("b.txt"), "bbb").unwrap(); + fs::write(dir.path().join("c.txt"), "ccc").unwrap(); + + let allowlist = empty_allowlist(&dir); + let mut ws = Workspace::new(&cas, dir.path(), dir.path().join(".ivaldi")); + + let mut seen = Vec::new(); + let result = ws + .gather_with_progress( + &["a.txt", "b.txt", "c.txt", "missing.txt"], + &allowlist, + &mut |p| seen.push(p.to_string()), + ) + .unwrap(); + + // Callback fires exactly once per gathered file; missing files are + // skipped without a callback. + assert_eq!(seen, vec!["a.txt", "b.txt", "c.txt"]); + assert_eq!(result.gathered, seen); + } + #[test] fn gather_all() { let (dir, cas) = setup_workspace(); @@ -1116,10 +1177,22 @@ mod tests { ws.collect_tree_files(&FsStore::new(&cas), new_tree, "", &mut files) .unwrap(); - assert!(files.contains_key("a.txt"), "parent file a.txt should survive"); - assert!(files.contains_key("b.txt"), "parent file b.txt should survive"); - assert!(files.contains_key("c.txt"), "parent file c.txt should survive"); - assert!(files.contains_key("d.txt"), "newly staged d.txt should be present"); + assert!( + files.contains_key("a.txt"), + "parent file a.txt should survive" + ); + assert!( + files.contains_key("b.txt"), + "parent file b.txt should survive" + ); + assert!( + files.contains_key("c.txt"), + "parent file c.txt should survive" + ); + assert!( + files.contains_key("d.txt"), + "newly staged d.txt should be present" + ); } #[test] @@ -1167,7 +1240,10 @@ mod tests { ws.collect_tree_files(&FsStore::new(&cas), new_tree, "", &mut files) .unwrap(); assert!(files.contains_key("a.txt")); - assert!(!files.contains_key("b.txt"), "b.txt should have been removed"); + assert!( + !files.contains_key("b.txt"), + "b.txt should have been removed" + ); assert!(files.contains_key("c.txt")); } @@ -1235,7 +1311,10 @@ mod tests { let lmeta = fs::symlink_metadata(dir.path().join("link")).unwrap(); assert!(lmeta.file_type().is_symlink(), "link is a real symlink"); assert_eq!( - fs::read_link(dir.path().join("link")).unwrap().to_str().unwrap(), + fs::read_link(dir.path().join("link")) + .unwrap() + .to_str() + .unwrap(), "regular.txt" ); From 35f62811b27bb55a4c814dff1fae4d722ba0c831 Mon Sep 17 00:00:00 2001 From: javanhut Date: Wed, 10 Jun 2026 00:56:02 -0400 Subject: [PATCH 3/3] feat: updated config settings --- docs/cli.md | 5 +- docs/config.md | 17 +++++ src/cli/commands.rs | 18 ++++- src/cli/mod.rs | 10 ++- src/config.rs | 145 +++++++++++++++++++++++++++++++++++++++++ src/tui/config_form.rs | 11 +--- 6 files changed, 190 insertions(+), 16 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 2370ce0..3604d07 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -148,7 +148,10 @@ ivaldi exclude "*.log" "build/" "node_modules/" | `--global` | Target `~/.ivaldi/config` instead of repo-local | | (no flag) | Launch the interactive ratatui form | -`configure` is an alias for `config`. +`configure` is an alias for `config`. `ivaldi config --help` lists every +known key with a description and example; `--set` validates values per +key (email shape, true/false toggles, repo specs) and rejects dotless +keys. See [config.md](config.md) for the full key reference. The interactive form's first field is the **scope** — toggle between repo-local and global with ←/→ or Enter; the form reloads from (and saves diff --git a/docs/config.md b/docs/config.md index c277610..6764593 100644 --- a/docs/config.md +++ b/docs/config.md @@ -8,6 +8,23 @@ Two-level configuration with repository overriding user settings: - **User (global) config**: `~/.ivaldi/config` (applies to all repos) - **Repo (local) config**: `.ivaldi/config` (per-repository overrides) +## Known keys + +These are the keys ivaldi reads (also shown by `ivaldi config --help`): + +| Key | Meaning | Valid values | +|-----|---------|--------------| +| `user.name` | Author name recorded in every seal | non-empty string | +| `user.email` | Author email recorded alongside the name | `name@domain.tld` | +| `color.ui` | Colored CLI output | `true` / `false` | +| `core.autoshelf` | Auto-shelve uncommitted changes on timeline switch | `true` / `false` | +| `portal.default` | Default remote for upload/sync with several portals | repo spec (`owner/repo` or URL) | + +`--set` validates values per key (bad emails, non-boolean toggles, and +malformed repo specs are rejected). Keys must use the `section.field` +form — a dotless key is an error. Unknown dotted keys are saved with a +warning, so forward-compatible/custom keys still work. + ## Format INI-style with sections: diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 3ea6fac..11afdd9 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -2707,12 +2707,26 @@ fn cmd_config(args: ConfigArgs) -> Result<(), String> { }; match cfg.get(key) { Some(value) => println!("{}", value), - None => return Err(format!("config key not found: {}", key)), + None => { + return Err(format!( + "config key not found: {}\nKnown keys:\n{}", + key, + config::known_keys_help() + )); + } } return Ok(()); } if let Some(key) = &args.set { - let value = args.value.as_deref().ok_or("value required for --set")?; + let value = args.value.as_deref().ok_or_else(|| { + format!( + "value required for --set. Usage: ivaldi config --set \nKnown keys:\n{}", + config::known_keys_help() + ) + })?; + if let Some(warning) = config::validate_set(key, value)? { + eprintln!("{}", color::dim(&format!("warning: {}", warning))); + } let mut cfg = Config::load(&target_path).unwrap_or_else(|_| Config::new()); cfg.set(key, value); cfg.save(&target_path).map_err(|e| e.to_string())?; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f559dcf..741d9d1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -548,17 +548,21 @@ pub struct WeldArgs { } #[derive(clap::Args, Debug)] +#[command(after_help = format!( + "Known keys:\n{}\n\nExamples:\n ivaldi config --set user.name \"Ada Lovelace\"\n ivaldi config --set user.email ada@example.com\n ivaldi config --global --set color.ui false\n ivaldi config --list\n ivaldi config (interactive form with local/global selector)", + crate::config::known_keys_help() +))] pub struct ConfigArgs { /// List all configuration values #[arg(long)] pub list: bool, - /// Set a configuration value - #[arg(long)] + /// Set a configuration value (key as argument, value positional) + #[arg(long, value_name = "KEY")] pub set: Option, /// Get a configuration value - #[arg(long)] + #[arg(long, value_name = "KEY")] pub get: Option, /// Operate on the global config (~/.ivaldi/config) instead of repo-local diff --git a/src/config.rs b/src/config.rs index 7f66973..1a0a745 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,103 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; +/// A documented configuration key: (key, what it does, example value). +pub const KNOWN_KEYS: &[(&str, &str, &str)] = &[ + ( + "user.name", + "Your name, recorded as the author of every seal", + "\"Ada Lovelace\"", + ), + ( + "user.email", + "Your email, recorded alongside the author name", + "ada@example.com", + ), + ("color.ui", "Colored CLI output (true/false)", "true"), + ( + "core.autoshelf", + "Auto-shelve uncommitted changes on timeline switch (true/false)", + "true", + ), + ( + "portal.default", + "Default remote for upload/sync when several portals are configured", + "owner/repo", + ), +]; + +/// One line per known key, used in `config --help` and error hints. +pub fn known_keys_help() -> String { + KNOWN_KEYS + .iter() + .map(|(key, desc, example)| format!(" {:<16} {} (e.g. {})", key, desc, example)) + .collect::>() + .join("\n") +} + +/// Loose email shape check: `local@domain.tld`. +pub fn is_email_like(s: &str) -> bool { + let (local, rest) = match s.split_once('@') { + Some(p) => p, + None => return false, + }; + if local.is_empty() { + return false; + } + rest.contains('.') && !rest.starts_with('.') && !rest.ends_with('.') +} + +/// Validate a `--set` request. `Err` blocks the write (malformed key or +/// value); `Ok(Some(warning))` allows it with a caveat (unknown key). +pub fn validate_set(key: &str, value: &str) -> Result, String> { + if !key.contains('.') { + return Err(format!( + "config keys use the form 'section.field' (got '{}').\nKnown keys:\n{}", + key, + known_keys_help() + )); + } + match key { + "user.name" => { + if value.trim().is_empty() { + return Err("user.name cannot be empty".into()); + } + } + "user.email" => { + if !is_email_like(value) { + return Err(format!( + "'{}' doesn't look like an email address (expected name@domain.tld)", + value + )); + } + } + "color.ui" | "core.autoshelf" => { + if value != "true" && value != "false" { + return Err(format!( + "{} must be 'true' or 'false' (got '{}')", + key, value + )); + } + } + "portal.default" => { + if crate::portal::parse_repo_spec(value).is_err() { + return Err(format!( + "'{}' is not a valid repo spec (expected owner/repo or a full URL)", + value + )); + } + } + unknown => { + return Ok(Some(format!( + "'{}' is not a key ivaldi reads — saving it anyway.\nKnown keys:\n{}", + unknown, + known_keys_help() + ))); + } + } + Ok(None) +} + /// Ivaldi configuration. #[derive(Debug, Clone, Default)] pub struct Config { @@ -194,6 +291,54 @@ mod tests { assert_eq!(cfg.get("core.autoshelf"), Some("true")); } + #[test] + fn validate_set_accepts_good_values() { + assert_eq!(validate_set("user.name", "Ada Lovelace"), Ok(None)); + assert_eq!(validate_set("user.email", "ada@example.com"), Ok(None)); + assert_eq!(validate_set("color.ui", "false"), Ok(None)); + assert_eq!(validate_set("core.autoshelf", "true"), Ok(None)); + assert_eq!(validate_set("portal.default", "owner/repo"), Ok(None)); + } + + #[test] + fn validate_set_rejects_bad_values() { + // Dotless keys would be silently dropped by the serializer — hard error. + let err = validate_set("username", "Ada").unwrap_err(); + assert!(err.contains("section.field")); + assert!(err.contains("user.name")); // hint lists known keys + + assert!(validate_set("user.name", " ").is_err()); + assert!(validate_set("user.email", "not-an-email").is_err()); + assert!(validate_set("user.email", "a@b").is_err()); + assert!(validate_set("color.ui", "yes").is_err()); + assert!(validate_set("core.autoshelf", "1").is_err()); + assert!(validate_set("portal.default", "not a spec").is_err()); + } + + #[test] + fn validate_set_warns_on_unknown_dotted_key() { + let warning = validate_set("custom.thing", "anything").unwrap(); + assert!(warning.unwrap().contains("not a key ivaldi reads")); + } + + #[test] + fn known_keys_help_mentions_every_key() { + let help = known_keys_help(); + for (key, _, _) in KNOWN_KEYS { + assert!(help.contains(key), "help missing {}", key); + } + } + + #[test] + fn email_shape_check() { + assert!(is_email_like("ada@example.com")); + assert!(is_email_like("a.b+c@sub.domain.io")); + assert!(!is_email_like("ada")); + assert!(!is_email_like("@example.com")); + assert!(!is_email_like("ada@example")); + assert!(!is_email_like("ada@.com")); + } + #[test] fn get_set() { let mut cfg = Config::new(); diff --git a/src/tui/config_form.rs b/src/tui/config_form.rs index 9714a47..c8fc5b4 100644 --- a/src/tui/config_form.rs +++ b/src/tui/config_form.rs @@ -292,16 +292,7 @@ fn event_loop(terminal: &mut Terminal, state: &mut State) -> io:: } } -fn is_email_like(s: &str) -> bool { - let (local, rest) = match s.split_once('@') { - Some(p) => p, - None => return false, - }; - if local.is_empty() { - return false; - } - rest.contains('.') && !rest.starts_with('.') && !rest.ends_with('.') -} +use crate::config::is_email_like; fn draw(frame: &mut Frame, state: &State) { let area = frame.area();