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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ tsutils hook format check
tsutils git changed | tsutils files filter --suffix .ts
```

`tsutils git changed` follows the branch's configured upstream when calculating files to push, so it works with remotes such as `origin/*` and `upstream/*`.

### Command Design Philosophy

All commands follow a **pipe-friendly** design:
Expand Down
31 changes: 11 additions & 20 deletions dist/commands/git/codeowners/check.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isGitRepo, getGitStatus } from '../utils/git.js';
import { ensureCondition } from '../../../utils/command-helpers.js';
import { isCommandInstalled } from '../../../utils/shell.js';
import { logIfVerbose } from '../../../utils/logger.js';
import { getNewlyChangedFiles } from '../../../utils/git-status.js';
export function gitCodeownersCheck(options = {}) {
const verbose = options.verbose || false;
logIfVerbose(verbose, '🔍 Checking CODEOWNERS files...');
Expand All @@ -27,31 +28,18 @@ export function gitCodeownersCheck(options = {}) {
}
const gitStatusAfter = getGitStatus(cwd);
ensureCondition(gitStatusAfter !== null, 'Error: Failed to get git status');
if (gitStatusBefore && gitStatusAfter && gitStatusBefore !== gitStatusAfter) {
if (gitStatusBefore === null || gitStatusAfter === null)
return;
const changedFiles = getNewlyChangedFiles(gitStatusBefore, gitStatusAfter).filter(isCodeownersFile);
if (changedFiles.length > 0) {
console.error('');
console.error('❌ CODEOWNERS files are out of sync!');
console.error("Please run 'coach codeowners generate' locally and commit the changes to your branch.");
console.error('');
console.error('Modified files:');
try {
const beforeLines = new Set(gitStatusBefore.split('\n').filter((line) => line.length > 0));
const afterLines = gitStatusAfter.split('\n').filter((line) => line.length > 0);
const changedFiles = afterLines.filter((line) => !beforeLines.has(line));
if (changedFiles.length > 0) {
changedFiles.forEach((line) => {
const match = line.match(/^..\s+(.+)$/);
if (match && match[1]) {
console.error(` ${match[1]}`);
}
});
}
else {
console.error(' (Unable to determine changed files)');
}
}
catch {
console.error(' (Unable to determine changed files)');
}
changedFiles.forEach((file) => {
console.error(` ${file}`);
});
console.error('');
process.exit(1);
}
Expand Down Expand Up @@ -87,3 +75,6 @@ export function gitCodeownersCheck(options = {}) {
logIfVerbose(verbose, '✅ No unowned files detected!');
process.exit(0);
}
function isCodeownersFile(file) {
return file.split('/').pop() === 'CODEOWNERS';
}
31 changes: 31 additions & 0 deletions dist/commands/git/utils/git.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,37 @@ describe('getRemoteBranch', () => {
rmSync(remoteDir, { recursive: true, force: true });
}
});
it('should return the configured upstream branch even when remote is not origin', () => {
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), 'git-test-')));
const remoteDir = realpathSync(mkdtempSync(join(tmpdir(), 'git-remote-')));
try {
execSync('git init', { cwd: tempDir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', {
cwd: tempDir,
stdio: 'pipe',
});
execSync('git config user.name "Test User"', {
cwd: tempDir,
stdio: 'pipe',
});
execSync('git checkout -b main', { cwd: tempDir, stdio: 'pipe' });
writeFileSync(join(tempDir, 'file.txt'), 'content');
execSync('git add .', { cwd: tempDir, stdio: 'pipe' });
execSync('git commit -m "initial"', { cwd: tempDir, stdio: 'pipe' });
execSync('git init --bare', { cwd: remoteDir, stdio: 'pipe' });
execSync(`git remote add upstream "${remoteDir}"`, {
cwd: tempDir,
stdio: 'pipe',
});
execSync('git push -u upstream main', { cwd: tempDir, stdio: 'pipe' });
const result = getRemoteBranch(tempDir);
expect(result).toBe('upstream/main');
}
finally {
rmSync(tempDir, { recursive: true, force: true });
rmSync(remoteDir, { recursive: true, force: true });
}
});
});
describe('getFilesToPush with remote', () => {
it('should return empty array when remote exists and no unpushed commits', () => {
Expand Down
13 changes: 4 additions & 9 deletions dist/commands/git/utils/repo/get-remote-branch.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
import { execSync } from 'node:child_process';
import { resolve } from 'node:path';
import { escapeShellArg } from '../../../../utils/shell.js';
import { isGitRepo } from './is-git-repo.js';
import { getCurrentBranch } from './get-current-branch.js';
export function getRemoteBranch(cwd = process.cwd()) {
try {
if (!isGitRepo(cwd)) {
return null;
}
const resolvedCwd = resolve(cwd);
const currentBranch = getCurrentBranch(resolvedCwd);
if (!currentBranch) {
return null;
}
const remoteBranch = `origin/${currentBranch}`;
execSync(`git rev-parse --verify ${escapeShellArg(remoteBranch)}`, {
const upstreamBranch = execSync('git rev-parse --abbrev-ref --symbolic-full-name @{upstream}', {
cwd: resolvedCwd,
stdio: 'pipe',
encoding: 'utf-8',
});
return remoteBranch;
const remoteBranch = upstreamBranch.trim();
return remoteBranch.length > 0 ? remoteBranch : null;
}
catch {
return null;
Expand Down
23 changes: 22 additions & 1 deletion dist/commands/hook/format/check.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function dartHookFormatCheck(options = {}) {
}
process.exit(1);
}
const filesWithChanges = modifiedFiles.filter((file) => hasUnstagedChanges(file, cwd));
const filesWithChanges = getFilesWithUnstagedChanges(modifiedFiles, cwd);
if (filesWithChanges.length > 0) {
console.error('');
console.error('❌ Push blocked: Files were formatted. Please stage and commit these changes:');
Expand All @@ -53,3 +53,24 @@ export function dartHookFormatCheck(options = {}) {
logIfVerbose(verbose, '✓ All files properly formatted');
process.exit(0);
}
function getFilesWithUnstagedChanges(files, cwd) {
if (files.length === 0) {
return [];
}
try {
const fileArgs = files.map(escapeShellArg).join(' ');
const result = execSync(`git diff --name-only -- ${fileArgs}`, {
cwd,
stdio: 'pipe',
encoding: 'utf-8',
});
const changedFiles = new Set(result
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0));
return files.filter((file) => changedFiles.has(file));
}
catch {
return files.filter((file) => hasUnstagedChanges(file, cwd));
}
}
2 changes: 0 additions & 2 deletions dist/commands/hook/format/check.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,10 @@ describe('dartHookFormatCheck', () => {
isGitRepoSpy.mockReturnValue(true);
isDartPackageSpy.mockReturnValue(true);
getAllChangedFilesSpy.mockReturnValue(['lib/main.dart']);
const hasUnstagedChangesSpy = vi.spyOn(gitUtils, 'hasUnstagedChanges').mockReturnValue(false);
expect(() => {
dartHookFormatCheck({ verbose: true });
}).toThrow('process.exit(0)');
expect(consoleErrorSpy).toHaveBeenCalledWith('Running dart format on 1 file(s):');
expect(consoleErrorSpy).toHaveBeenCalledWith(' lib/main.dart');
hasUnstagedChangesSpy.mockRestore();
});
});
32 changes: 12 additions & 20 deletions dist/commands/hook/graphql/check.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { isDartPackage } from '../../dart/utils/dart.js';
import { ensureCondition, displayFileList } from '../../../utils/command-helpers.js';
import { isCommandInstalled } from '../../../utils/shell.js';
import { logIfVerbose } from '../../../utils/logger.js';
import { getNewlyChangedFiles } from '../../../utils/git-status.js';
import { setVerbose } from '../../../utils/verbose-state.js';
const GRAPHQL_GENERATED_SUFFIXES = new Set(['.gql.dart', '.fakes.dart']);
export async function dartHookGraphqlCheck(options = {}) {
const verbose = options.verbose || false;
const codegenCommands = ['melos run codegen:graphql', 'melos run codegen:graphql:test'];
Expand Down Expand Up @@ -42,33 +44,23 @@ export async function dartHookGraphqlCheck(options = {}) {
}
const gitStatusAfter = getGitStatus(cwd);
ensureCondition(gitStatusAfter !== null, 'Error: Failed to get git status');
if (gitStatusBefore && gitStatusAfter && gitStatusBefore !== gitStatusAfter) {
if (gitStatusBefore === null || gitStatusAfter === null)
return;
const changedFiles = getNewlyChangedFiles(gitStatusBefore, gitStatusAfter).filter(isGraphqlOwnedFile);
if (changedFiles.length > 0) {
console.error('');
console.error('⚠️ WARNING: GraphQL fakes need regeneration!');
console.error(' Modified files:');
try {
const beforeLines = new Set(gitStatusBefore.split('\n').filter((line) => line.length > 0));
const afterLines = gitStatusAfter.split('\n').filter((line) => line.length > 0);
const changedFiles = afterLines.filter((line) => !beforeLines.has(line));
if (changedFiles.length > 0) {
changedFiles.forEach((line) => {
const match = line.match(/^..\s+(.+)$/);
if (match && match[1]) {
console.error(` ${match[1]}`);
}
});
}
else {
console.error(' (Unable to determine changed files)');
}
}
catch {
console.error(' (Unable to determine changed files)');
}
changedFiles.forEach((file) => {
console.error(` ${file}`);
});
console.error('');
console.error(` Run 'melos run codegen:graphql && melos run codegen:graphql:test' and commit changes`);
process.exit(1);
}
logIfVerbose(verbose, '✓ GraphQL fakes are up to date');
process.exit(0);
}
function isGraphqlOwnedFile(file) {
return Array.from(GRAPHQL_GENERATED_SUFFIXES).some((suffix) => file.endsWith(suffix));
}
25 changes: 25 additions & 0 deletions dist/commands/hook/graphql/check.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,29 @@ describe('dartHookGraphqlCheck', () => {
}).rejects.toThrow('process.exit(0)');
expect(processExitSpy).toHaveBeenCalledWith(0);
});
it('should exit with error when GraphQL-owned generated files change', async () => {
isGitRepoSpy.mockReturnValue(true);
isDartPackageSpy.mockReturnValue(true);
getAllChangedFilesSpy.mockReturnValue(['lib/query.graphql']);
isCommandInstalledSpy.mockReturnValue(true);
getGitStatusSpy.mockReturnValueOnce('M lib/query.graphql');
getGitStatusSpy.mockReturnValueOnce('M lib/query.graphql\nM lib/query.gql.dart');
await expect(async () => {
await dartHookGraphqlCheck({ verbose: false });
}).rejects.toThrow('process.exit(1)');
expect(consoleErrorSpy).toHaveBeenCalledWith('⚠️ WARNING: GraphQL fakes need regeneration!');
expect(consoleErrorSpy).toHaveBeenCalledWith(' lib/query.gql.dart');
});
it('should ignore unrelated repo changes made during GraphQL check', async () => {
isGitRepoSpy.mockReturnValue(true);
isDartPackageSpy.mockReturnValue(true);
getAllChangedFilesSpy.mockReturnValue(['lib/query.graphql']);
isCommandInstalledSpy.mockReturnValue(true);
getGitStatusSpy.mockReturnValueOnce('M lib/query.graphql');
getGitStatusSpy.mockReturnValueOnce('M lib/query.graphql\nM README.md');
await expect(async () => {
await dartHookGraphqlCheck({ verbose: false });
}).rejects.toThrow('process.exit(0)');
expect(processExitSpy).toHaveBeenCalledWith(0);
});
});
2 changes: 1 addition & 1 deletion dist/commands/upgrade.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function upgrade(options = {}) {
logIfVerbose(verbose, `📦 Current version: ${currentVersion}`);
logIfVerbose(verbose, `✨ Latest version: ${latestVersion}`);
logIfVerbose(verbose, `📥 Upgrading using ${packageManager}...`);
upgradeFromGitHub(GITHUB_OWNER, GITHUB_REPO, packageManager);
upgradeFromGitHub(GITHUB_OWNER, GITHUB_REPO, `v${latestVersion}`, packageManager);
logIfVerbose(verbose, `✓ Successfully upgraded to version ${latestVersion}`);
process.exit(0);
}
Expand Down
1 change: 1 addition & 0 deletions dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export { dartFix, type DartFixOptions } from './commands/dart/fix.js';
export { parseDcmAnalyzeOutput, dcmAnalyze, type CallAndParseDcmOptions, type CallAndParseDcmResult, } from './utils/dcm-parse.js';
export { dartDcmAnalyze, type DartDcmAnalyzeOptions } from './commands/dart/dcm/analyze.js';
export { checkExternals, type CheckExternalsOptions } from './commands/check/externals.js';
export { parseGitStatusEntries, getNewlyChangedFiles } from './utils/git-status.js';
export { logError, getErrorLogPath, isErrorLoggingEnabled, sanitizeErrorMessage, createErrorContext, type ErrorLogConfig, type ErrorContext, } from './utils/error-logger.js';
1 change: 1 addition & 0 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export { dartFix } from './commands/dart/fix.js';
export { parseDcmAnalyzeOutput, dcmAnalyze, } from './utils/dcm-parse.js';
export { dartDcmAnalyze } from './commands/dart/dcm/analyze.js';
export { checkExternals } from './commands/check/externals.js';
export { parseGitStatusEntries, getNewlyChangedFiles } from './utils/git-status.js';
export { logError, getErrorLogPath, isErrorLoggingEnabled, sanitizeErrorMessage, createErrorContext, } from './utils/error-logger.js';
2 changes: 2 additions & 0 deletions dist/utils/git-status.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export declare function parseGitStatusEntries(status: string): Map<string, string>;
export declare function getNewlyChangedFiles(before: string, after: string): string[];
30 changes: 30 additions & 0 deletions dist/utils/git-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export function parseGitStatusEntries(status) {
const entries = new Map();
status
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.length > 0)
.forEach((line) => {
const match = line.match(/^(.{2})\s+(.+)$/);
if (!match) {
return;
}
const [, state, rawPath] = match;
if (!state || !rawPath)
return;
const normalizedPath = rawPath.includes(' -> ')
? (rawPath.split(' -> ').pop() ?? rawPath)
: rawPath;
if (normalizedPath) {
entries.set(normalizedPath, state);
}
});
return entries;
}
export function getNewlyChangedFiles(before, after) {
const beforeEntries = parseGitStatusEntries(before);
const afterEntries = parseGitStatusEntries(after);
return Array.from(afterEntries.entries())
.filter(([path, state]) => beforeEntries.get(path) !== state)
.map(([path]) => path);
}
2 changes: 1 addition & 1 deletion dist/utils/version.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ export declare function checkForUpdate(owner: string, repo: string): Promise<{
latestVersion: string;
}>;
export declare function detectPackageManager(): 'npm' | 'pnpm' | 'yarn' | null;
export declare function upgradeFromGitHub(owner: string, repo: string, packageManager?: 'npm' | 'pnpm' | 'yarn'): void;
export declare function upgradeFromGitHub(owner: string, repo: string, ref?: string, packageManager?: 'npm' | 'pnpm' | 'yarn'): void;
10 changes: 8 additions & 2 deletions dist/utils/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,21 @@ export function detectPackageManager() {
function isValidGitHubName(name) {
return /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name);
}
export function upgradeFromGitHub(owner, repo, packageManager) {
function isValidGitRef(ref) {
return /^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(ref);
}
export function upgradeFromGitHub(owner, repo, ref, packageManager) {
if (!isValidGitHubName(owner)) {
throw new Error(`Invalid GitHub owner: "${owner}". Must be alphanumeric with hyphens/underscores.`);
}
if (!isValidGitHubName(repo)) {
throw new Error(`Invalid GitHub repo: "${repo}". Must be alphanumeric with hyphens/underscores.`);
}
if (ref && !isValidGitRef(ref)) {
throw new Error(`Invalid GitHub ref: "${ref}". Must be alphanumeric with hyphens, underscores, dots, and slashes.`);
}
const pm = packageManager || detectPackageManager() || 'pnpm';
const githubUrl = `github:${owner}/${repo}`;
const githubUrl = ref ? `github:${owner}/${repo}#${ref}` : `github:${owner}/${repo}`;
let command;
switch (pm) {
case 'pnpm':
Expand Down
4 changes: 3 additions & 1 deletion docs/git.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ Outputs the git root path to stdout. Perfect for `cd "$(tsutils git root)"`.

Outputs filenames (one per line) to stdout. With `--all`, prefixes with type (`committed:`, `staged:`, `unstaged:`).

When used with the default push-oriented behavior, tsutils follows the branch's configured git upstream (for example `origin/feature-x` or `upstream/main`) instead of assuming `origin/<branch>`.

### `git branch`

Outputs the current git branch name to stdout.
Expand All @@ -105,7 +107,7 @@ Generates a GitHub PR description from branch changes using Claude CLI. Outputs

### `git codeowners check`

Checks if CODEOWNERS files are in sync by running `coach codeowners generate` and verifying no files changed. Also checks for unowned files using `coach codeowners unowned --check`. Returns exit code only (0=all checks pass, 1=checks fail). Perfect for CI/CD pipelines.
Checks if CODEOWNERS files are in sync by running `coach codeowners generate` and verifying that no `CODEOWNERS` files changed. Also checks for unowned files using `coach codeowners unowned --check`. Returns exit code only (0=all checks pass, 1=checks fail). Perfect for CI/CD pipelines.

**Requirements**: `coach` CLI must be installed.

Expand Down
10 changes: 5 additions & 5 deletions docs/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Self-upgrade tsutils to the latest version from the GitHub repository.

## upgrade

Upgrades tsutils from GitHub to the latest version. This command first checks if an update is available, then uses your specified package manager to install the latest version from GitHub.
Upgrades tsutils from GitHub. This command first checks the latest GitHub release, then uses your specified package manager to install that exact release tag.

**Usage:**

Expand Down Expand Up @@ -84,15 +84,15 @@ fi
**Notes:**

- The upgrade command uses the same package manager syntax as the initial installation
- It installs from `github:bestdan/tsu` which always gets the latest code from the main branch
- It installs the resolved release tag, for example `github:bestdan/tsu#v0.7.0`, rather than a floating branch ref
- Requires appropriate permissions to install global packages
- If the upgrade fails, you can manually reinstall:
```bash
npm install -g github:bestdan/tsu
npm install -g github:bestdan/tsu#v0.7.0
# or
pnpm add -g github:bestdan/tsu
pnpm add -g github:bestdan/tsu#v0.7.0
# or
yarn global add github:bestdan/tsu
yarn global add github:bestdan/tsu#v0.7.0
```

## Related Commands
Expand Down
Loading
Loading