A tool to help managing forked repos with their own history
at main 268 lines 7.8 kB view raw
1use anyhow::{bail, Context, Result}; 2use git2::{DiffOptions, Repository, ResetType, Status}; 3use std::path::Path; 4 5pub const SOURCE_DIR: &str = "source"; 6pub const FORKME_BRANCH: &str = "forkme"; 7 8pub fn clone_repo(url: &str, branch: &str, depth: Option<usize>) -> Result<Repository> { 9 let depth_msg = match depth { 10 Some(d) => format!(" with depth {}", d), 11 None => String::new(), 12 }; 13 println!("Cloning {} (branch: {}){}...", url, branch, depth_msg); 14 15 let mut builder = git2::build::RepoBuilder::new(); 16 builder.branch(branch); 17 18 // Set fetch depth if provided 19 if let Some(d) = depth { 20 let mut fetch_options = git2::FetchOptions::new(); 21 fetch_options.depth(d as i32); 22 builder.fetch_options(fetch_options); 23 } 24 25 let repo = builder 26 .clone(url, Path::new(SOURCE_DIR)) 27 .with_context(|| format!("Failed to clone {} into {}", url, SOURCE_DIR))?; 28 29 println!("Clone complete."); 30 Ok(repo) 31} 32 33pub fn open_repo() -> Result<Repository> { 34 Repository::open(SOURCE_DIR) 35 .with_context(|| format!("Failed to open repository at {}", SOURCE_DIR)) 36} 37 38pub fn create_forkme_branch(repo: &Repository, upstream_branch: &str) -> Result<()> { 39 let upstream_ref = format!("origin/{}", upstream_branch); 40 let reference = repo 41 .find_reference(&format!("refs/remotes/{}", upstream_ref)) 42 .with_context(|| format!("Failed to find remote branch {}", upstream_ref))?; 43 44 let commit = reference.peel_to_commit()?; 45 create_forkme_branch_at(repo, commit.id()) 46} 47 48pub fn create_forkme_branch_at(repo: &Repository, oid: git2::Oid) -> Result<()> { 49 let commit = repo.find_commit(oid)?; 50 51 // Create the forkme branch 52 repo.branch(FORKME_BRANCH, &commit, false) 53 .with_context(|| format!("Failed to create branch {}", FORKME_BRANCH))?; 54 55 // Checkout the forkme branch 56 let obj = repo.revparse_single(&format!("refs/heads/{}", FORKME_BRANCH))?; 57 repo.checkout_tree(&obj, None)?; 58 repo.set_head(&format!("refs/heads/{}", FORKME_BRANCH))?; 59 60 println!("Created and checked out branch '{}'", FORKME_BRANCH); 61 Ok(()) 62} 63 64pub fn get_upstream_commit(repo: &Repository, branch: &str) -> Result<git2::Oid> { 65 let upstream_ref = format!("refs/remotes/origin/{}", branch); 66 let reference = repo 67 .find_reference(&upstream_ref) 68 .with_context(|| format!("Failed to find upstream branch {}", branch))?; 69 70 Ok(reference.peel_to_commit()?.id()) 71} 72 73pub fn get_upstream_commit_sha(repo: &Repository, branch: &str) -> Result<String> { 74 let oid = get_upstream_commit(repo, branch)?; 75 Ok(oid.to_string()) 76} 77 78pub fn resolve_commit(repo: &Repository, sha: &str) -> Result<git2::Oid> { 79 let oid = git2::Oid::from_str(sha).with_context(|| format!("Invalid commit SHA: {}", sha))?; 80 // Verify the commit exists 81 repo.find_commit(oid).with_context(|| { 82 format!( 83 "Commit {} not found. You may need a deeper clone (remove --depth option).", 84 sha 85 ) 86 })?; 87 Ok(oid) 88} 89 90pub fn reset_to_upstream(repo: &Repository, branch: &str) -> Result<()> { 91 let upstream_oid = get_upstream_commit(repo, branch)?; 92 reset_to_commit(repo, upstream_oid) 93} 94 95pub fn reset_to_commit(repo: &Repository, oid: git2::Oid) -> Result<()> { 96 let commit = repo.find_commit(oid)?; 97 let obj = commit.as_object(); 98 99 repo.reset(obj, ResetType::Hard, None) 100 .with_context(|| "Failed to reset to upstream")?; 101 102 println!("Reset to {}", &oid.to_string()[..12]); 103 Ok(()) 104} 105 106pub fn is_working_tree_clean(repo: &Repository) -> Result<bool> { 107 let statuses = repo.statuses(None)?; 108 109 if !statuses.is_empty() { 110 for status in &statuses { 111 if status.status() != Status::IGNORED { 112 return Ok(false); 113 } 114 } 115 } 116 Ok(true) 117} 118 119pub fn has_uncommitted_changes(repo: &Repository, file_path: &str) -> Result<bool> { 120 let statuses = repo.statuses(None)?; 121 122 for entry in statuses.iter() { 123 if let Some(path) = entry.path() { 124 if path == file_path { 125 // Check if file has any uncommitted changes (modified, added, deleted, etc.) 126 let status = entry.status(); 127 return Ok(!status.is_empty()); 128 } 129 } 130 } 131 132 Ok(false) 133} 134 135pub fn ensure_on_forkme_branch(repo: &Repository) -> Result<()> { 136 let head = repo.head()?; 137 let branch_name = head.shorthand().unwrap_or(""); 138 139 if branch_name != FORKME_BRANCH { 140 bail!( 141 "Not on '{}' branch. Currently on '{}'. Please checkout '{}'.", 142 FORKME_BRANCH, 143 branch_name, 144 FORKME_BRANCH 145 ); 146 } 147 Ok(()) 148} 149 150pub enum FileContent { 151 Text(String), 152 Binary(Vec<u8>), 153} 154 155impl FileContent { 156 #[allow(dead_code)] 157 pub fn is_binary(&self) -> bool { 158 matches!(self, FileContent::Binary(_)) 159 } 160 161 pub fn as_text(&self) -> Option<&str> { 162 match self { 163 FileContent::Text(s) => Some(s), 164 FileContent::Binary(_) => None, 165 } 166 } 167 168 #[allow(dead_code)] 169 pub fn as_bytes(&self) -> &[u8] { 170 match self { 171 FileContent::Text(s) => s.as_bytes(), 172 FileContent::Binary(b) => b, 173 } 174 } 175} 176 177pub struct FileDiff { 178 pub path: String, 179 pub old_content: Option<FileContent>, 180 pub new_content: Option<FileContent>, 181} 182 183pub fn get_changes_from_upstream( 184 repo: &Repository, 185 upstream_branch: &str, 186) -> Result<Vec<FileDiff>> { 187 let upstream_oid = get_upstream_commit(repo, upstream_branch)?; 188 let upstream_commit = repo.find_commit(upstream_oid)?; 189 let upstream_tree = upstream_commit.tree()?; 190 191 let head = repo.head()?; 192 let head_commit = head.peel_to_commit()?; 193 let head_tree = head_commit.tree()?; 194 195 let mut diff_opts = DiffOptions::new(); 196 let diff = 197 repo.diff_tree_to_tree(Some(&upstream_tree), Some(&head_tree), Some(&mut diff_opts))?; 198 199 let mut changes = Vec::new(); 200 201 diff.foreach( 202 &mut |delta, _| { 203 let path = delta 204 .new_file() 205 .path() 206 .or_else(|| delta.old_file().path()) 207 .map(|p| p.to_string_lossy().to_string()) 208 .unwrap_or_default(); 209 210 let old_content = if delta.old_file().id().is_zero() { 211 None 212 } else { 213 repo.find_blob(delta.old_file().id()) 214 .ok() 215 .map(|blob| blob_to_content(&blob)) 216 }; 217 218 let new_content = if delta.new_file().id().is_zero() { 219 None 220 } else { 221 repo.find_blob(delta.new_file().id()) 222 .ok() 223 .map(|blob| blob_to_content(&blob)) 224 }; 225 226 changes.push(FileDiff { 227 path, 228 old_content, 229 new_content, 230 }); 231 232 true 233 }, 234 None, 235 None, 236 None, 237 )?; 238 239 Ok(changes) 240} 241 242fn blob_to_content(blob: &git2::Blob) -> FileContent { 243 let bytes = blob.content(); 244 // Check if content is valid UTF-8 and doesn't contain null bytes 245 if let Ok(text) = std::str::from_utf8(bytes) { 246 if !bytes.contains(&0) { 247 return FileContent::Text(text.to_string()); 248 } 249 } 250 FileContent::Binary(bytes.to_vec()) 251} 252 253pub fn commit_changes(repo: &Repository, message: &str) -> Result<()> { 254 let mut index = repo.index()?; 255 index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?; 256 index.write()?; 257 258 let tree_id = index.write_tree()?; 259 let tree = repo.find_tree(tree_id)?; 260 261 let head = repo.head()?; 262 let parent = head.peel_to_commit()?; 263 264 let sig = repo.signature()?; 265 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?; 266 267 Ok(()) 268}