Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Now whenever you have a chain of branches, list them in `.pr-train.yml` to tell

- `git pr-train -p` will merge branches sequentially one into another and push
- `git pr-train -r -p -f` will rebase branches instead of merging and then push with `--force`.
- `git pr-train -r --commit <commit> -p -f` will rebase branches starting from `commit` then push with `--force` (See example below)
- `git pr-train -h` to print usage information

### Automatically create GitHub PRs from chained branches
Expand Down Expand Up @@ -68,6 +69,50 @@ If you wish, it also makes sure there is a "combined" branch (which contains the

Now everytime you make a change to any branch in the train, run `git pr-train -p` to merge and push branches or `git pr-train -rpf` to rebase branches and force-push (if you prefer rebasing).

## Rebase With Commit Example

You can skip this example if you don't use the rebase workflow when merging your PRs.

Taking the PR train from previous example, let's say there's one more branch at the top of the train, and it's just merged/rebased to `master`:
- `fred_billing-setup-infrastructure` (B1 - merged to `master`)
- `fred_billing-refactor_frontend_bits` (B2)
- `fred_billing-refactor_backend_bits` (B3)
- `fred_billing-refactor_tests` (B4)

Using the rebase workflow, we'd like to rebase the new head PR `B2`, onto `master`, so the PR diff only displays changes from B2. However, `B2` still contains commits from `B1` which we don't want to include in the rebase process, as they were merged to `master` already. Also, attempting to rebase `B1`'s commits again can result in merge conflicts with `master`. What we would likely want to do to exclude `B1`'s commits is the following:

```bash
git checkout fred_billing-refactor_frontend_bits
# Exclude all commits in fred_billing-setup-infrastructure
# from the rebase process, since these changes were in master already.
git rebase --onto master fred_billing-setup-infrastructure
```

If you have a long train, this becomes a tedious process as you need to do the same for every single PR in the train. To automate this, you can provide the `--commit` option, passing in either a commit SHA or a branch/ref name, which denotes the commit from which we want to start the rebase (exclusively i.e the rebase will take effect on whatever commit is next). What this option does behind the scenes is roughly these commands:

```bash
git checkout B[i]
# Exclude all commits prior to and including commitID when
# rebasing B[i] on top of B[i - 1]. Normally, this commit is what
# B[i - 1] pointed to prior to being rebased, as these commits were
# already replicated over to B[i - 1].
git rebase --onto B[i - 1] ${commitID}
# Set commitID to the commit B[i] pointed to prior to being rebased,
# which is referenced by B[i]@{1}. This new commitID will then be used
# when rebasing the next branch B[i + 1] on top of B[i].
commitID = B[i]@{1}
```

The first branch in your train i.e `B2`, would be rebased against the base branch. This can be configured in the yml config `main-branch-name` for convenience and can be overriden on the command line by using `-b/--base` option.

With `--commit` option, you can rebase the whole train after `B1` is merged to `master` in one single command (make sure you commented out `B1` from the pr train yml config first):
```bash
git pr-train -r --commit B1
```

**Note**: If `commitID` is not found in the next branch to be rebased (`B[i]`), we fallback to a normal rebase i.e `git checkout B[i] && git rebase B[i - 1]`.


### `.pr-train.yml` config

The `.pr-train.yml` file contains simple configuration that describes your trains. For example, the "billing refactor" example from above would be expressed as:
Expand Down
64 changes: 51 additions & 13 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,26 +39,57 @@ function isBranchAncestor(sg, r1, r2) {
*
* @param {simpleGit.SimpleGit} sg
* @param {boolean} rebase
* @param {string} commit
* @param {string} from
* @param {string} to
*/
async function combineBranches(sg, rebase, from, to) {
async function combineBranches(sg, rebase, commit, from, to) {
if (program.rebase) {
process.stdout.write(`rebasing ${to} onto branch ${from}... `);
} else {
process.stdout.write(`merging ${from} into branch ${to}... `);
}
try {
// We check that the branch to be rebased contains
// the given commit, which is equivalent to the commit
// being an ancestor of the branch.
if (commit && !isBranchAncestor(sg, commit, to)) {
console.log(`Branch ${to} does not contain commit ${commit}. Performing normal rebase...`);
commit = null;
}
const rebaseArgs = commit ? ['--onto', from, commit] : [from];

/**
*
* @param {simpleGit.SimpleGit} sg
* @param {boolean} rebase
* @param {string[]} rebaseArgs
* @param {string} to
*/
const doCombineBranches = async (sg, rebase, rebaseArgs, to) => {
await sg.checkout(to);
await (rebase ? sg.rebase([from]) : sg.merge([from]));
let returnCommit = null;
if (rebase) {
await sg.rebase(rebaseArgs);
// After "to" is rebased successfully, the commit
// "to" previously pointed to is referenced in to@{1}.
// We return this commit ID so in the next loop iteration,
// the next branch to be rebased can start from this commit.
returnCommit = (await sg.raw(['rev-parse', `${to}@{1}`])).trim();
} else {
await sg.merge([from]);
}
console.log(emoji.get('white_check_mark'));
return returnCommit;
}

try {
return doCombineBranches(sg, rebase, rebaseArgs, to);
} catch (e) {
if (!e.conflicts || e.conflicts.length === 0) {
await sleep(MERGE_STEP_DELAY_WAIT_FOR_LOCK);
await sg.checkout(to);
await (rebase ? sg.rebase([from]) : sg.merge([from]));
return doCombineBranches(sg, rebase, rebaseArgs, to);
}
}
console.log(emoji.get('white_check_mark'));
}

async function pushBranches(sg, branches, forcePush, remote = DEFAULT_REMOTE) {
Expand Down Expand Up @@ -216,7 +247,7 @@ async function main() {
process.exit(1);
}

const defaultBase = getConfigOption(ymlConfig, 'prs.main-branch-name') || DEFAULT_BASE_BRANCH;
const defaultBase = String(getConfigOption(ymlConfig, 'prs.main-branch-name')) || DEFAULT_BASE_BRANCH;
const draftByDefault = !!getConfigOption(ymlConfig, 'prs.draft-by-default');

program
Expand All @@ -225,6 +256,7 @@ async function main() {
.option('-p, --push', 'Push changes')
.option('-l, --list', 'List branches in current train')
.option('-r, --rebase', 'Rebase branches rather than merging them')
.option('--commit <commit>', 'The commit to start the rebase from (useful for rebasing the train after head PR is merged)')
.option('-f, --force', 'Force push to remote')
.option('--push-merged', 'Push all branches (including those that have already been merged into the base branch)')
.option('--remote <remote>', 'Set remote to push to. Defaults to "origin"')
Expand Down Expand Up @@ -259,6 +291,11 @@ async function main() {

program.createPrs && checkGHKeyExists();

if (program.commit && !program.rebase) {
console.log('Commit can only be used if you also rebase');
process.exit(1);
}

const baseBranch = program.base; // will have default value if one is not supplied

const draft = program.draft != null ? program.draft : draftByDefault;
Expand Down Expand Up @@ -331,14 +368,15 @@ async function main() {
return;
}

for (let i = 0; i < sortedTrainBranches.length - 1; ++i) {
const b1 = sortedTrainBranches[i];
const b2 = sortedTrainBranches[i + 1];
if (isBranchAncestor(sg, b1, b2)) {
console.log(`Branch ${b1} is an ancestor of ${b2} => nothing to do`);
let commit = program.commit;
for (let i = 0; i < sortedTrainBranches.length; ++i) {
const from = i === 0 ? baseBranch : sortedTrainBranches[i - 1];
const to = sortedTrainBranches[i];
if (isBranchAncestor(sg, from, to)) {
console.log(`Branch ${from} is an ancestor of ${to} => nothing to do`);
continue;
}
await combineBranches(sg, program.rebase, b1, b2);
commit = await combineBranches(sg, program.rebase, commit, from, to);
await sleep(MERGE_STEP_DELAY_MS);
}

Expand Down
Loading