Skip to content

Commit c303e32

Browse files
authored
Merge pull request #6 from quodlibetor/git-rebase-with-intermediates
Add new `git rebase-with-intermediates` command
2 parents bbcc550 + 80b9bfe commit c303e32

File tree

4 files changed

+521
-258
lines changed

4 files changed

+521
-258
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
use structopt::StructOpt;
2+
3+
#[derive(Debug, StructOpt)]
4+
#[structopt(
5+
about = "Perform a rebase, and pull all the branches that were pointing at commits being rebased",
6+
max_term_width = 100,
7+
setting = structopt::clap::AppSettings::UnifiedHelpMessage,
8+
setting = structopt::clap::AppSettings::ColoredHelp,
9+
)]
10+
struct Args {
11+
/// The target ref
12+
onto: String,
13+
}
14+
15+
fn main() {
16+
let args = Args::from_args();
17+
if let Err(e) = git_fixup::rebase_onto(&args.onto) {
18+
eprintln!("{:#}", e);
19+
std::process::exit(1);
20+
}
21+
}

src/lib.rs

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

0 commit comments

Comments
 (0)