diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb0b31..e1ee7c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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). diff --git a/README.md b/README.md index 95feefb..9e45561 100644 --- a/README.md +++ b/README.md @@ -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 ` | `gg switch ` | Checkout a branch | | `gg r ` | `gg remote ` | `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 ` | `gg restore ` | 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 | @@ -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` | @@ -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 diff --git a/lib/cli-args.ts b/lib/cli-args.ts index efdb8c8..7c82eda 100644 --- a/lib/cli-args.ts +++ b/lib/cli-args.ts @@ -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 { diff --git a/lib/doctor.ts b/lib/doctor.ts index 9d6843a..fb59cd2 100644 --- a/lib/doctor.ts +++ b/lib/doctor.ts @@ -306,28 +306,29 @@ export async function runDoctorChecks(deps: DoctorDeps): Promise 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); diff --git a/lib/status.ts b/lib/status.ts new file mode 100644 index 0000000..5099975 --- /dev/null +++ b/lib/status.ts @@ -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 ...HEAD` output + * ("\t"). 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 = { + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + C: "copied", + T: "type", + }; + const key = code[0] !== " " ? code[0] : code[1]; + return map[key] || "changed"; +} diff --git a/scripts/cli.ts b/scripts/cli.ts index e6c3d01..5430f51 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -17,8 +17,13 @@ * save s add -> commit -> checkout default branch * checkout ck git checkout (sw alias) * remote r init -> remote add origin -> first push - * restore [file] rs [file] git restore . (or one file) — destructive + * restore [file] rs [file] discard staged+worktree changes — destructive * pull [--merge] pl [--merge] git pull --rebase (default) or merge pull + * status st compact status (branch, ahead/behind, files) + * log [n] lg [n] recent commits (default 10) + * undo undo un-commit the last commit, keep changes staged + * stash [pop|list] stash stash away / restore / list WIP (includes untracked) + * amend [-m] amend add . -> amend last commit (keep or replace message) * doctor dr check git, gh, API key, repo, upstream * model [slug] mo [slug] show or switch the AI model * setup / onboard setup OpenRouter onboard (hidden key + model + lang) @@ -77,6 +82,7 @@ import { import { sanitizeBranchName } from "../lib/branch-name"; import { PUSH_ALIASES, isPrToken, isPushToken, parseCommitPrArgs } from "../lib/cli-args"; import { runDoctorChecks } from "../lib/doctor"; +import { describeStatusCode, parseAheadBehind, parsePorcelainStatus } from "../lib/status"; import { APP_NAME, CLI_NAME, getPackageName, getVersion } from "../lib/version"; import { c, header, row, sym, visibleLength } from "../lib/ui"; @@ -181,7 +187,12 @@ function gitLabel(args: string[]): string { if (cmd === "checkout" && args[1]) return `git checkout ${args[1]}`; if (cmd === "push" && args.includes("-u")) return "git push -u"; if (cmd === "merge" && args[1]) return `git merge ${args[1]}`; - if (cmd === "restore" && args[1] && args[1] !== ".") return `git restore ${args[1]}`; + if (cmd === "restore") { + const target = args[args.length - 1]; + return target && target !== "." ? `git restore ${target}` : "git restore"; + } + if (cmd === "stash" && args[1]) return `git stash ${args[1]}`; + if (cmd === "reset" && args[1]) return `git reset ${args[1]}`; if (cmd === "remote" && args[1] === "add") return "git remote add"; if (cmd === "branch" && args[1] === "-M") return "git branch -M"; return `git ${cmd}`; @@ -335,6 +346,18 @@ async function hasUpstream(): Promise { return Boolean(up); } +/** Push the current branch, setting upstream automatically on first push. */ +async function pushCurrent(): Promise { + if (await hasUpstream()) { + await git(["push"], { progress: true }); + } else { + const head = (await gitQuiet(["branch", "--show-current"])).trim(); + if (!head) die("detached HEAD — checkout a branch before pushing"); + log(` ${c.dim("no upstream — first push sets")} ${c.dim(`origin/${head}`)}`); + await git(["push", "-u", "origin", head], { progress: true }); + } +} + async function isInsideGitRepo(): Promise { return (await gitQuiet(["rev-parse", "--is-inside-work-tree"])).trim() === "true"; } @@ -451,11 +474,7 @@ async function createPullRequest(baseHint?: string): Promise { } // Ensure remote has our commits - if (await hasUpstream()) { - await git(["push"], { progress: true }); - } else { - await git(["push", "-u", "origin", head], { progress: true }); - } + await pushCurrent(); // Same head already has an open PR → just update via push; don't create another. const existing = await withProgress("check existing PR", () => findOpenPrForHead(head)); @@ -735,7 +754,6 @@ const SHORT_CMDS: Record = { c: "commit", cnp: "commit", // commit AND push in one word (implies push — see PUSH_ALIASES) pr: "pr", - pull: "pr", "pull-request": "pr", b: "branch", m: "merge", @@ -746,6 +764,8 @@ const SHORT_CMDS: Record = { r: "remote", rs: "restore", pl: "pull", + st: "status", + lg: "log", dr: "doctor", doctor: "doctor", mo: "model", @@ -1056,31 +1076,59 @@ function helpText(): string { const { apiKey, model } = currentSettings(); const { packageRoot: root } = installPaths(); const d = c.dim; - const rows: Array<[string, string, string]> = [ - ["start", "start", "open the web app with this folder"], - ["commit [push] [pr] [-m]", "c [p] [pr] [-m]", "add . → commit [→ push] [→ PR]"], - ["commit-and-push [pr]", "cnp [pr]", "add . → commit → push [→ AI PR via gh]"], - ["pr [base] [-y]", "pr [base]", "push → AI title/body → create PR (asks base)"], - ["branch [-m]", "b [-m]", "new branch → add → commit → push -u"], - ["merge [dst] [-m]", "m [dst]", "commit → checkout dst|default → merge → push"], - ["save [-m]", "s [-m]", "commit current work, then checkout default branch"], - ["checkout ", "ck ", "git checkout "], - ["remote [-m]", "r [-m]", "git init → remote add origin → first push"], - ["restore [file] [-y]", "rs [file] [-y]", "discard changes (all, or one file)"], - ["pull [--merge]", "pl [--merge]", "pull upstream (rebase default; --merge to merge)"], - ["doctor", "dr", "check git, gh, API key, repo, upstream"], - ["model [slug]", "mo [slug]", "show or switch the AI model"], - ["setup / onboard", "setup", "OpenRouter onboard (hidden key + model)"], - ["config [show|set|path|reset]", "config", "show/set config · reset = re-onboard"], - ["update", "u", "check npm / install latest"], - ["version", "v", "print version + install path"], - ["help", "h", "show this list (default if no command)"], + // null = section header row + const sections: Array<[string, Array<[string, string, string]>]> = [ + [ + "Workflows", + [ + ["commit [push] [pr] [-m]", "c [p] [pr] [-m]", "add . → commit [→ push] [→ PR]"], + ["commit-and-push [pr]", "cnp [pr]", "add . → commit → push [→ AI PR via gh]"], + ["pr [base] [-y]", "pr [base]", "push → AI title/body → create PR (asks base)"], + ["branch [-m]", "b [-m]", "new branch → add → commit → push -u"], + ["merge [dst] [-m]", "m [dst]", "commit → checkout dst|default → merge → push"], + ["save [-m]", "s [-m]", "commit current work, then checkout default branch"], + ["checkout ", "ck ", "git checkout "], + ["remote [-m]", "r [-m]", "git init → remote add origin → first push"], + ["pull [--merge]", "pl [--merge]", "pull upstream (rebase default; --merge to merge)"], + ], + ], + [ + "Inspect & fix", + [ + ["status", "st", "branch, ahead/behind, changed files"], + ["log [n]", "lg [n]", "recent commits (default 10)"], + ["undo [-y]", "undo [-y]", "un-commit last commit, keep changes staged"], + ["amend [-m]", "amend [-m]", "add . → amend last commit (same or new message)"], + ["stash [pop|list] [-m]", "stash", "park WIP incl. untracked · restore · list"], + ["restore [file] [-y]", "rs [file] [-y]", "discard staged+worktree changes (all, or one file)"], + ["doctor", "dr", "check git, gh, API key, repo, upstream"], + ], + ], + [ + "Setup & tooling", + [ + ["start", "start", "open the web app with this folder"], + ["model [slug]", "mo [slug]", "show or switch the AI model"], + ["setup / onboard", "setup", "OpenRouter onboard (hidden key + model)"], + ["config [show|set|path|reset]", "config", "show/set config · reset = re-onboard"], + ["update", "u", "check npm / install latest"], + ["version", "v", "print version + install path"], + ["help", "h", "show this list (default if no command)"], + ], + ], ]; + const rows = sections.flatMap(([, r]) => r); const longW = Math.max(...rows.map((r) => r[0].length)); const shortW = Math.max(...rows.map((r) => r[1].length)); - const body = rows - .map(([l, s, a]) => ` ${c.cyan(l.padEnd(longW))} ${d(s.padEnd(shortW))} ${a}`) - .join("\n"); + const body = sections + .map( + ([title, secRows]) => + ` ${c.bold(title)}\n` + + secRows + .map(([l, s, a]) => ` ${c.cyan(l.padEnd(longW))} ${d(s.padEnd(shortW))} ${a}`) + .join("\n") + ) + .join("\n\n"); return ` ${c.bold(c.cyan("gitgen / git-gen / gg"))} ${d("— terminal git workflows")} @@ -1188,7 +1236,7 @@ async function main() { await runSteps(async () => { await git(["add", "."]); await git(["commit", "-m", message]); - if (push) await git(["push"], { progress: true }); + if (push) await pushCurrent(); }); } } else if (!wantPr) { @@ -1262,7 +1310,7 @@ async function main() { } await git(["checkout", target]); await git(["merge", source]); - await git(["push"], { progress: true }); + await pushCurrent(); }); return; } @@ -1321,7 +1369,160 @@ async function main() { } } await runSteps(async () => { - await git(["restore", target]); + // --staged --worktree: also unstage, so "ALL uncommitted changes" + // really means all (plain `git restore` leaves staged edits behind). + await git(["restore", "--staged", "--worktree", target]); + }); + return; + } + + case "status": { + await ensureGitRepo(); + banner("status"); + const branch = (await gitQuiet(["branch", "--show-current"])).trim(); + log(row("branch", branch ? c.bold(branch) : c.yellow("(detached HEAD)"))); + + const upstream = (await gitQuiet(["rev-parse", "--abbrev-ref", "@{upstream}"])).trim(); + if (upstream) { + const counts = parseAheadBehind( + await gitQuiet(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]) + ); + const sync = + !counts || (counts.ahead === 0 && counts.behind === 0) + ? c.green("in sync") + : [ + counts.ahead ? c.yellow(`${counts.ahead} ahead`) : "", + counts.behind ? c.yellow(`${counts.behind} behind`) : "", + ] + .filter(Boolean) + .join(c.dim(" · ")); + log(row("remote", `${upstream} ${sync}`)); + } else { + log(row("remote", c.dim("no upstream — first push sets it"))); + } + + const summary = parsePorcelainStatus(await gitQuiet(["status", "--porcelain", "-u"])); + if (summary.entries.length === 0) { + log(` ${sym.ok} ${c.green("working tree clean")}`); + return; + } + const parts = [ + summary.staged ? c.green(`${summary.staged} staged`) : "", + summary.unstaged ? c.yellow(`${summary.unstaged} unstaged`) : "", + summary.untracked ? c.cyan(`${summary.untracked} untracked`) : "", + summary.conflicted ? c.red(`${summary.conflicted} conflicted`) : "", + ].filter(Boolean); + log(row("changes", parts.join(c.dim(" · ")))); + const MAX_FILES = 15; + for (const entry of summary.entries.slice(0, MAX_FILES)) { + log(` ${c.dim(describeStatusCode(entry.code).padEnd(9))} ${entry.path}`); + } + if (summary.entries.length > MAX_FILES) { + log(` ${c.dim(`… and ${summary.entries.length - MAX_FILES} more`)}`); + } + return; + } + + case "log": { + await ensureGitRepo(); + const n = Math.min(Math.max(parseInt(arg1 || "10", 10) || 10, 1), 100); + banner(`log · last ${n}`); + const out = ( + await gitQuiet(["log", `-${n}`, "--no-decorate", "--pretty=format:%h\t%ar\t%an\t%s"]) + ).trim(); + if (!out) { + log(` ${c.dim("no commits yet")}`); + return; + } + for (const line of out.split("\n")) { + const [hash, when, author, ...rest] = line.split("\t"); + log(` ${c.yellow(hash || "")} ${rest.join("\t")}`); + log(` ${c.dim(`${when} · ${author}`)}`); + } + return; + } + + case "undo": { + await ensureGitRepo(); + banner("undo last commit (keep changes)"); + const subject = (await gitQuiet(["log", "-1", "--pretty=%s"])).trim(); + if (!subject) die("no commits to undo"); + const hasParent = (await gitQuiet(["rev-parse", "--verify", "--quiet", "HEAD~1"])).trim(); + if (!hasParent) die("this is the first commit — nothing to reset to"); + const onRemote = (await gitQuiet(["branch", "-r", "--contains", "HEAD"])).trim(); + if (onRemote) { + warn("last commit is already on the remote — undoing rewrites history (next push may need --force)"); + } + log(row("commit", c.bold(subject))); + if (!yes) { + const ok = await confirm("Un-commit it? Your changes stay staged."); + if (!ok) { + log(` ${c.dim("undo aborted")}`); + return; + } + } + await runSteps(async () => { + await git(["reset", "--soft", "HEAD~1"]); + log(` ${c.dim("changes are back in the staging area — recommit with gg c")}`); + }); + return; + } + + case "stash": { + await ensureGitRepo(); + const sub = (arg1 || "push").toLowerCase(); + if (sub === "list") { + banner("stash list"); + const out = (await gitQuiet(["stash", "list"])).trim(); + if (!out) { + log(` ${c.dim("no stashes")}`); + return; + } + for (const line of out.split("\n")) log(` ${c.dim(line)}`); + return; + } + if (sub === "pop") { + banner("stash pop"); + await runSteps(async () => { + await git(["stash", "pop"]); + }); + return; + } + if (sub === "push" || sub === "save") { + banner("stash"); + if (!(await hasChanges())) { + log(` ${sym.ok} ${c.dim("nothing to stash — working tree clean")}`); + return; + } + await runSteps(async () => { + // -u: include untracked files so `gg stash` parks everything. + const args = ["stash", "push", "-u"]; + if (messageFlag?.trim()) args.push("-m", messageFlag.trim()); + await git(args); + log(` ${c.dim("bring it back with")} ${c.cyan("gg stash pop")}`); + }); + return; + } + die(`unknown stash subcommand "${sub}" — use (no arg) | pop | list`); + } + + case "amend": { + await ensureGitRepo(); + banner("amend last commit"); + const subject = (await gitQuiet(["log", "-1", "--pretty=%s"])).trim(); + if (!subject) die("no commit to amend"); + const onRemote = (await gitQuiet(["branch", "-r", "--contains", "HEAD"])).trim(); + if (onRemote) { + warn("last commit is already on the remote — amending rewrites history (next push may need --force)"); + } + log(row("commit", c.bold(subject))); + await runSteps(async () => { + await git(["add", "."]); + if (messageFlag?.trim()) { + await git(["commit", "--amend", "-m", messageFlag.trim()]); + } else { + await git(["commit", "--amend", "--no-edit"]); + } }); return; } diff --git a/test/cli-args.test.ts b/test/cli-args.test.ts index 526f0c6..2dad965 100644 --- a/test/cli-args.test.ts +++ b/test/cli-args.test.ts @@ -3,16 +3,17 @@ import assert from "node:assert/strict"; import { isPrToken, isPushToken, parseCommitPrArgs } from "../lib/cli-args"; describe("isPrToken", () => { - it("accepts pr, pull, pull-request in any case", () => { + it("accepts pr and pull-request in any case", () => { assert.equal(isPrToken("pr"), true); assert.equal(isPrToken("PR"), true); - assert.equal(isPrToken("pull"), true); assert.equal(isPrToken("pull-request"), true); }); it("rejects other tokens and undefined", () => { assert.equal(isPrToken("push"), false); assert.equal(isPrToken("main"), false); + // "pull" is the pull command, not a PR token + assert.equal(isPrToken("pull"), false); assert.equal(isPrToken(undefined), false); assert.equal(isPrToken(""), false); }); diff --git a/test/status.test.ts b/test/status.test.ts new file mode 100644 index 0000000..43299c4 --- /dev/null +++ b/test/status.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + describeStatusCode, + parseAheadBehind, + parsePorcelainStatus, +} from "../lib/status"; + +describe("parsePorcelainStatus", () => { + it("returns zero counts for empty output", () => { + const s = parsePorcelainStatus(""); + assert.equal(s.staged, 0); + assert.equal(s.unstaged, 0); + assert.equal(s.untracked, 0); + assert.equal(s.conflicted, 0); + assert.deepEqual(s.entries, []); + }); + + it("counts staged, unstaged and untracked entries", () => { + const raw = ["M lib/a.ts", " M lib/b.ts", "?? new-file.txt", "A added.ts"].join("\n"); + const s = parsePorcelainStatus(raw); + assert.equal(s.staged, 2); // M + A + assert.equal(s.unstaged, 1); // " M" + assert.equal(s.untracked, 1); + assert.equal(s.conflicted, 0); + assert.equal(s.entries.length, 4); + assert.equal(s.entries[2].path, "new-file.txt"); + }); + + it("counts a file staged AND unstaged (MM) in both buckets", () => { + const s = parsePorcelainStatus("MM lib/a.ts"); + assert.equal(s.staged, 1); + assert.equal(s.unstaged, 1); + }); + + it("detects merge conflicts (UU, AA, DD)", () => { + const s = parsePorcelainStatus(["UU conflict.ts", "AA both-added.ts", "DD both-deleted.ts"].join("\n")); + assert.equal(s.conflicted, 3); + assert.equal(s.staged, 0); + assert.equal(s.unstaged, 0); + }); + + it("ignores blank/short lines", () => { + const s = parsePorcelainStatus("\n \nM lib/a.ts\n"); + assert.equal(s.entries.length, 1); + }); +}); + +describe("parseAheadBehind", () => { + it("parses tab-separated behind/ahead counts", () => { + assert.deepEqual(parseAheadBehind("2\t3"), { behind: 2, ahead: 3 }); + assert.deepEqual(parseAheadBehind("0 0\n"), { behind: 0, ahead: 0 }); + }); + + it("returns null when unparseable (e.g. no upstream)", () => { + assert.equal(parseAheadBehind(""), null); + assert.equal(parseAheadBehind("fatal: no upstream"), null); + }); +}); + +describe("describeStatusCode", () => { + it("labels common codes", () => { + assert.equal(describeStatusCode("??"), "untracked"); + assert.equal(describeStatusCode("M "), "modified"); + assert.equal(describeStatusCode(" M"), "modified"); + assert.equal(describeStatusCode("A "), "added"); + assert.equal(describeStatusCode("D "), "deleted"); + assert.equal(describeStatusCode("R "), "renamed"); + assert.equal(describeStatusCode("UU"), "conflict"); + }); + + it("falls back to a generic label", () => { + assert.equal(describeStatusCode("X "), "changed"); + }); +});