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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

- **New — `gg status` (`st`):** compact status — branch, upstream ahead/behind, and staged/unstaged/untracked/conflicted counts with a file list.
- **New — `gg log [n]` (`lg`):** pretty recent-commit log (hash, relative date, author, subject; default 10, max 100).
- **New — `gg undo`:** un-commit the last commit and keep the changes staged (`reset --soft HEAD~1`), with a confirm (`-y` skips) and a warning when the commit is already on the remote.
- **New — `gg stash [pop|list]`:** park work-in-progress including untracked files (`stash push -u`, optional `-m "label"`), restore with `pop`, inspect with `list`.
- **New — `gg amend [-m]`:** stage everything and amend the last commit, keeping the message (or replacing it with `-m`); warns when the commit was already pushed.
- **Fix — `gg pull`:** the long form now runs a git pull (same as `pl`); previously it opened the PR flow. `pr` / `pull-request` still create PRs.
- **Fix — `gg restore`:** now discards staged changes too (`git restore --staged --worktree`), matching the documented "ALL uncommitted changes".
- **Improvement — auto upstream:** `gg c p`, `gg cnp`, and `gg m` now push with `-u origin <branch>` automatically when the branch has no upstream (no more failed first push).
- **Improvement — faster doctor:** independent checks (git, gh, repo, OpenRouter, …) run in parallel.
- **Improvement — help:** command list grouped into Workflows · Inspect & fix · Setup & tooling sections.
- **PR workflow:** `gg pr [base]` and `gg cnp pr [base]` — push the current branch, generate an AI title + description from the branch diff, and create a GitHub pull request via `gh`. Prompts for the merge target (base) branch; `-y` skips confirm and uses the default base.
- **PR AI:** title and body are two separate plain-text model calls (no JSON), which is more reliable on smaller models.
- **PR reuse:** if the head branch already has an open PR, push only and print that URL (skip AI create).
Expand Down
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,25 @@ All of these work with **`gg`**, **`gitgen`**, or **`git-gen`**.
| `gg s` | `gg save` | Commit current work → checkout `main` |
| `gg sw <branch>` | `gg switch <branch>` | Checkout a branch |
| `gg r <url>` | `gg remote <url>` | `git init` → add `origin` → first push to `main` |
| `gg rs` | `gg restore` | Discard **all** uncommitted changes (asks first) |
| `gg pl` | `gg pull` | Pull upstream (`--rebase` default; `--merge` to merge) |
| `gg rs` | `gg restore` | Discard **all** uncommitted changes, staged included (asks first) |
| `gg rs <file>` | `gg restore <file>` | Discard changes to one file |

### Inspect & fix

| Short | Long | Description |
|-------|------|-------------|
| `gg st` | `gg status` | Compact status: branch, ahead/behind, changed files |
| `gg lg [n]` | `gg log [n]` | Recent commits (default 10, max 100) |
| `gg undo` | `gg undo` | Un-commit the last commit, keep changes staged (asks first) |
| `gg amend` | `gg amend [-m]` | Stage all → amend last commit (keep or replace message) |
| `gg stash` | `gg stash` | Stash WIP including untracked files (`-m "label"` optional) |
| `gg stash pop` | same | Restore the latest stash |
| `gg stash list` | same | List stashes |
| `gg dr` | `gg doctor` | Check git, gh, API key, repo, upstream |

`undo` and `amend` warn when the last commit is already on the remote (rewriting history may need a force push). Pushes from `gg c p` / `gg cnp` / `gg m` set the upstream automatically on a branch's first push.

### Setup & tooling

| Short | Long | Description |
Expand All @@ -158,8 +174,9 @@ All of these work with **`gg`**, **`gitgen`**, or **`git-gen`**.

| Flag | Commands | Effect |
|------|----------|--------|
| `-m "msg"` / `--message "msg"` | Any command that commits | Use this message instead of AI / default |
| `-y` / `--yes` | `restore` / `rs` / `pr` | Skip restore confirm; on PR, use default base without prompting |
| `-m "msg"` / `--message "msg"` | Any command that commits; `amend`; `stash` | Use this message instead of AI / default (on `stash`: label) |
| `-y` / `--yes` | `restore` / `rs` / `undo` / `pr` | Skip the confirm; on PR, use default base without prompting |
| `--merge` / `--rebase` | `pull` / `pl` | Pull by merge instead of the default rebase |
| `-v` / `-V` / `--version` | — | Same as `version` |
| `-h` / `--help` | — | Same as `help` |

Expand Down Expand Up @@ -264,6 +281,22 @@ gg r https://github.com/you/new-repo.git
# Undo uncommitted mess (confirm required unless -y)
gg rs
gg rs package-lock.json

# See where you are and what changed
gg st # branch, ahead/behind, changed files
gg lg 5 # last 5 commits

# Fix the last commit
gg undo # un-commit, keep changes staged
gg amend # add forgotten files to the last commit
gg amend -m "fix: better msg" # also rewrite its message

# Park work without committing
gg stash -m "half-done login"
gg sw main
# …later…
gg sw feature/login
gg stash pop
```

### Live progress
Expand Down
3 changes: 2 additions & 1 deletion lib/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const PUSH_ALIASES = new Set(["cnp"]);

export function isPrToken(t: string | undefined): boolean {
const v = (t || "").toLowerCase();
return v === "pr" || v === "pull" || v === "pull-request";
// "pull" is NOT a PR token — `gg pull` means git pull.
return v === "pr" || v === "pull-request";
}

export function isPushToken(t: string | undefined): boolean {
Expand Down
27 changes: 14 additions & 13 deletions lib/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,28 +306,29 @@ export async function runDoctorChecks(deps: DoctorDeps): Promise<DoctorSummary>
const file = loadConfigFn(configPath);
const { apiKey } = resolveRuntimeSettings(file, env);

const checks: DoctorCheck[] = [
await checkNode(deps),
await checkGit(deps, run),
await checkRepo(deps, run),
];

const branch = await checkBranch(deps, run);
// Independent checks run in parallel — doctor answers in one round-trip
// instead of a serial chain (matters when git/gh/network are slow).
const [node, gitCheck, repo, branch, upstream, gh, or] = await Promise.all([
checkNode(deps),
checkGit(deps, run),
checkRepo(deps, run),
checkBranch(deps, run),
checkUpstream(deps, run),
checkGh(run, deps.cwd),
checkOpenRouter(apiKey, fetchFn),
]);

const checks: DoctorCheck[] = [node, gitCheck, repo];
if (branch) checks.push(branch);

const upstream = await checkUpstream(deps, run);
if (upstream) checks.push(upstream);

checks.push(checkApiKey(file, env));
checks.push(checkConfigPath(configPath));

const gh = await checkGh(run, deps.cwd);
checks.push(gh);

// gh auth only makes sense when gh exists — the one dependency kept serial.
const ghAuth = await checkGhAuth(run, deps.cwd, gh.status === "ok");
if (ghAuth) checks.push(ghAuth);

const or = await checkOpenRouter(apiKey, fetchFn);
if (or) checks.push(or);

return summarizeDoctorChecks(checks);
Expand Down
75 changes: 75 additions & 0 deletions lib/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Pure parsers for `gg status` — porcelain status and ahead/behind counts.
* No IO here so everything is unit-testable on any OS.
*/

export type StatusEntry = {
/** Two-char porcelain XY code (e.g. "M ", " M", "??", "UU"). */
code: string;
path: string;
};

export type StatusSummary = {
staged: number;
unstaged: number;
untracked: number;
conflicted: number;
entries: StatusEntry[];
};

/** Parse `git status --porcelain -u` output into counts + entries. */
export function parsePorcelainStatus(raw: string): StatusSummary {
const summary: StatusSummary = {
staged: 0,
unstaged: 0,
untracked: 0,
conflicted: 0,
entries: [],
};
for (const line of raw.split("\n")) {
if (line.length < 4) continue;
const code = line.slice(0, 2);
const path = line.slice(3).trim();
if (!path) continue;
summary.entries.push({ code, path });
if (code === "??") {
summary.untracked++;
continue;
}
const [x, y] = [code[0], code[1]];
// Merge conflicts: any U, or both-added / both-deleted.
if (x === "U" || y === "U" || code === "AA" || code === "DD") {
summary.conflicted++;
continue;
}
if (x !== " ") summary.staged++;
if (y !== " ") summary.unstaged++;
}
return summary;
}

/**
* Parse `git rev-list --left-right --count <upstream>...HEAD` output
* ("<behind>\t<ahead>"). Returns null when unparseable (e.g. no upstream).
*/
export function parseAheadBehind(raw: string): { ahead: number; behind: number } | null {
const m = raw.trim().match(/^(\d+)\s+(\d+)$/);
if (!m) return null;
return { behind: parseInt(m[1], 10), ahead: parseInt(m[2], 10) };
}

/** Human label for a porcelain XY code, for the file list. */
export function describeStatusCode(code: string): string {
if (code === "??") return "untracked";
if (code[0] === "U" || code[1] === "U" || code === "AA" || code === "DD") return "conflict";
const map: Record<string, string> = {
M: "modified",
A: "added",
D: "deleted",
R: "renamed",
C: "copied",
T: "type",
};
const key = code[0] !== " " ? code[0] : code[1];
return map[key] || "changed";
}
Loading
Loading