Skip to content
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.2.0] - 2026-05-13

### Added

- `pr cleanup` command to delete merged branches locally and remotely
- `pr cleanup --dry-run` flag to preview changes without applying them
- `pr cleanup --force` flag to skip ahead-of-base safety checks
- `pr push <pr-number>` command to push local changes back to contributor's fork
- `pr stack init --base <branch>` command to initialize a stacked PR chain
- `pr stack add <branch> --depends-on <branch>` command to add PRs to a stack
- `pr stack status` command to view stacked PR chain status
- `pr stack sync` command to synchronize stacked PRs with base branch
- `pr next` command to checkout the next PR in a dependency chain
- `pr next --reverse` command to checkout the previous PR in a chain
- `src/api/pr.ts` for GitHub pull request API calls
- `src/services/pr.ts` with branch detection, squash/rebase safety, and fast-forward logic
- `src/services/stack.ts` for stacked PR chain management
- `src/commands/pr.ts` with self-registering `pr` subcommand module
- `src/core/git.ts` for Git operations (branch detection, remote tracking, fast-forward)
- Unit tests for `pr` service functionality
- Unit tests for `stack` service functionality
- Unit tests for `core/git` operations
- Fast-forward of default branch (`main`/`master`) after cleanup

## [2.1.0] - 2026-05-09

### Added
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,37 @@ ghitgud config set <key> <val> Set a configuration value (token or repo)
ghitgud config get <key> Get a configuration value
```

## PR Workflow Commands

### Clean up merged branches

```bash
ghitgud pr cleanup --dry-run # Preview what would be deleted
ghitgud pr cleanup # Delete merged branches
```

### Push back to contributor's fork

```bash
ghitgud pr push <pr-number> # Push local changes to contributor's fork
```

### Manage stacked PRs

```bash
ghitgud pr stack init --base main
ghitgud pr stack add feature-part-2 --depends-on feature-part-1
ghitgud pr stack status
ghitgud pr stack sync
```

### Navigate PR chain

```bash
ghitgud pr next # Checkout next PR in chain
ghitgud pr next --reverse # Checkout previous PR
```

## Templates

Built-in label presets are available with the `--template` / `-t` flag:
Expand Down
90 changes: 90 additions & 0 deletions src/api/pr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import client from "./client";

interface PullRequest {
number: number;
title: string;
state: string;
merged: boolean;
maintainer_can_modify: boolean;

head: {
ref: string;
repo: {
full_name: string;
html_url: string;
} | null;
};

base: {
ref: string;
};

merge_commit_sha: string | null;
}

const pr = {
fetchMerged: async (): Promise<Response> => {
const repo = client.getRepo();
return client.get(`/repos/${repo}/pulls?state=closed&per_page=100`);
},

getCommit: async (sha: string): Promise<Response> => {
const repo = client.getRepo();
return client.get(`/repos/${repo}/commits/${sha}`);
},

fetch: async (prNumber: number): Promise<PullRequest> => {
const repo = client.getRepo();
const response = await client.get(`/repos/${repo}/pulls/${prNumber}`);
return response.json();
},

checkPushAccess: async (repo: string): Promise<boolean> => {
try {
const response = await client.get(`/repos/${repo}`);
const data = await response.json();
return data.permissions?.push === true;
} catch {
return false;
}
},

listOpen: async (): Promise<Response> => {
const repo = client.getRepo();
return client.get(`/repos/${repo}/pulls?state=open&per_page=100`);
},

createPr: async (body: {
title: string;
head: string;
base: string;
body: string;
draft: boolean;
}): Promise<PullRequest> => {
const repo = client.getRepo();
const response = await client.post(`/repos/${repo}/pulls`, body);
return response.json();
},

updatePr: async (
prNumber: number,
body: {
title?: string;
body?: string;
base?: string;
state?: string;
},
): Promise<PullRequest> => {
const repo = client.getRepo();

const response = await client.patch(
`/repos/${repo}/pulls/${prNumber}`,
body,
);

return response.json();
},
};

export default pr;
export type { PullRequest };
4 changes: 3 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { program } from "commander";
import ascii from "./ascii";
import logger from "@/core/logger";
import ghCommand from "@/commands/gh";
import prCommand from "@/commands/pr";
import pingCommand from "@/commands/ping";
import { GhitgudError } from "@/core/errors";
import labelsCommand from "@/commands/labels";
import configCommand from "@/commands/config";
import mentionsCommand from "@/commands/mentions";
import activityCommand from "@/commands/activity";
import notificationsCommand from "@/commands/notifications";
import { GhitgudError } from "@/core/errors";

const NAME = "ghitgud";
const DESCRIPTION = "A simple CLI to give superpowers to GitHub.";
Expand All @@ -24,6 +25,7 @@ mentionsCommand.register(program);
pingCommand.register(program);
labelsCommand.register(program);
configCommand.register(program);
prCommand.register(program);

program.addHelpText("before", ascii);
program.exitOverride();
Expand Down
93 changes: 93 additions & 0 deletions src/commands/pr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Command } from "commander";
import prService from "@/services/pr";
import stackService from "@/services/stack";

const register = (program: Command) => {
const pr = program
.command("pr")
.description("Manage pull requests for a repository.");

pr.command("cleanup")
.description(
"Delete merged branches locally and remotely, and fast-forward the base branch.",
)
.option(
"--dry-run",
"Show what would be done without making changes",
false,
)
.option("--force", "Skip confirmation prompts (commits ahead check)", false)
.action(async (options) => {
await prService.cleanup({
dryRun: options.dryRun,
force: options.force,
});
});

pr.command("push <pr-number>")
.description("Push current local changes back to a contributor's fork.")
.option("-f, --force", "Force push even if there are diverged commits")
.action(async (prNumber: string, options) => {
await prService.push(parseInt(prNumber, 10), options.force);
});

pr.command("next")
.description("Checkout the next PR in a dependency chain.")
.option("--reverse", "Go to previous PR in chain instead of next")
.option("--list", "Show all PRs in current stack without checking out")
.action(async (options) => {
await stackService.next({
reverse: options.reverse,
list: options.list,
});
});

const stack = pr
.command("stack")
.description("Manage stacked PRs (create/update dependent chains).");

stack
.command("create")
.description("Create a new stack from current branch.")
.option(
"--base <branch>",
"Base branch for the stack (default: auto)",
"auto",
)
.action(async (options) => {
await stackService.create({ base: options.base });
});

stack
.command("list")
.description("Show current stack status.")
.action(async () => {
await stackService.list();
});

stack
.command("update")
.description("Update existing stack after parent PR merges.")
.action(async () => {
await stackService.update();
});

stack
.command("push")
.description("Push entire stack and create/update PRs.")
.option("--base <branch>", "Base branch for the stack")
.option(
"--title <title>",
"Title template for stacked PRs",
"feat: {branch}",
)
.option("--draft", "Create PRs as drafts", false)
.action(async (options) => {
await stackService.push({
title: options.title,
draft: options.draft,
});
});
};

export default { register };
Loading