Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: preserve commit history and add --fresh flag #5

Merged
merged 10 commits into from
Mar 20, 2023
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: ${{ github.head_ref }}
repository: ${{ github.repository }} # For the tests to be able to publish current branch

- name: Setup Node.js
uses: actions/setup-node@v3
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Publish your npm package to a branch on the Git repository:
npx git-publish
```

> **⚠️ Warning:** This command will force-push to the remote branch `npm/<current branch>`. Make sure there are no unsaved changes there.
This command will publish to the remote branch `npm/<current branch>`.


### Global install
Expand All @@ -61,6 +61,7 @@ git-publish
| - | - |
| `-b, --branch <branch name>` | The branch to publish the package to. Defaults to prefixing "npm/" to the current branch or tag name. |
| `-r, --remote <remote>` | The remote to push to. (default: `origin`) |
| `-f, --fresh` | Publish without a commit history. Warning: Force-pushes to remote |
| `-d, --dry` | Dry run mode. Will not commit or push to the remote. |
| `-h, --help` | Show help |
| `--version` | Show version |
Expand All @@ -85,8 +86,8 @@ Like `npm publish`, you can call the build command it in the [`prepack` script](

1. Run [npm hooks](https://docs.npmjs.com/cli/v8/using-npm/scripts) `prepare` & `prepack`
2. Create a temporary branch by prefixing the current branch with the `npm/` namespace
3. Detect and commit the [npm publish files](https://github.com/npm/npm-packlist)
4. Force push the branch to remote
3. Detect and commit only the [npm publish files](https://github.com/npm/npm-packlist)
4. Push the branch to remote
5. Delete local branch from Step 2
6. Print the installation command for the branch

Expand Down
98 changes: 64 additions & 34 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import task from 'tasuku';
import { cli } from 'cleye';
import packlist from 'npm-packlist';
import { name, version, description } from '../package.json';
import { assertCleanTree, getCurrentBranchOrTagName, readJson } from './utils';
import {
assertCleanTree, getCurrentBranchOrTagName, readJson, gitStatusTracked,
} from './utils';

const { stringify } = JSON;

Expand All @@ -30,6 +32,12 @@ const { stringify } = JSON;
default: 'origin',
},

fresh: {
type: Boolean,
alias: 'f',
description: 'Publish without a commit history. Warning: Force-pushes to remote',
},

dry: {
type: Boolean,
alias: 'd',
Expand All @@ -54,6 +62,7 @@ const { stringify } = JSON;
const {
branch: publishBranch = `npm/${currentBranch}`,
remote,
fresh,
dry,
} = argv.flags;

Expand All @@ -72,40 +81,50 @@ const { stringify } = JSON;
// In the try-finally block in case it modifies the working tree
// On failure, they will be reverted by the hard reset
try {
let publishFiles: string[] = [];

const runHooks = await task('Running hooks', async ({ setWarning, setTitle }) => {
const checkoutBranch = await task('Checking out branch', async ({ setWarning }) => {
if (dry) {
setWarning('');
return;
}

setTitle('Running hook "prepare"');
await execa('npm', ['run', '--if-present', 'prepare']);
if (fresh) {
await execa('git', ['checkout', '--orphan', localTemporaryBranch]);
} else {
const gitFetch = await execa('git', ['fetch', '--depth=1', remote, `${publishBranch}:${localTemporaryBranch}`], {
reject: false,
});

await execa('git', [
'checkout',
...(gitFetch.failed ? ['-b'] : []),
localTemporaryBranch,
]);
}

setTitle('Running hook "prepack"');
await execa('npm', ['run', '--if-present', 'prepack']);
// Checkout the files tree from the previous branch
// This also applies any file deletions from the source branch
await execa('git', ['restore', '--source', currentBranch, ':/']);
});

if (!dry) {
runHooks.clear();
checkoutBranch.clear();
}

const getPublishFiles = await task('Getting publish files', async ({ setWarning }) => {
const runHooks = await task('Running hooks', async ({ setWarning, setTitle }) => {
if (dry) {
setWarning('');
return;
}

publishFiles = await packlist();
setTitle('Running hook "prepare"');
await execa('npm', ['run', '--if-present', 'prepare']);

if (publishFiles.length === 0) {
throw new Error('No publish files found');
}
setTitle('Running hook "prepack"');
await execa('npm', ['run', '--if-present', 'prepack']);
});

if (!dry) {
getPublishFiles.clear();
runHooks.clear();
}

const removeHooks = await task('Removing "prepare" & "prepack" hooks', async ({ setWarning }) => {
Expand Down Expand Up @@ -161,46 +180,54 @@ const { stringify } = JSON;
removeHooks.clear();
}

const checkoutBranch = await task(`Checking out branch ${stringify(publishBranch)}`, async ({ setWarning }) => {
const commit = await task('Commiting publish assets', async ({ setWarning }) => {
if (dry) {
setWarning('');
return;
}

await execa('git', ['checkout', '--orphan', localTemporaryBranch]);
const publishFiles = await packlist();
if (publishFiles.length === 0) {
throw new Error('No publish files found');
}

// Unstage all files
await execa('git', ['reset']);
});
// Remove all files from Git tree
// This removes all files from the branch so only the publish files will be added
await execa('git', ['rm', '--cached', '-r', ':/'], {
// Can fail if tree is empty: fatal: pathspec ':/' did not match any files
reject: false,
});

if (!dry) {
checkoutBranch.clear();
}
await execa('git', ['add', '-f', ...publishFiles]);

const commit = await task('Commiting publish assets', async ({ setWarning }) => {
if (dry) {
setWarning('');
return;
const { stdout: trackedFiles } = await gitStatusTracked();
if (trackedFiles.length === 0) {
console.warn('⚠️ No new changes found to commit.');
} else {
// -a is passed in so it can stage deletions from `git restore`
await execa('git', ['commit', '--no-verify', '-am', `Published branch ${stringify(currentBranch)}`]);
}

await execa('git', ['add', '-f', ...publishFiles]);
await execa('git', ['commit', '--no-verify', '-m', `Published branch ${stringify(currentBranch)}`]);
});

if (!dry) {
commit.clear();
}

const push = await task(
`Force pushing branch ${stringify(publishBranch)} to remote ${stringify(remote)}`,
`Pushing branch ${stringify(publishBranch)} to remote ${stringify(remote)}`,
async ({ setWarning }) => {
if (dry) {
setWarning('');
return;
}

await execa('git', ['push', '--no-verify', '-f', remote, `${localTemporaryBranch}:${publishBranch}`]);

await execa('git', [
'push',
...(fresh ? ['--force'] : []),
'--no-verify',
remote,
`${localTemporaryBranch}:${publishBranch}`,
]);
success = true;
},
);
Expand All @@ -221,7 +248,10 @@ const { stringify } = JSON;
await execa('git', ['checkout', '-f', currentBranch]);

// Delete local branch
await execa('git', ['branch', '-D', localTemporaryBranch]);
await execa('git', ['branch', '-D', localTemporaryBranch], {
// Ignore failures (e.g. in case it didin't even succeed to create this branch)
reject: false,
});
});

revertBranch.clear();
Expand Down
4 changes: 3 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import fs from 'fs';
import { execa } from 'execa';

export const gitStatusTracked = () => execa('git', ['status', '--porcelain', '--untracked-files=no']);

export async function assertCleanTree() {
const { stdout } = await execa('git', ['status', '--porcelain', '--untracked-files=no']).catch((error) => {
const { stdout } = await gitStatusTracked().catch((error) => {
if (error.stderr.includes('not a git repository')) {
throw new Error('Not in a git repository');
}
Expand Down
1 change: 0 additions & 1 deletion tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ describe('git-publish', ({ describe, test }) => {
});

expect(gitPublishProcess.exitCode).toBe(0);
expect(gitPublishProcess.stderr).toBe('');
expect(gitPublishProcess.stdout).toMatch('✔');
});
});