Skip to content

Commit eab646f

Browse files
committed
Convert into a lib project with an external main
This will allow using the same rebase logic for other binaries.
1 parent 2d70309 commit eab646f

File tree

2 files changed

+344
-333
lines changed

2 files changed

+344
-333
lines changed

src/lib.rs

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
use std::collections::HashMap;
2+
use std::process::Command;
3+
4+
use anyhow::{anyhow, bail};
5+
use console::style;
6+
use dialoguer::{Confirm, Select};
7+
use git2::{Branch, Commit, Diff, Object, ObjectType, Oid, Rebase, Repository};
8+
9+
#[derive(Eq, PartialEq, Debug)]
10+
enum Changes {
11+
Staged,
12+
Unstaged,
13+
}
14+
15+
pub fn instafix(
16+
_squash: bool,
17+
max_commits: usize,
18+
message_pattern: Option<String>,
19+
upstream_branch_name: Option<&str>,
20+
) -> Result<(), anyhow::Error> {
21+
let repo = Repository::open(".")?;
22+
let diff = create_diff(&repo)?;
23+
let head = repo
24+
.head()
25+
.map_err(|e| anyhow!("HEAD is not pointing at a valid branch: {}", e))?;
26+
let head_branch = Branch::wrap(head);
27+
println!("head_branch: {:?}", head_branch.name().unwrap().unwrap());
28+
let upstream = get_upstream(&repo, &head_branch, upstream_branch_name)?;
29+
let commit_to_amend = select_commit_to_amend(&repo, upstream, max_commits, &message_pattern)?;
30+
do_fixup_commit(&repo, &head_branch, &commit_to_amend, false)?;
31+
println!("selected: {}", disp(&commit_to_amend));
32+
let current_branch = Branch::wrap(repo.head()?);
33+
do_rebase(&repo, &current_branch, &commit_to_amend, &diff)?;
34+
35+
Ok(())
36+
}
37+
38+
fn do_rebase(
39+
repo: &Repository,
40+
branch: &Branch,
41+
commit_to_amend: &Commit,
42+
diff: &Diff,
43+
) -> Result<(), anyhow::Error> {
44+
let first_parent = repo.find_annotated_commit(commit_parent(commit_to_amend)?.id())?;
45+
let branch_commit = repo.reference_to_annotated_commit(branch.get())?;
46+
let fixup_commit = branch.get().peel_to_commit()?;
47+
let fixup_message = fixup_commit.message();
48+
49+
let rebase = &mut repo
50+
.rebase(Some(&branch_commit), Some(&first_parent), None, None)
51+
.map_err(|e| anyhow!("Error starting rebase: {}", e))?;
52+
match do_rebase_inner(repo, rebase, diff, fixup_message) {
53+
Ok(_) => {
54+
rebase.finish(None)?;
55+
Ok(())
56+
}
57+
Err(e) => {
58+
eprintln!("Aborting rebase, please apply it manualy via");
59+
eprintln!(
60+
" git rebase --interactive --autosquash {}~",
61+
first_parent.id()
62+
);
63+
rebase.abort()?;
64+
Err(e)
65+
}
66+
}
67+
}
68+
69+
fn do_rebase_inner(
70+
repo: &Repository,
71+
rebase: &mut Rebase,
72+
diff: &Diff,
73+
fixup_message: Option<&str>,
74+
) -> Result<(), anyhow::Error> {
75+
let sig = repo.signature()?;
76+
77+
let mut branches: HashMap<Oid, Branch> = HashMap::new();
78+
for (branch, _type) in repo.branches(Some(git2::BranchType::Local))?.flatten() {
79+
let oid = branch.get().peel_to_commit()?.id();
80+
// TODO: handle multiple branches pointing to the same commit
81+
branches.insert(oid, branch);
82+
}
83+
84+
match rebase.next() {
85+
Some(ref res) => {
86+
let op = res.as_ref().map_err(|e| anyhow!("No commit: {}", e))?;
87+
let target_commit = repo.find_commit(op.id())?;
88+
repo.apply(diff, git2::ApplyLocation::Both, None)?;
89+
let mut idx = repo.index()?;
90+
let oid = idx.write_tree()?;
91+
let tree = repo.find_tree(oid)?;
92+
93+
// TODO: Support squash amends
94+
95+
let rewrit_id = target_commit.amend(None, None, None, None, None, Some(&tree))?;
96+
repo.reset(
97+
&repo.find_object(rewrit_id, None)?,
98+
git2::ResetType::Soft,
99+
None,
100+
)?;
101+
102+
rewrit_id
103+
}
104+
None => bail!("Unable to start rebase: no first step in rebase"),
105+
};
106+
107+
while let Some(ref res) = rebase.next() {
108+
use git2::RebaseOperationType::*;
109+
110+
let op = res.as_ref().map_err(|e| anyhow!("Err: {}", e))?;
111+
match op.kind() {
112+
Some(Pick) => {
113+
let commit = repo.find_commit(op.id())?;
114+
if commit.message() != fixup_message {
115+
let new_id = rebase.commit(None, &sig, None)?;
116+
if let Some(branch) = branches.get_mut(&commit.id()) {
117+
branch
118+
.get_mut()
119+
.set_target(new_id, "git-fixup retarget historical branch")?;
120+
}
121+
}
122+
}
123+
Some(Fixup) | Some(Squash) | Some(Exec) | Some(Edit) | Some(Reword) => {
124+
// None of this should happen, we'd need to manually create the commits
125+
bail!("Unable to handle {:?} rebase operation", op.kind().unwrap())
126+
}
127+
None => {}
128+
}
129+
}
130+
131+
Ok(())
132+
}
133+
134+
fn commit_parent<'a>(commit: &'a Commit) -> Result<Commit<'a>, anyhow::Error> {
135+
match commit.parents().next() {
136+
Some(c) => Ok(c),
137+
None => bail!("Commit '{}' has no parents", disp(&commit)),
138+
}
139+
}
140+
141+
/// Display a commit as "short_hash summary"
142+
fn disp(commit: &Commit) -> String {
143+
format!(
144+
"{} {}",
145+
&commit.id().to_string()[0..10],
146+
commit.summary().unwrap_or("<no summary>"),
147+
)
148+
}
149+
150+
fn get_upstream<'a>(
151+
repo: &'a Repository,
152+
head_branch: &'a Branch,
153+
upstream_name: Option<&str>,
154+
) -> Result<Option<Object<'a>>, anyhow::Error> {
155+
let upstream = if let Some(upstream_name) = upstream_name {
156+
let branch = repo
157+
.branches(None)?
158+
.filter_map(|branch| branch.ok().map(|(b, _type)| b))
159+
.find(|b| {
160+
b.name()
161+
.map(|n| n.expect("valid utf8 branchname") == upstream_name)
162+
.unwrap_or(false)
163+
})
164+
.ok_or_else(|| anyhow!("cannot find branch with name {:?}", upstream_name))?;
165+
branch.into_reference().peel(ObjectType::Commit)?
166+
} else {
167+
if let Ok(upstream) = head_branch.upstream() {
168+
upstream.into_reference().peel(ObjectType::Commit)?
169+
} else {
170+
return Ok(None);
171+
}
172+
};
173+
174+
let mb = repo.merge_base(
175+
head_branch
176+
.get()
177+
.target()
178+
.expect("all branches should ahve a target"),
179+
upstream.id(),
180+
)?;
181+
let commit = repo.find_object(mb, None).unwrap();
182+
183+
Ok(Some(commit))
184+
}
185+
186+
/// Get a diff either from the index or the diff from the index to the working tree
187+
fn create_diff(repo: &Repository) -> Result<Diff, anyhow::Error> {
188+
let head = repo.head()?;
189+
let head_tree = head.peel_to_tree()?;
190+
let staged_diff = repo.diff_tree_to_index(Some(&head_tree), None, None)?;
191+
let diffstat = staged_diff.stats()?;
192+
let diff = if diffstat.files_changed() == 0 {
193+
let diff = repo.diff_index_to_workdir(None, None)?;
194+
let dirty_workdir_stats = diff.stats()?;
195+
if dirty_workdir_stats.files_changed() > 0 {
196+
print_diff(Changes::Unstaged)?;
197+
if !Confirm::new()
198+
.with_prompt("Nothing staged, stage and commit everything?")
199+
.interact()?
200+
{
201+
bail!("");
202+
}
203+
} else {
204+
bail!("Nothing staged and no tracked files have any changes");
205+
}
206+
repo.apply(&diff, git2::ApplyLocation::Index, None)?;
207+
diff
208+
} else {
209+
println!("Staged changes:");
210+
print_diff(Changes::Staged)?;
211+
staged_diff
212+
};
213+
214+
Ok(diff)
215+
}
216+
217+
/// Commit the current index as a fixup or squash commit
218+
fn do_fixup_commit<'a>(
219+
repo: &'a Repository,
220+
head_branch: &'a Branch,
221+
commit_to_amend: &'a Commit,
222+
squash: bool,
223+
) -> Result<(), anyhow::Error> {
224+
let msg = if squash {
225+
format!("squash! {}", commit_to_amend.id())
226+
} else {
227+
format!("fixup! {}", commit_to_amend.id())
228+
};
229+
230+
let sig = repo.signature()?;
231+
let mut idx = repo.index()?;
232+
let tree = repo.find_tree(idx.write_tree()?)?;
233+
let head_commit = head_branch.get().peel_to_commit()?;
234+
repo.commit(Some("HEAD"), &sig, &sig, &msg, &tree, &[&head_commit])?;
235+
Ok(())
236+
}
237+
238+
fn select_commit_to_amend<'a>(
239+
repo: &'a Repository,
240+
upstream: Option<Object<'a>>,
241+
max_commits: usize,
242+
message_pattern: &Option<String>,
243+
) -> Result<Commit<'a>, anyhow::Error> {
244+
let mut walker = repo.revwalk()?;
245+
walker.push_head()?;
246+
let commits = if let Some(upstream) = upstream.as_ref() {
247+
let upstream_oid = upstream.id();
248+
walker
249+
.flat_map(|r| r)
250+
.take_while(|rev| *rev != upstream_oid)
251+
.take(max_commits)
252+
.map(|rev| repo.find_commit(rev))
253+
.collect::<Result<Vec<_>, _>>()?
254+
} else {
255+
walker
256+
.flat_map(|r| r)
257+
.take(max_commits)
258+
.map(|rev| repo.find_commit(rev))
259+
.collect::<Result<Vec<_>, _>>()?
260+
};
261+
if commits.len() == 0 {
262+
bail!(
263+
"No commits between {} and {:?}",
264+
format_ref(&repo.head()?)?,
265+
upstream.map(|u| u.id()).unwrap()
266+
);
267+
}
268+
let branches: HashMap<Oid, String> = repo
269+
.branches(None)?
270+
.filter_map(|b| {
271+
b.ok().and_then(|(b, _type)| {
272+
let name: Option<String> = b.name().ok().and_then(|n| n.map(|n| n.to_owned()));
273+
let oid = b.into_reference().resolve().ok().and_then(|r| r.target());
274+
name.and_then(|name| oid.map(|oid| (oid, name)))
275+
})
276+
})
277+
.collect();
278+
if let Some(message_pattern) = message_pattern.as_ref() {
279+
commits
280+
.into_iter()
281+
.find(|commit| {
282+
commit
283+
.summary()
284+
.map(|s| s.contains(message_pattern))
285+
.unwrap_or(false)
286+
})
287+
.ok_or_else(|| anyhow::anyhow!("No commit contains the pattern in its summary"))
288+
} else {
289+
let rev_aliases = commits
290+
.iter()
291+
.enumerate()
292+
.map(|(i, commit)| {
293+
let bname = if i > 0 {
294+
branches
295+
.get(&commit.id())
296+
.map(|n| format!("({}) ", n))
297+
.unwrap_or_else(String::new)
298+
} else {
299+
String::new()
300+
};
301+
format!(
302+
"{} {}{}",
303+
&style(&commit.id().to_string()[0..10]).blue(),
304+
style(bname).green(),
305+
commit.summary().unwrap_or("no commit summary")
306+
)
307+
})
308+
.collect::<Vec<_>>();
309+
if upstream.is_none() {
310+
println!("Select a commit to amend (no upstream for HEAD):");
311+
} else {
312+
println!("Select a commit to amend:");
313+
}
314+
let selected = Select::new().items(&rev_aliases).default(0).interact();
315+
Ok(repo.find_commit(commits[selected?].id())?)
316+
}
317+
}
318+
319+
fn format_ref(rf: &git2::Reference<'_>) -> Result<String, anyhow::Error> {
320+
let shorthand = rf.shorthand().unwrap_or("<unnamed>");
321+
let sha = rf.peel_to_commit()?.id().to_string();
322+
Ok(format!("{} ({})", shorthand, &sha[..10]))
323+
}
324+
325+
fn print_diff(kind: Changes) -> Result<(), anyhow::Error> {
326+
let mut args = vec!["diff", "--stat"];
327+
if kind == Changes::Staged {
328+
args.push("--cached");
329+
}
330+
let status = Command::new("git").args(&args).spawn()?.wait()?;
331+
if status.success() {
332+
Ok(())
333+
} else {
334+
bail!("git diff failed")
335+
}
336+
}

0 commit comments

Comments
 (0)