Skip to content

Commit ecf5947

Browse files
committed
wip!: variant derivation
1 parent f41171f commit ecf5947

3 files changed

Lines changed: 261 additions & 0 deletions

File tree

src/commands.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
pub mod derive;
12
pub mod diff;
23
pub mod init;
34

5+
pub use derive::derive;
46
pub use diff::diff;
57
pub use init::init;

src/commands/derive.rs

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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+
}

src/main.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ struct Cli {
1515
enum Commands {
1616
/// Initialize variant management support in the git repository
1717
Init,
18+
/// Derive a new variant from the specified set of features
19+
Derive {
20+
/// The name of the variant
21+
name: String,
22+
/// The set of features to use for the derivation
23+
#[arg(short, long = "feature", required = true)]
24+
features: Vec<String>,
25+
},
1826
/// List the commits exist in the target branch but not in the current branch
1927
Diff {
2028
/// The branch to compare against
@@ -33,6 +41,9 @@ fn main() -> Result<()> {
3341
Commands::Init => {
3442
commands::init(&repo).context("Failed to initialize VMS support")?;
3543
}
44+
Commands::Derive { name, features } => {
45+
commands::derive(&repo, &name, &features).context("Failed to derive variant")?;
46+
}
3647
Commands::Diff { target, reverse } => {
3748
commands::diff(&repo, &target, reverse).context(format!(
3849
"Failed to diff current branch against '{}'",

0 commit comments

Comments
 (0)