|
| 1 | +use anyhow::{Context, Result}; |
| 2 | +use git2::{Diff, Index, IndexEntry, IndexTime, Oid, Repository, Signature, Sort, Tree}; |
| 3 | +use regex::Regex; |
| 4 | +use std::path::{Path, PathBuf}; |
| 5 | + |
| 6 | +use std::collections::{HashMap, HashSet}; |
| 7 | + |
| 8 | +fn create_variant_initial_commit( |
| 9 | + repo: &Repository, |
| 10 | + ref_name: &str, |
| 11 | + sig: &Signature, |
| 12 | +) -> Result<Oid> { |
| 13 | + let tree_oid = repo |
| 14 | + .treebuilder(None)? |
| 15 | + .write() |
| 16 | + .context("Failed to create empty tree.")?; |
| 17 | + let tree = repo.find_tree(tree_oid)?; |
| 18 | + |
| 19 | + let variant_head_oid = repo |
| 20 | + .commit( |
| 21 | + Some(&ref_name), |
| 22 | + &sig, |
| 23 | + &sig, |
| 24 | + "Variant initial commit\n", |
| 25 | + &tree, |
| 26 | + &[], |
| 27 | + ) |
| 28 | + .context("Failed to create initial commit for variant")?; |
| 29 | + Ok(variant_head_oid) |
| 30 | +} |
| 31 | + |
| 32 | +fn extract_hunks(diff: Diff) -> HashMap<PathBuf, Vec<(u32, Vec<u8>)>> { |
| 33 | + let mut additions: HashMap<PathBuf, Vec<(u32, Vec<u8>)>> = HashMap::new(); |
| 34 | + diff.foreach( |
| 35 | + &mut |_, _| true, |
| 36 | + None, |
| 37 | + None, |
| 38 | + Some(&mut |delta, _hunk, line| { |
| 39 | + if line.origin() != '+' { |
| 40 | + return true; |
| 41 | + } |
| 42 | + let path = delta |
| 43 | + .new_file() |
| 44 | + .path() |
| 45 | + .or_else(|| delta.old_file().path()) |
| 46 | + .unwrap(); |
| 47 | + |
| 48 | + let new_lineno = line.new_lineno().unwrap(); |
| 49 | + additions |
| 50 | + .entry(path.to_path_buf()) |
| 51 | + .or_default() |
| 52 | + .push((new_lineno, line.content().to_vec())); |
| 53 | + true |
| 54 | + }), |
| 55 | + ) |
| 56 | + .unwrap(); |
| 57 | + additions |
| 58 | +} |
| 59 | + |
| 60 | +fn get_file_bytes_from_tree(repo: &Repository, tree: &Tree, path: &Path) -> Result<Vec<u8>> { |
| 61 | + match tree.get_path(path) { |
| 62 | + Ok(entry) => { |
| 63 | + let blob = repo.find_blob(entry.id())?; |
| 64 | + Ok(blob.content().to_vec()) |
| 65 | + } |
| 66 | + Err(_) => Ok(Vec::new()), |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +fn split_lines_including_newline(bytes: &[u8]) -> Vec<Vec<u8>> { |
| 71 | + let mut out = Vec::new(); |
| 72 | + let mut start = 0usize; |
| 73 | + |
| 74 | + for (i, &b) in bytes.iter().enumerate() { |
| 75 | + if b == b'\n' { |
| 76 | + out.push(bytes[start..=i].to_vec()); |
| 77 | + start = i + 1; |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + if start < bytes.len() { |
| 82 | + out.push(bytes[start..].to_vec()); |
| 83 | + } |
| 84 | + out |
| 85 | +} |
| 86 | + |
| 87 | +fn join_lines(lines: &[Vec<u8>]) -> Vec<u8> { |
| 88 | + let mut out = Vec::new(); |
| 89 | + for line in lines { |
| 90 | + out.extend_from_slice(line); |
| 91 | + } |
| 92 | + out |
| 93 | +} |
| 94 | + |
| 95 | +fn ensure_length(lines: &mut Vec<Vec<u8>>, len: usize) { |
| 96 | + while lines.len() < len { |
| 97 | + lines.push(b"\n".to_vec()); |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +fn apply_additions(base: Vec<u8>, mut additions: Vec<(u32, Vec<u8>)>) -> Vec<u8> { |
| 102 | + additions.sort_by_key(|(ln, _)| *ln); |
| 103 | + let mut lines = split_lines_including_newline(&base); |
| 104 | + |
| 105 | + for (new_lineno, content) in additions { |
| 106 | + let idx = (new_lineno as usize).saturating_sub(1); |
| 107 | + |
| 108 | + ensure_length(&mut lines, idx); |
| 109 | + if idx >= lines.len() { |
| 110 | + lines.push(content); |
| 111 | + } else { |
| 112 | + lines.insert(idx, content); |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + join_lines(&lines) |
| 117 | +} |
| 118 | + |
| 119 | +pub fn derive(repo: &Repository, name: &str, features: &[String]) -> Result<()> { |
| 120 | + println!("Deriving variant '{}' with features: {:?}", name, features); |
| 121 | + // create a new orphan branch |
| 122 | + let ref_name = format!("refs/heads/variant/{name}"); |
| 123 | + let sig = Signature::now("user", "user@example.com")?; |
| 124 | + let mut variant_head_oid = create_variant_initial_commit(repo, &ref_name, &sig)?; |
| 125 | + |
| 126 | + let target_features: HashSet<String> = features.iter().cloned().collect(); |
| 127 | + |
| 128 | + // walk the commit graph |
| 129 | + let mut revwalk = repo.revwalk()?; |
| 130 | + revwalk.push_head()?; |
| 131 | + revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME | Sort::REVERSE)?; |
| 132 | + |
| 133 | + for oid in revwalk { |
| 134 | + let oid = oid?; |
| 135 | + let commit = repo.find_commit(oid)?; |
| 136 | + |
| 137 | + let summary = commit.summary().context("Error extracting summary")?; |
| 138 | + if let Some(feature) = extract_scope(summary) { |
| 139 | + if !target_features.contains(feature) { |
| 140 | + continue; |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + if commit.parent_count() == 0 { |
| 145 | + println!("Commit '{}'has no parent. Skipping.", commit.id()); |
| 146 | + continue; |
| 147 | + } |
| 148 | + let parent = commit.parent(0)?; |
| 149 | + |
| 150 | + let commit_tree = commit.tree()?; |
| 151 | + let parent_tree = parent.tree()?; |
| 152 | + |
| 153 | + let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)?; |
| 154 | + let additions = extract_hunks(diff); |
| 155 | + |
| 156 | + // write changes to disk without touching the worktree |
| 157 | + if !additions.is_empty() { |
| 158 | + let variant_parent = repo.find_commit(variant_head_oid)?; |
| 159 | + let variant_tree = variant_parent.tree()?; |
| 160 | + |
| 161 | + let mut index = Index::new()?; |
| 162 | + index.read_tree(&variant_tree)?; |
| 163 | + |
| 164 | + for (path, contents) in additions { |
| 165 | + let base = get_file_bytes_from_tree(repo, &variant_tree, &path)?; |
| 166 | + let merged = apply_additions(base, contents); |
| 167 | + |
| 168 | + let blob_oid = repo.blob(&merged)?; |
| 169 | + let path_string = path.to_string_lossy().replace('\\', "/"); |
| 170 | + let entry = IndexEntry { |
| 171 | + ctime: IndexTime::new(0, 0), |
| 172 | + mtime: IndexTime::new(0, 0), |
| 173 | + dev: 0, |
| 174 | + ino: 0, |
| 175 | + mode: 0o100644, |
| 176 | + uid: 0, |
| 177 | + gid: 0, |
| 178 | + file_size: merged.len() as u32, |
| 179 | + id: blob_oid, |
| 180 | + flags: 0, |
| 181 | + flags_extended: 0, |
| 182 | + path: path_string.into_bytes(), |
| 183 | + }; |
| 184 | + index.add(&entry)?; |
| 185 | + } |
| 186 | + let new_tree_oid = index.write_tree_to(repo)?; |
| 187 | + let new_tree = repo.find_tree(new_tree_oid)?; |
| 188 | + |
| 189 | + let commit_msg = commit.message().unwrap(); |
| 190 | + variant_head_oid = repo.commit( |
| 191 | + Some(&ref_name), |
| 192 | + &sig, |
| 193 | + &sig, |
| 194 | + &commit_msg, |
| 195 | + &new_tree, |
| 196 | + &[&variant_parent], |
| 197 | + )?; |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + let var_ref_name = format!("refs/variants/{name}"); |
| 202 | + let target_ref = repo.find_reference(&ref_name)?; |
| 203 | + let var_ref_target = target_ref.name().context("Branch has invalid UTF-8 name")?; |
| 204 | + |
| 205 | + repo.reference_symbolic( |
| 206 | + &var_ref_name, |
| 207 | + &var_ref_target, |
| 208 | + false, |
| 209 | + "initialized new variant", |
| 210 | + ) |
| 211 | + .context("Failed to create symbolic ref to variant")?; |
| 212 | + Ok(()) |
| 213 | +} |
| 214 | + |
| 215 | +// TODO: building each time can be expensive. Look for an alternative |
| 216 | +fn extract_scope(commit: &str) -> Option<&str> { |
| 217 | + let re = Regex::new(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:").unwrap(); |
| 218 | + re.captures(commit) |
| 219 | + .and_then(|caps| caps.name("scope").map(|m| m.as_str())) |
| 220 | +} |
| 221 | + |
| 222 | +#[cfg(test)] |
| 223 | +mod tests { |
| 224 | + use super::extract_scope; |
| 225 | + |
| 226 | + #[test] |
| 227 | + fn extracts_scope_when_present() { |
| 228 | + assert_eq!(extract_scope("feat(auth): add login"), Some("auth")); |
| 229 | + } |
| 230 | + |
| 231 | + #[test] |
| 232 | + fn extracts_scope_with_breaking_change() { |
| 233 | + assert_eq!( |
| 234 | + extract_scope("feat(api)!: change response format"), |
| 235 | + Some("api") |
| 236 | + ); |
| 237 | + } |
| 238 | + |
| 239 | + #[test] |
| 240 | + fn returns_none_when_no_scope() { |
| 241 | + assert_eq!(extract_scope("fix: missing semicolon"), None); |
| 242 | + } |
| 243 | + |
| 244 | + #[test] |
| 245 | + fn does_not_match_invalid_format() { |
| 246 | + assert_eq!(extract_scope("not a conventional commit"), None); |
| 247 | + } |
| 248 | +} |
0 commit comments