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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@ jobs:
- '@percy/sdk-utils'
- '@percy/webdriver-utils'
- '@percy/monitoring'
- '@percy/git-utils'
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: ${{ matrix.package == '@percy/git-utils' && 0 || 1 }}
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ jobs:
- '@percy/sdk-utils'
- '@percy/webdriver-utils'
- '@percy/monitoring'
- '@percy/git-utils'
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: ${{ matrix.package == '@percy/git-utils' && 0 || 1 }}
- uses: actions/setup-node@v3
with:
node-version: 14
Expand Down
231 changes: 231 additions & 0 deletions packages/git-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# @percy/git-utils

Utility helpers for interacting with git (used internally by Percy CLI packages).

This package provides higher-level helpers around common git operations with smart error handling, retry logic, and diagnostic capabilities.

## Installation

```bash
npm install @percy/git-utils
# or
yarn add @percy/git-utils
```

## Usage

You can use the package in two ways:

### Individual Function Imports

```js
import { isGitRepository, getCurrentCommit } from '@percy/git-utils';

const isRepo = await isGitRepository();
const commit = await getCurrentCommit();
```

### PercyGitUtils Object

```js
import { PercyGitUtils } from '@percy/git-utils';

const isRepo = await PercyGitUtils.isGitRepository();
const commit = await PercyGitUtils.getCurrentCommit();
```

## API Reference

### Repository Validation

#### `isGitRepository()`

Check if the current directory is a git repository.

```js
import { isGitRepository } from '@percy/git-utils';

const isRepo = await isGitRepository();
// Returns: true or false
```

#### `getRepositoryRoot()`

Get the root directory of the git repository.

```js
import { getRepositoryRoot } from '@percy/git-utils';

const root = await getRepositoryRoot();
// Returns: '/path/to/repo'
// Throws: Error if not a git repository
```

### Commit & Branch Information

#### `getCurrentCommit()`

Get the SHA of the current HEAD commit.

```js
import { getCurrentCommit } from '@percy/git-utils';

const sha = await getCurrentCommit();
// Returns: 'abc123...' (40-character SHA)
```

#### `getCurrentBranch()`

Get the name of the current branch.

```js
import { getCurrentBranch } from '@percy/git-utils';

const branch = await getCurrentBranch();
// Returns: 'main' or 'HEAD' (if detached)
```

#### `commitExists(commit)`

Check if a commit exists in the repository.

```js
import { commitExists } from '@percy/git-utils';

const exists = await commitExists('abc123');
// Returns: true or false
```

### Repository State & Diagnostics

#### `getGitState()`

Get comprehensive diagnostic information about the repository state.

```js
import { getGitState } from '@percy/git-utils';

const state = await getGitState();
// Returns: {
// isValid: true,
// isShallow: false,
// isDetached: false,
// isFirstCommit: false,
// hasRemote: true,
// remoteName: 'origin',
// defaultBranch: 'main',
// issues: [] // Array of diagnostic messages
// }
```

**State Properties:**
- `isValid`: Whether the directory is a valid git repository
- `isShallow`: Whether the repository is a shallow clone
- `isDetached`: Whether HEAD is in detached state
- `isFirstCommit`: Whether the current commit is the first commit
- `hasRemote`: Whether a remote is configured
- `remoteName`: Name of the first remote (usually 'origin')
- `defaultBranch`: Detected default branch name
- `issues`: Array of diagnostic warning messages

### Merge Base & Changed Files

#### `getMergeBase(targetBranch?)`

Get the merge-base commit between HEAD and a target branch with smart fallback logic.

```js
import { getMergeBase } from '@percy/git-utils';

const result = await getMergeBase('main');
// Returns: {
// success: true,
// commit: 'abc123...',
// branch: 'main',
// error: null
// }

// Or on failure:
// {
// success: false,
// commit: null,
// branch: 'main',
// error: { code: 'SHALLOW_CLONE', message: '...' }
// }
```

**Error Codes:**
- `NOT_GIT_REPO`: Not a git repository
- `SHALLOW_CLONE`: Repository is shallow
- `NO_MERGE_BASE`: No common ancestor found
- `UNKNOWN_ERROR`: Other error

The function automatically:
- Detects the default branch if `targetBranch` is not provided
- Tries remote refs before local branches
- Handles detached HEAD state
- Provides helpful error messages

#### `getChangedFiles(baselineCommit)`

Get all changed files between a baseline commit and HEAD.

```js
import { getChangedFiles } from '@percy/git-utils';

const files = await getChangedFiles('origin/main');
// Returns: ['src/file.js', 'package.json', ...]
```

**Features:**
- Handles file renames (includes both old and new paths)
- Handles file copies (includes both source and destination)
- Detects submodule changes
- Returns paths relative to repository root

### File Operations

#### `checkoutFile(commit, filePath, outputDir)`

Checkout a file from a specific commit to an output directory.

```js
import { checkoutFile } from '@percy/git-utils';

const outputPath = await checkoutFile(
'abc123',
'src/file.js',
'/tmp/checkout'
);
// Returns: '/tmp/checkout/file.js'
```

## Advanced Features

### Retry Logic

All git commands include automatic retry logic for concurrent operations:
- Detects `index.lock` and similar errors
- Exponential backoff (100ms, 200ms, 400ms)
- Configurable via `retries` and `retryDelay` options

### Error Handling

Functions provide detailed error messages with context:
- Diagnostic information about repository state
- Suggestions for fixing common issues
- Specific error codes for programmatic handling

## Development

This repository uses Lerna and package-local scripts. From repo root run:

```bash
yarn build
yarn test
yarn lint packages/git-utils
```

## License

MIT
34 changes: 34 additions & 0 deletions packages/git-utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@percy/git-utils",
"version": "1.31.4",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/percy/cli",
"directory": "packages/git-utils"
},
"publishConfig": {
"access": "public",
"tag": "latest"
},
"engines": {
"node": ">=14"
},
"files": [
"dist"
],
"main": "./dist/index.js",
"type": "module",
"exports": {
".": "./dist/index.js"
},
"scripts": {
"build": "node ../../scripts/build",
"lint": "eslint --ignore-path ../../.gitignore .",
"test": "node ../../scripts/test",
"test:coverage": "yarn test --coverage"
},
"dependencies": {
"cross-spawn": "^7.0.3"
}
}
50 changes: 50 additions & 0 deletions packages/git-utils/src/git-commands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Basic git queries
export const GIT_REV_PARSE_GIT_DIR = ['git', 'rev-parse', '--git-dir'];
export const GIT_REV_PARSE_SHOW_TOPLEVEL = ['git', 'rev-parse', '--show-toplevel'];
export const GIT_REV_PARSE_HEAD = ['git', 'rev-parse', 'HEAD'];
export const GIT_REV_PARSE_ABBREV_REF_HEAD = ['git', 'rev-parse', '--abbrev-ref', 'HEAD'];
export const GIT_REV_PARSE_IS_SHALLOW = ['git', 'rev-parse', '--is-shallow-repository'];

// Remote operations
export const GIT_REMOTE_V = ['git', 'remote', '-v'];
export const GIT_REMOTE_SET_HEAD = (remote, ...args) => ['git', 'remote', 'set-head', remote, ...args];

// History and commits
export const GIT_REV_LIST_PARENTS_HEAD = ['git', 'rev-list', '--parents', 'HEAD'];

// Branch operations
export const GIT_REV_PARSE_VERIFY = (ref) => ['git', 'rev-parse', '--verify', ref];
export const GIT_SYMBOLIC_REF = (ref) => ['git', 'symbolic-ref', ref];

// Config operations
export const GIT_CONFIG = (...args) => ['git', 'config', ...args];
export const GIT_CONFIG_FILE_GET_REGEXP = (file, pattern) =>
['git', 'config', '--file', file, '--get-regexp', pattern];

// Merge base
export const GIT_MERGE_BASE = (ref1, ref2) => ['git', 'merge-base', ref1, ref2];
export const GIT_FETCH = (remote, refspec, ...args) => ['git', 'fetch', remote, refspec, ...args];

// Diff operations
export const GIT_DIFF_NAME_STATUS = (baselineCommit, headCommit = 'HEAD') =>
['git', 'diff', '--name-status', `${baselineCommit}..${headCommit}`];
export const GIT_DIFF_SUBMODULE = (baselineCommit, headCommit = 'HEAD') =>
['git', 'diff', `${baselineCommit}..${headCommit}`, '--submodule=short'];
export const GIT_DIFF_NAME_ONLY_SUBMODULE = (baselineCommit, headCommit = 'HEAD') =>
['git', 'diff', '--name-only', `${baselineCommit}..${headCommit}`];

// Submodule operations
export const GIT_SUBMODULE_DIFF = (submodulePath, baselineCommit, headCommit = 'HEAD') =>
['git', '-C', submodulePath, 'diff', '--name-only', `${baselineCommit}..${headCommit}`];

// File operations
export const GIT_SHOW = (ref, filePath) => ['git', 'show', `${ref}:${filePath}`];
export const GIT_CAT_FILE_E = (ref) => ['git', 'cat-file', '-e', ref];

// Error patterns for retry logic
export const CONCURRENT_ERROR_PATTERNS = [
'index.lock',
'unable to create',
'file exists',
'another git process'
];
Loading
Loading