Skip to content

Commit d27fc69

Browse files
authored
Merge pull request #1 from quodlibetor/select-upstreams
Support constructing a merge base
2 parents b9bef99 + 63b6777 commit d27fc69

File tree

9 files changed

+411
-340
lines changed

9 files changed

+411
-340
lines changed

Cargo.lock

Lines changed: 272 additions & 283 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ authors = ["Brandon W Maister <[email protected]>"]
55
edition = "2018"
66

77
[dependencies]
8-
git2 = { version = "0.10.0", default_features = false }
9-
dialoguer = "0.1.0"
10-
structopt = "0.2.8"
11-
console = "0.6.1"
12-
clap = { version = "2.31.2", features = ["wrap_help"] }
8+
git2 = { version = "0.13.0", default_features = false }
9+
dialoguer = "0.5.0"
10+
structopt = "0.3"
11+
console = "0.10"
12+
clap = { version = "2.33", features = ["wrap_help"] }

README.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,39 @@
22

33
Quickly fix up an old commit using your currently-staged changes.
44

5-
[![asciicast](./static/asciicast.png)](https://asciinema.org/a/SYKj4ztmMJ52cSmGxSF9hHjtl?autoplay=1&t=3)
5+
![usage](./static/full-workflow-simple.gif)
66

77
## Usage
88

99
After installation, just run `git fixup` or `git squash` to perform the related
1010
actions.
1111

12-
Running `git fixup` will check if you have any staged changes (if not it will
13-
prompt you to stage all changes) and then present you with a list of commits
14-
from your current work point (HEAD) to HEAD's upstream. For example, if you are
15-
on `master` and its is `origin/master`, `git fixup` will show all commits
16-
between `master` and `origin/master`. In general this is just what you want,
17-
since you probably shouldn't be editing commits that other people are working
18-
off of.
12+
By default, `git fixup` checks for staged changes and offers to amend an old
13+
commit.
14+
15+
Given a repo that looks like:
16+
17+
![linear-repo](./static/00-initial-state.png)
18+
19+
Running `git fixup` will allow you to edit an old commit:
20+
21+
![linear-repo-fixup](./static/01-selector.gif)
22+
23+
The default behavior will check if your current HEAD commit has an `upstream`
24+
branch and show you only the commits between where you currently are and that
25+
commit. If there is no upstream for HEAD you will see the behavior above.
26+
27+
If you're using a pull-request workflow (e.g. github) you will often have repos that look more like this:
28+
29+
![full-repo](./static/20-initial-full-repo.png)
30+
31+
You can set `GIT_INSTAFIX_UPSTREAM` to a branch name and `git fixup` will only
32+
show changes between HEAD and the merge-base:
33+
34+
![full-repo-fixup](./static/21-with-upstream.gif)
35+
36+
In general this is just what you want, since you probably shouldn't be editing
37+
commits that other people are working off of.
1938

2039
After you select the commit to edit, `git fixup` will apply your staged changes
2140
to that commit without any further prompting or work from you.

src/main.rs

Lines changed: 107 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,18 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
use std::collections::HashMap;
1516
use std::env;
1617
use std::error::Error;
1718
use std::process::Command;
1819

1920
use console::style;
2021
use dialoguer::{Confirmation, Select};
21-
use git2::{Branch, Commit, Diff, Repository};
22+
use git2::{Branch, Commit, Diff, Object, ObjectType, Oid, Repository};
2223
use structopt::StructOpt;
2324

25+
const UPSTREAM_VAR: &str = "GIT_INSTAFIX_UPSTREAM";
26+
2427
#[derive(StructOpt, Debug)]
2528
#[structopt(
2629
about = "Fix a commit in your history with your currently-staged changes",
@@ -30,13 +33,15 @@ When run with no arguments this will:
3033
3134
* If you have no staged changes, ask if you'd like to stage all changes
3235
* Print a `diff --stat` of your currently staged changes
33-
* Provide a list of commits from HEAD to HEAD's upstream,
34-
or --max-commits, whichever is lesser
36+
* Provide a list of commits to fixup or amend going back to:
37+
* The merge-base of HEAD and the environment var GIT_INSTAFIX_UPSTREAM
38+
(if it is set)
39+
* HEAD's upstream
3540
* Fixup your selected commit with the staged changes
3641
",
37-
raw(max_term_width = "100"),
38-
raw(setting = "structopt::clap::AppSettings::UnifiedHelpMessage"),
39-
raw(setting = "structopt::clap::AppSettings::ColoredHelp")
42+
max_term_width = 100,
43+
setting = structopt::clap::AppSettings::UnifiedHelpMessage,
44+
setting = structopt::clap::AppSettings::ColoredHelp,
4045
)]
4146
struct Args {
4247
/// Use `squash!`: change the commit message that you amend
@@ -69,34 +74,71 @@ fn main() {
6974

7075
fn run(squash: bool, max_commits: usize) -> Result<(), Box<dyn Error>> {
7176
let repo = Repository::open(".")?;
72-
match repo.head() {
73-
Ok(head) => {
74-
let head_tree = head.peel_to_tree()?;
75-
let head_branch = Branch::wrap(head);
76-
let diff = repo.diff_tree_to_index(Some(&head_tree), None, None)?;
77-
let commit_to_amend =
78-
create_fixup_commit(&repo, &head_branch, &diff, squash, max_commits)?;
79-
println!(
80-
"selected: {} {}",
81-
&commit_to_amend.id().to_string()[0..10],
82-
commit_to_amend.summary().unwrap_or("")
83-
);
84-
// do the rebase
85-
let target_id = format!("{}~", commit_to_amend.id());
86-
Command::new("git")
87-
.args(&["rebase", "--interactive", "--autosquash", &target_id])
88-
.env("GIT_SEQUENCE_EDITOR", "true")
89-
.spawn()?
90-
.wait()?;
77+
let head = repo
78+
.head()
79+
.map_err(|e| format!("HEAD is not pointing at a valid branch: {}", e))?;
80+
let head_tree = head.peel_to_tree()?;
81+
let head_branch = Branch::wrap(head);
82+
let diff = repo.diff_tree_to_index(Some(&head_tree), None, None)?;
83+
let upstream = get_upstream(&repo, &head_branch)?;
84+
let commit_to_amend =
85+
create_fixup_commit(&repo, &head_branch, upstream, &diff, squash, max_commits)?;
86+
println!(
87+
"selected: {} {}",
88+
&commit_to_amend.id().to_string()[0..10],
89+
commit_to_amend.summary().unwrap_or("")
90+
);
91+
// do the rebase
92+
let target_id = format!("{}~", commit_to_amend.id());
93+
Command::new("git")
94+
.args(&["rebase", "--interactive", "--autosquash", &target_id])
95+
.env("GIT_SEQUENCE_EDITOR", "true")
96+
.spawn()?
97+
.wait()?;
98+
Ok(())
99+
}
100+
101+
fn get_upstream<'a>(
102+
repo: &'a Repository,
103+
head_branch: &'a Branch,
104+
) -> Result<Option<Object<'a>>, Box<dyn Error>> {
105+
let upstream = if let Ok(upstream_name) = env::var(UPSTREAM_VAR) {
106+
let branch = repo
107+
.branches(None)?
108+
.filter_map(|branch| branch.ok().map(|(b, _type)| b))
109+
.find(|b| {
110+
b.name()
111+
.map(|n| n.expect("valid utf8 branchname") == &upstream_name)
112+
.unwrap_or(false)
113+
})
114+
.ok_or_else(|| format!("cannot find branch with name {:?}", upstream_name))?;
115+
let result = Command::new("git")
116+
.args(&[
117+
"merge-base",
118+
head_branch.name().unwrap().unwrap(),
119+
branch.name().unwrap().unwrap(),
120+
])
121+
.output()?
122+
.stdout;
123+
let oid = Oid::from_str(std::str::from_utf8(&result)?.trim())?;
124+
let commit = repo.find_object(oid, None).unwrap();
125+
126+
commit
127+
} else {
128+
if let Ok(upstream) = head_branch.upstream() {
129+
upstream.into_reference().peel(ObjectType::Commit)?
130+
} else {
131+
return Ok(None);
91132
}
92-
Err(e) => return Err(format!("head is not pointing at a valid branch: {}", e).into()),
93133
};
94-
Ok(())
134+
135+
Ok(Some(upstream))
95136
}
96137

97138
fn create_fixup_commit<'a>(
98139
repo: &'a Repository,
99140
head_branch: &'a Branch,
141+
upstream: Option<Object<'a>>,
100142
diff: &'a Diff,
101143
squash: bool,
102144
max_commits: usize,
@@ -106,7 +148,10 @@ fn create_fixup_commit<'a>(
106148
let dirty_workdir_stats = repo.diff_index_to_workdir(None, None)?.stats()?;
107149
if dirty_workdir_stats.files_changed() > 0 {
108150
print_diff(Changes::Unstaged)?;
109-
if !Confirmation::new("Nothing staged, stage and commit everything?").interact()? {
151+
if !Confirmation::new()
152+
.with_text("Nothing staged, stage and commit everything?")
153+
.interact()?
154+
{
110155
return Err("".into());
111156
}
112157
} else {
@@ -116,18 +161,13 @@ fn create_fixup_commit<'a>(
116161
let mut idx = repo.index()?;
117162
idx.update_all(&pathspecs, None)?;
118163
idx.write()?;
119-
let commit_to_amend =
120-
select_commit_to_amend(&repo, head_branch.upstream().ok(), max_commits)?;
121-
do_fixup_commit(&repo, &head_branch, &commit_to_amend, squash)?;
122-
Ok(commit_to_amend)
123164
} else {
124165
println!("Staged changes:");
125166
print_diff(Changes::Staged)?;
126-
let commit_to_amend =
127-
select_commit_to_amend(&repo, head_branch.upstream().ok(), max_commits)?;
128-
do_fixup_commit(&repo, &head_branch, &commit_to_amend, squash)?;
129-
Ok(commit_to_amend)
130167
}
168+
let commit_to_amend = select_commit_to_amend(&repo, upstream, max_commits)?;
169+
do_fixup_commit(&repo, &head_branch, &commit_to_amend, squash)?;
170+
Ok(commit_to_amend)
131171
}
132172

133173
fn do_fixup_commit<'a>(
@@ -152,13 +192,13 @@ fn do_fixup_commit<'a>(
152192

153193
fn select_commit_to_amend<'a>(
154194
repo: &'a Repository,
155-
upstream: Option<Branch<'a>>,
195+
upstream: Option<Object<'a>>,
156196
max_commits: usize,
157197
) -> Result<Commit<'a>, Box<dyn Error>> {
158198
let mut walker = repo.revwalk()?;
159199
walker.push_head()?;
160-
let commits = if let Some(upstream) = upstream {
161-
let upstream_oid = upstream.get().target().expect("No upstream target");
200+
let commits = if let Some(upstream) = upstream.as_ref() {
201+
let upstream_oid = upstream.id();
162202
walker
163203
.flat_map(|r| r)
164204
.take_while(|rev| *rev != upstream_oid)
@@ -172,19 +212,42 @@ fn select_commit_to_amend<'a>(
172212
.map(|rev| repo.find_commit(rev))
173213
.collect::<Result<Vec<_>, _>>()?
174214
};
215+
let branches: HashMap<Oid, String> = repo
216+
.branches(None)?
217+
.filter_map(|b| {
218+
b.ok().and_then(|(b, _type)| {
219+
let name: Option<String> = b.name().ok().and_then(|n| n.map(|n| n.to_owned()));
220+
let oid = b.into_reference().resolve().ok().and_then(|r| r.target());
221+
name.and_then(|name| oid.map(|oid| (oid, name)))
222+
})
223+
})
224+
.collect();
175225
let rev_aliases = commits
176226
.iter()
177-
.map(|commit| {
227+
.enumerate()
228+
.map(|(i, commit)| {
229+
let bname = if i > 0 {
230+
branches
231+
.get(&commit.id())
232+
.map(|n| format!("({}) ", n))
233+
.unwrap_or_else(String::new)
234+
} else {
235+
String::new()
236+
};
178237
format!(
179-
"{} {}",
238+
"{} {}{}",
180239
&style(&commit.id().to_string()[0..10]).blue(),
240+
style(bname).green(),
181241
commit.summary().unwrap_or("no commit summary")
182242
)
183243
})
184244
.collect::<Vec<_>>();
185-
let commitmsgs = rev_aliases.iter().map(|s| s.as_ref()).collect::<Vec<_>>();
186-
println!("Select a commit to amend:");
187-
let selected = Select::new().items(&commitmsgs).default(0).interact();
245+
if upstream.is_none() {
246+
eprintln!("Select a commit to amend (no upstream for HEAD):");
247+
} else {
248+
eprintln!("Select a commit to amend:");
249+
}
250+
let selected = Select::new().items(&rev_aliases).default(0).interact();
188251
Ok(repo.find_commit(commits[selected?].id())?)
189252
}
190253

static/00-initial-state.png

46.5 KB
Loading

static/01-selector.gif

542 KB
Loading

static/20-initial-full-repo.png

59.6 KB
Loading

static/21-with-upstream.gif

304 KB
Loading

static/full-workflow-simple.gif

188 KB
Loading

0 commit comments

Comments
 (0)