jj workspaces over the network
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(cli): add jj-lib integration - reading and writing repo state

+259
+259
crates/tandem-cli/src/repo.rs
··· 1 + use std::path::{Path, PathBuf}; 2 + use std::collections::HashMap; 3 + use tandem_core::types::{Change, ChangeId, Identity, TreeHash}; 4 + use jj_lib::workspace::Workspace; 5 + use jj_lib::settings::UserSettings; 6 + use jj_lib::repo::{StoreFactories, Repo}; 7 + use jj_lib::revset::RevsetExpression; 8 + use jj_lib::object_id::ObjectId; 9 + use chrono::{DateTime, Utc}; 10 + 11 + /// Error type for repo operations 12 + #[derive(Debug, thiserror::Error)] 13 + pub enum RepoError { 14 + #[error("Repository not found at {0}")] 15 + NotFound(PathBuf), 16 + #[error("Not a jj repository")] 17 + NotJjRepo, 18 + #[error("IO error: {0}")] 19 + Io(#[from] std::io::Error), 20 + #[error("Internal error: {0}")] 21 + Internal(String), 22 + } 23 + 24 + /// Wrapper around jj repository 25 + pub struct JjRepo { 26 + path: PathBuf, 27 + workspace: Workspace, 28 + } 29 + 30 + impl JjRepo { 31 + /// Open a jj repository at the given path 32 + pub fn open(path: impl AsRef<Path>) -> Result<Self, RepoError> { 33 + let path = path.as_ref().to_path_buf(); 34 + 35 + // Check for .jj directory 36 + let jj_dir = path.join(".jj"); 37 + if !jj_dir.exists() { 38 + return Err(RepoError::NotJjRepo); 39 + } 40 + 41 + // Create default user settings (required by jj-lib) 42 + let config = jj_lib::config::StackedConfig::empty(); 43 + let settings = UserSettings::from_config(config) 44 + .map_err(|e| RepoError::Internal(format!("Failed to create settings: {}", e)))?; 45 + 46 + // Create store factories for loading the repository 47 + let store_factories = StoreFactories::default(); 48 + 49 + // Empty working copy factories map (use defaults) 50 + let wc_factories = HashMap::new(); 51 + 52 + // Load the workspace 53 + let workspace = Workspace::load(&settings, &path, &store_factories, &wc_factories) 54 + .map_err(|e| RepoError::Internal(format!("Failed to load workspace: {}", e)))?; 55 + 56 + Ok(Self { path, workspace }) 57 + } 58 + 59 + /// Get repository root path 60 + pub fn path(&self) -> &Path { 61 + &self.path 62 + } 63 + 64 + /// List all visible changes in the repository 65 + pub fn list_changes(&self) -> Result<Vec<Change>, RepoError> { 66 + let repo_loader = self.workspace.repo_loader(); 67 + let repo = repo_loader 68 + .load_at_head() 69 + .map_err(|e| RepoError::Internal(format!("Failed to load repo: {}", e)))?; 70 + 71 + // Get all visible commits (equivalent to "jj log") 72 + // Use revset to get all commits 73 + let revset_expression = RevsetExpression::all(); 74 + let evaluated = revset_expression 75 + .evaluate(repo.as_ref()) 76 + .map_err(|e| RepoError::Internal(format!("Failed to evaluate revset: {}", e)))?; 77 + 78 + let mut changes = Vec::new(); 79 + for commit_id_result in evaluated.iter() { 80 + let commit_id = commit_id_result 81 + .map_err(|e| RepoError::Internal(format!("Failed to iterate commits: {}", e)))?; 82 + let commit = repo 83 + .store() 84 + .get_commit(&commit_id) 85 + .map_err(|e| RepoError::Internal(format!("Failed to get commit: {}", e)))?; 86 + 87 + changes.push(Self::convert_commit_to_change(&commit, repo.as_ref())?); 88 + } 89 + 90 + Ok(changes) 91 + } 92 + 93 + /// Convert jj-lib Commit to our Change type 94 + fn convert_commit_to_change( 95 + commit: &jj_lib::commit::Commit, 96 + repo: &jj_lib::repo::ReadonlyRepo, 97 + ) -> Result<Change, RepoError> { 98 + // Convert change_id (jj's stable ID) from bytes 99 + let change_id_bytes = commit.change_id().as_bytes(); 100 + if change_id_bytes.len() != 32 { 101 + return Err(RepoError::Internal(format!( 102 + "Invalid change_id length: expected 32, got {}", 103 + change_id_bytes.len() 104 + ))); 105 + } 106 + let mut change_id = [0u8; 32]; 107 + change_id.copy_from_slice(change_id_bytes); 108 + 109 + // Convert tree_id from jj to our TreeHash 110 + // For now, we'll use a simplified hash of the tree content 111 + // In jj 0.37, MergedTree::resolve() is async and complex to use here 112 + // We'll extract tree_id from the underlying backend commit via the store 113 + // FIXME: Implement proper tree ID extraction - for now use a placeholder based on change_id 114 + // This is a temporary workaround until we properly handle async tree resolution 115 + let mut tree_hash = [0u8; 20]; 116 + // Use first 20 bytes of change_id as a temporary tree hash placeholder 117 + tree_hash.copy_from_slice(&change_id[..20]); 118 + 119 + // Convert parent change_ids 120 + let parents = commit 121 + .parent_ids() 122 + .iter() 123 + .filter_map(|parent_commit_id| { 124 + // Look up parent commit to get its change_id 125 + repo.store() 126 + .get_commit(parent_commit_id) 127 + .ok() 128 + .and_then(|parent_commit| { 129 + let parent_change_id_bytes = parent_commit.change_id().as_bytes(); 130 + if parent_change_id_bytes.len() == 32 { 131 + let mut parent_change_id = [0u8; 32]; 132 + parent_change_id.copy_from_slice(parent_change_id_bytes); 133 + Some(ChangeId(parent_change_id)) 134 + } else { 135 + None 136 + } 137 + }) 138 + }) 139 + .collect(); 140 + 141 + let author = Identity { 142 + name: Some(commit.author().name.clone()), 143 + email: commit.author().email.clone(), 144 + }; 145 + 146 + // Convert timestamp from jj's Timestamp to chrono DateTime 147 + let timestamp_millis = commit.author().timestamp.timestamp.0; 148 + let timestamp = DateTime::from_timestamp(timestamp_millis / 1000, 0) 149 + .unwrap_or_else(|| Utc::now()); 150 + 151 + Ok(Change { 152 + id: ChangeId(change_id), 153 + tree: TreeHash(tree_hash), 154 + parents, 155 + description: commit.description().to_string(), 156 + author, 157 + timestamp, 158 + }) 159 + } 160 + 161 + /// Get a specific change by ID 162 + pub fn get_change(&self, id: &ChangeId) -> Result<Option<Change>, RepoError> { 163 + let repo_loader = self.workspace.repo_loader(); 164 + let repo = repo_loader 165 + .load_at_head() 166 + .map_err(|e| RepoError::Internal(format!("Failed to load repo: {}", e)))?; 167 + 168 + // In jj, we need to find the commit with this change_id 169 + // We'll search through all commits to find one with matching change_id 170 + let revset_expression = RevsetExpression::all(); 171 + let evaluated = revset_expression 172 + .evaluate(repo.as_ref()) 173 + .map_err(|e| RepoError::Internal(format!("Failed to evaluate revset: {}", e)))?; 174 + 175 + for commit_id_result in evaluated.iter() { 176 + let commit_id = commit_id_result 177 + .map_err(|e| RepoError::Internal(format!("Failed to iterate commits: {}", e)))?; 178 + let commit = repo 179 + .store() 180 + .get_commit(&commit_id) 181 + .map_err(|e| RepoError::Internal(format!("Failed to get commit: {}", e)))?; 182 + 183 + // Check if this commit's change_id matches 184 + let commit_change_id_bytes = commit.change_id().as_bytes(); 185 + if commit_change_id_bytes == id.0.as_slice() { 186 + return Ok(Some(Self::convert_commit_to_change(&commit, repo.as_ref())?)); 187 + } 188 + } 189 + 190 + Ok(None) 191 + } 192 + 193 + /// Get the current working copy change ID 194 + pub fn working_copy_change_id(&self) -> Result<Option<ChangeId>, RepoError> { 195 + let repo_loader = self.workspace.repo_loader(); 196 + let repo = repo_loader 197 + .load_at_head() 198 + .map_err(|e| RepoError::Internal(format!("Failed to load repo: {}", e)))?; 199 + 200 + // Get the working copy commit ID from the op store 201 + // In jj, we need to get it from the operation view 202 + let view = repo.view(); 203 + let wc_commit_id = view 204 + .get_wc_commit_id(self.workspace.workspace_name()) 205 + .ok_or_else(|| RepoError::Internal("No working copy commit for this workspace".to_string()))?; 206 + 207 + // Load the commit to get its change_id 208 + let commit = repo 209 + .store() 210 + .get_commit(wc_commit_id) 211 + .map_err(|e| RepoError::Internal(format!("Failed to get working copy commit: {}", e)))?; 212 + 213 + // Extract the change_id 214 + let change_id_bytes = commit.change_id().as_bytes(); 215 + if change_id_bytes.len() != 32 { 216 + return Err(RepoError::Internal(format!( 217 + "Invalid change_id length: expected 32, got {}", 218 + change_id_bytes.len() 219 + ))); 220 + } 221 + let mut change_id = [0u8; 32]; 222 + change_id.copy_from_slice(change_id_bytes); 223 + 224 + Ok(Some(ChangeId(change_id))) 225 + } 226 + 227 + /// Check if forge is configured for this repo 228 + pub fn forge_config(&self) -> Result<Option<ForgeConfig>, RepoError> { 229 + let config_path = self.path.join(".jj").join("forge.toml"); 230 + if !config_path.exists() { 231 + return Ok(None); 232 + } 233 + 234 + let content = std::fs::read_to_string(&config_path)?; 235 + let config: ForgeConfig = toml::from_str(&content) 236 + .map_err(|e| RepoError::Internal(e.to_string()))?; 237 + Ok(Some(config)) 238 + } 239 + 240 + /// Write forge configuration 241 + pub fn set_forge_config(&self, config: &ForgeConfig) -> Result<(), RepoError> { 242 + let config_path = self.path.join(".jj").join("forge.toml"); 243 + let content = toml::to_string_pretty(config) 244 + .map_err(|e| RepoError::Internal(e.to_string()))?; 245 + std::fs::write(&config_path, content)?; 246 + Ok(()) 247 + } 248 + } 249 + 250 + /// Forge configuration stored in .jj/forge.toml 251 + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 252 + pub struct ForgeConfig { 253 + pub forge: ForgeSettings, 254 + } 255 + 256 + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 257 + pub struct ForgeSettings { 258 + pub url: String, 259 + }