From 3f3c4e0ab68300bb65abfe5de0ead3acc927e4c4 Mon Sep 17 00:00:00 2001 From: Chris Peterson Date: Thu, 26 Mar 2026 16:53:49 -0700 Subject: [PATCH] Initial implementation Filter origin/fi from legacy-format branch parsing so it does not appear as a phantom branch in the list output. Also stop emitting 'origin/fi' in the empty legacy commit message to prevent future occurrences. --- .github/workflows/pages.yml | 31 ++ .gitignore | 2 + .npmignore | 4 + LICENSE | 2 +- README.md | 41 ++- SPEC.md | 485 +++++++++++++++++++++++++++++ STATUS.md | 163 ++++++++++ docs/_sidebar.md | 7 + docs/advanced.md | 90 ++++++ docs/ci-integration.md | 82 +++++ docs/commands.md | 124 ++++++++ docs/home.md | 209 +++++++++++++ docs/index.html | 103 +++++++ docs/merge-process.md | 115 +++++++ docs/quickstart.md | 57 ++++ docs/spec.md | 1 + package-lock.json | 597 ++++++++++++++++++++++++++++++++++++ package.json | 38 +++ src/commands.ts | 323 +++++++++++++++++++ src/git.ts | 253 +++++++++++++++ src/gitlab.ts | 204 ++++++++++++ src/index.ts | 185 +++++++++++ src/merge.ts | 447 +++++++++++++++++++++++++++ src/style.ts | 134 ++++++++ src/types.ts | 15 + src/ui.ts | 156 ++++++++++ tsconfig.json | 14 + 27 files changed, 3880 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 SPEC.md create mode 100644 STATUS.md create mode 100644 docs/_sidebar.md create mode 100644 docs/advanced.md create mode 100644 docs/ci-integration.md create mode 100644 docs/commands.md create mode 100644 docs/home.md create mode 100644 docs/index.html create mode 100644 docs/merge-process.md create mode 100644 docs/quickstart.md create mode 100644 docs/spec.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/commands.ts create mode 100644 src/git.ts create mode 100644 src/gitlab.ts create mode 100644 src/index.ts create mode 100644 src/merge.ts create mode 100644 src/style.ts create mode 100644 src/types.ts create mode 100644 src/ui.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..2b67903 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,31 @@ +name: Deploy docs to GitHub Pages + +on: + push: + branches: [main] + paths: [docs/**] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: docs + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..3e90376 --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +src/ +tsconfig.json +SPEC.md +docs/ diff --git a/LICENSE b/LICENSE index 2cc383d..2bb5e5b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Getty Images +Copyright (c) 2017-2026 Getty Images Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c7e83c3..db26966 100644 --- a/README.md +++ b/README.md @@ -1 +1,40 @@ -# git-fi \ No newline at end of file +# git-fi + +A git plugin that maintains a temporary integration branch named `fi`. Merge multiple in-progress feature branches together to detect conflicts early and test features in collaboration — before they land on `main`. + +**[Documentation](https://gettyimages.github.io/git-fi/)** | **[Specification](SPEC.md)** + +## Install + +Requires Node.js >= 18 and git >= 2.50.0. + +```bash +git clone https://github.com/gettyimages/git-fi.git +cd git-fi +npm install -g . # or: yarn global add file:. +``` + +## Development + +```bash +npm start -- -a my-branch # run from source via tsx +npm run build # compile TypeScript to dist/ +``` + +The implementation follows [SPEC.md](SPEC.md), which defines every requirement with a unique ID and includes mermaid diagrams for the major flows. + +## Project Structure + +```text +SPEC.md Behavioral specification +src/ TypeScript implementation +docs/ Docsify documentation site +``` + +## Contributing + +Bug reports and pull requests are welcome on [GitHub](https://github.com/gettyimages/git-fi/issues). + +## License + +[MIT](LICENSE) — Copyright (c) 2017-2026 Getty Images. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..5da3b33 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,485 @@ +# git-fi Specification (DRAFT) + +## Overview + +git-fi is a git plugin that maintains a temporary integration branch named `fi`. It merges multiple in-progress feature branches together so teams can detect merge conflicts early and test features in collaboration rather than isolation. + +The `fi` branch is ephemeral — it is force-pushed on every operation and should never be manually committed to. + +## Invocation + +``` +git fi [options] [...] +``` + +git-fi is invoked as a git subcommand. It must be run from the repository root (a `.git` directory must exist in the current working directory). + +## Pre-flight Checks + +Before any command executes, git-fi runs the following pre-flight checks: + +1. `PF-01` If no `.git` directory exists in the current working directory, then git-fi shall abort with: `No .git directory found.` +2. `PF-02` If the git version is below 2.50.0, then git-fi shall abort with: `git version X is too old, please upgrade to at least 2.50.0.` +3. `PF-03` If `git config push.default` is `upstream` or `tracking`, then git-fi shall abort with: `Your default git push config is set to a hazardous option.` +4. `PF-04` git-fi shall run `git fetch --quiet --prune origin` once per invocation, memoizing to avoid redundant fetches. + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +flowchart TD + A[git fi invoked] --> B{.git exists?} + B -- no --> B1[ABORT: No .git directory found] + B -- yes --> C{git >= 2.50.0?} + C -- no --> C1[ABORT: git version too old] + C -- yes --> D{push.default safe?} + D -- no --> D1[ABORT: hazardous push config] + D -- yes --> E[Fetch origin] + E --> F[Dispatch command] +``` + +## Global Options + +| ID | Flag | Short | Description | +|----------|-------------|-------|---------------------------------------------------------------------------| +| `OPT-01` | `--debug` | `-d` | Print git commands as they execute; remove `--quiet` from git invocations | +| `OPT-02` | `--bare` | `-b` | Machine-readable output (space-separated branch names for list) | +| `OPT-03` | `--json` | `-j` | Structured JSON output for `list` (see [JSON Output](#json-output)) | +| `OPT-04` | `--select` | `-s` | Interactive branch picker for `--add` / `--remove` (requires TTY) | +| `OPT-05` | `--version` | `-V` | Print the current version string to stdout and exit 0 | +| `OPT-06` | `--help` | `-h` | Print a usage summary to stdout and exit 0; direct to the public wiki for full details | + +`OPT-07` If `--bare` is specified with any action other than `list`, then git-fi shall abort with an error. + +## Terminal Output + +### General + +`TRM-01` When the word `fi` appears in console output (messages, headers, prompts), git-fi shall render it as a code-styled token — e.g., using backtick quoting in markdown-aware terminals, or bold/highlighted formatting in TTY output. + +### Color + +`TRM-02` The system shall use only the base 8 ANSI foreground colors (and their bold variants). The system shall not use 256-color codes, RGB escape sequences, or background colors — these break across terminal themes. Semantic ANSI colors adapt to the user's theme automatically (e.g., "cyan" in Solarized Dark differs from "cyan" in Dracula, but both are readable). + +`TRM-03` The system shall use bold, dim, underline, and other text attributes for structural emphasis so meaning is not conveyed by color alone. + +`TRM-04` When stdout is a TTY, git-fi shall colorize output using these assignments: + +- **Branch names** — cyan; green when highlighted on success +- **Action annotations** (`<- new`, `<- merging`, etc.) — dim; green bold on success +- **Success verb** (`added`, `removed`, etc.) — green, bold +- **Failure indicator** (`failed`) — red, bold +- **Warnings** (dead branches, already-merged) — yellow +- **Errors** and abort messages — red, bold +- **Bullet markers** (` * `) — dim + +`TRM-05` When stdout is not a TTY, `--bare` or `--json` is specified, or the `NO_COLOR` environment variable is set, the system shall disable all color output (see [no-color.org](https://no-color.org)). + +### Progress + +`TRM-06` While a long-running operation is in progress, git-fi shall display progress on stderr so it does not interfere with stdout: + +- **Fetch** — `Fetching from origin...` with a spinner +- **Merge** — `Merging N branches...` +- **GitLab API** — `Fetching CI status...` with a spinner + +`TRM-07` When stderr is not a TTY, or when `--bare` or `--json` is specified, git-fi shall suppress progress output. + +`TRM-08` When a mutation operation is in progress, git-fi shall update each action annotation (`<- ...`) in-place on a TTY using cursor movement, progressing through a sequence of states where each state fully replaces the previous annotation text. When stdout is not a TTY, git-fi shall skip the in-place updates and print `Done!` to stderr instead. + +**Initial state** — displayed when the branch list is first printed: + +| Action | Initial annotation | +|----------|--------------------| +| add | `<- new` | +| remove | `<- removing` | +| force | `<- replacing` | +| again | `<- re-merging` | +| prune | `<- pruning` | + +**Intermediate states** — each overwrites the annotation in-place as the operation progresses: + +1. `<- merging` — before `git merge` (skipped when no branches to merge) +2. `<- committing` — before `git commit` +3. `<- pushing` — before `git push` + +**Terminal states** — the final annotation, styled green bold on success or red bold on failure: + +| Action | Success annotation | Failure annotation | +|----------|--------------------|--------------------| +| add | `<- added` | `<- failed` | +| remove | `<- removed` | `<- failed` | +| force | `<- replaced` | `<- failed` | +| again | `<- re-merged` | `<- failed` | +| prune | `<- pruned` | `<- failed` | + +## Commands + +Exactly one action flag may be specified. If no action and no branches are given, the default action is `list`. + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +flowchart TD + A[Parse flags] --> B{Which action?} + B -- none --> L[list] + B -- -a --> ADD[add: append to branch list] + B -- -r --> REM[remove: subtract from branch list] + B -- -f --> FRC[force: replace branch list] + B -- -g --> AGN[again: keep branch list as-is] + B -- -p --> PRN[prune: remove dead/merged branches] + B -- -A --> ABT[abort: re-pull fi from origin] + + ADD --> M[Merge Process] + REM --> M + FRC --> M + AGN --> M + PRN --> M + + L --> OUT[Print branch list] + ABT --> PULL[Fetch and update origin/fi ref] +``` + +| Flag | Short | Action | +|------|-------|--------| +| _(none)_ | | List branches currently in fi | +| `--add` | `-a` | Add branch(es) to fi | +| `--remove` | `-r` | Remove branch(es) from fi | +| `--force` | `-f` | Replace fi contents with only the given branch(es) | +| `--again` | `-g` | Re-merge all branches currently in fi | +| `--prune` | `-p` | Remove dead and already-merged branches from fi | +| `--abort` | `-A` | Re-pull fi from origin (discard local state) | + +### Branch Name Resolution + +- `BR-01` When a branch name does not start with `origin/`, git-fi shall prepend `origin/`. +- `BR-02` When `--add` or `--remove` is specified with no branch name and `--select` is not set, git-fi shall default to the current branch. If the current branch is `main`, `master`, `fi`, or `HEAD`, then git-fi shall abort with: `No branch was specified.` +- `BR-03` When `--add` or `--force` is specified, git-fi shall verify all branches exist on origin. If any branches are missing, then git-fi shall print the missing branches and abort: + ``` + the following branches do not exist on origin: + * no-such-branch + ``` +- `BR-04` When `--remove` is specified, git-fi shall not perform an existence check (removing a non-existent branch is a no-op). + +### list (default) + +`LS-01` If `origin/fi` does not exist, then git-fi shall abort with: `there is no fi branch for this project.` + +**Behavior:** + +- `LS-02` When `--bare` is specified, git-fi shall print space-separated branch names (without `origin/` prefix) to stdout. +- `LS-03` When listing in normal mode, git-fi shall print a tabular list of branch names (without `origin/` prefix). Where `GITLAB_ACCESS_TOKEN` is set, git-fi shall also show CI status, last commit date, and author (see [GitLab CI Status](#gitlab-ci-status)), followed by the fi integration pipeline ID and status (see GL-05). + +**Output (normal):** + +``` +Branch +────────────── +feature-a +feature-b + +For enhanced CI status, export GITLAB_ACCESS_TOKEN. To suppress this hint, export GIT_FI_NO_HINTS. +``` + +**Output (bare):** + +``` +feature-a feature-b +``` + +`LS-04` When `GITLAB_ACCESS_TOKEN` is set, `GIT_FI_NO_HINTS` is set, or `--bare` or `--json` is specified, git-fi shall suppress the hint line. + +`LS-05` When branch names are given with no action flag, git-fi shall treat them as a single regex pattern that filters the `list` output to matching branches. If more than one pattern is given, then git-fi shall abort. + +`LS-06` git-fi shall display branches in insertion order — the order in which they were originally added. git-fi shall not apply alphabetical or date-based sorting. + +`LS-07` When fi contains no enlisted branches, git-fi shall omit the table entirely (no headers or separator are printed). The `fi:` pipeline line (GL-05) shall still be shown if applicable. + +### Interactive Branch Selection (`--select`) + +`SEL-01` When `--select` is combined with `--add`, git-fi shall display an interactive multi-select picker showing all remote branches not already in fi (excluding the default branch and `origin/fi`). When the user confirms, git-fi shall continue with the normal add flow. + +`SEL-02` When `--select` is combined with `--remove`, git-fi shall display an interactive multi-select picker showing branches currently in fi. When the user confirms, git-fi shall continue with the normal remove flow. + +`SEL-03` If `--select` is specified and a TTY is not available on both stdin and stdout, then git-fi shall abort. + +`SEL-04` If `--select` is combined with `--force`, `--again`, or `--list`, then git-fi shall abort. + +`SEL-05` When the user confirms the picker with no branches selected, git-fi shall exit 0 without merging. + +`SEL-06` When `--select` is used alone (no `--add` / `--remove`), git-fi shall display a unified multi-select picker showing all remote `--no-merged` branches from the last 3 months (sorted by committer date, most recent first), plus any branches currently in fi. Current fi branches shall be pre-selected (toggled on). When the user confirms, git-fi shall compute the diff between the current fi set and the selected set to determine which branches to add and remove, then continue with the normal merge flow. + +### add / `--add` / `-a` + +`AD-01` If the working index is not clean, then git-fi shall abort. + +**Process:** + +1. `AD-02` git-fi shall get the current branch list from fi (via commit message parsing — see [Branch List Storage](#branch-list-storage)). +2. `AD-03` git-fi shall append new branches and deduplicate. +3. `AD-04` git-fi shall run the merge process with the full list. + +**Output:** + +``` +fi: + * feature-a + * feature-b + * feature-c <- added +``` + +### remove / `--remove` / `-r` + +`CMD-01` When `--remove` is specified, git-fi shall remove the specified branches from the current fi branch list and run the merge process with the remaining list. + +**Output:** Removed branches are shown dimmed with `<- removing` annotation. + +`CMD-02` When removing a branch that is not in fi, git-fi shall silently ignore it. + +### force / `--force` / `-f` + +`CMD-03` When `--force` is specified, git-fi shall replace the entire branch list with only the specified branches. + +**Output:** Branch list followed by `<- replacing` footer. + +`CMD-04` When `--force` is specified with no branches, git-fi shall remove all features (empty fi). + +### again / `--again` / `-g` + +`CMD-05` When `--again` is specified, git-fi shall re-merge all branches currently in fi. If branch arguments are provided, then git-fi shall abort with `--again does not accept branch names`. + +**Output:** Branch list followed by `<- re-merging` footer. + +### prune / `--prune` / `-p` + +`CMD-06` When `--prune` is specified, git-fi shall remove branches from fi that no longer exist on origin (dead) or that have already been merged into the default branch. If branch arguments are provided, then git-fi shall abort with `--prune does not accept branch names`. + +`CMD-07` If no branches qualify for pruning, then git-fi shall print `Nothing to prune.` and exit without merging. + +**Output:** Branch list followed by `<- pruning` footer. + +### abort / `--abort` / `-A` + +`CMD-08` When `--abort` is specified, git-fi shall re-pull `origin/fi` from origin, discarding any local ref state. If branch arguments are provided, then git-fi shall abort with `--abort does not accept branch names`. + +`CMD-09` If `origin/fi` does not exist when `--abort` is specified, then git-fi shall abort with `origin/fi does not exist — nothing to re-pull`. + +## Merge Process + +The core merge operation that `--add`, `--remove`, `--force`, and `--again` all converge on. + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +flowchart TD + A[Start merge] --> B{Ambiguous origin/fi?} + B -- yes --> B1[ABORT: more than one origin/fi] + B -- no --> C{Index clean?} + C -- no --> C1[ABORT: index is dirty] + C -- yes --> D[Capture untracked files] + D --> E[Fetch if needed] + E --> F{origin/fi exists?} + F -- no --> G{User confirms bootstrap?} + G -- no --> G1[ABORT] + G -- yes --> H[Prune dead branches] + F -- yes --> H + H --> I[Warn about already-merged branches] + I --> J[Checkout -B fi from default branch] + J --> K[git merge --no-commit --no-ff] + K --> L{Merge succeeded?} + L -- yes --> M[Commit] + M --> N[Push -f origin fi] + N --> O[Print summary] + L -- no --> P[Print failed branches] + P --> Q[git reset --hard HEAD] + Q --> R[List new untracked files] + R --> S[ABORT: merge failures] + O --> T[Cleanup] + S --> T + T --> U[Checkout original branch] + U --> V[Delete local fi branch] +``` + +### Flow + +1. `MG-01` If more than one `origin/fi` ref exists, then git-fi shall abort with: `There is more than one origin/fi!` +2. `MG-02` If uncommitted changes exist, then git-fi shall abort with `Your index is dirty`. +3. `MG-03` git-fi shall capture a snapshot of untracked files via `git ls-files --other --exclude-standard`. +4. `MG-04` git-fi shall run `git fetch --quiet --prune origin` (if not already done). +5. `MG-05` If no `origin/fi` ref exists after fetch, then git-fi shall display a bootstrap confirmation prompt. This prompt shall always be shown and cannot be suppressed. If the user does not enter `y`, then git-fi shall abort. Example: + + ```text + Bootstrap path/to/repo with fi capability? + See: https://github.com/gettyimages/git-fi + + y - yes + anything else: no + + Are you sure? + ``` + + `MG-14` git-fi shall include a `See:` line in the bootstrap prompt (MG-05) linking to the git-fi project README (`https://github.com/gettyimages/git-fi`) so users unfamiliar with fi can understand the tool before confirming. + +6. `MG-06` When branches in the list no longer exist on origin, git-fi shall remove them and warn on stderr: `Ignoring branches that no longer exist:` +7. `MG-07` When a branch is already an ancestor of the default branch, git-fi shall warn on stderr: `X already in main` +8. `MG-08` git-fi shall create a temporary fi branch via `git checkout --quiet -B fi origin/`. +9. `MG-09` git-fi shall merge via `git merge --no-commit --quiet --no-ff --no-edit ...` +10. `MG-10` When the merge succeeds, git-fi shall: + - Commit (see [Commit Message](#commit-message)) — update annotation to `<- committing`. + - Push: `git push --no-verify -f origin fi` — update annotation to `<- pushing`. + - Finalize annotation line(s) with the action's terminal success state (see TRM-08). + - Print the branch list table (identical to `list` output, including the fi pipeline per GL-05) so the user sees the final state without running a separate command. +11. `MG-11` When the merge fails, git-fi shall: + - Abort the failed merge (leave the working tree clean). + - Print failed branch names. + - List any new untracked files created during the failed merge, with suggested `rm` commands. + - Abort with: `Aborted due to merge failures` +12. `MG-12` After the merge process completes (success or failure), git-fi shall: + - Restore the user to their original branch. + - Delete the local temporary `fi` branch. + +### Commit Message + +The commit message uses the format `(branch-a, branch-b)@[shorthash]` where `shorthash` is the short hash of the default branch tip. This format is parsed by BL-01 for round-tripping (see [Branch List Storage](#branch-list-storage)). + +**CI mode** — see [CI Integration](#ci-integration) for the commit message format when running in a pipeline. + +### Success Output + +The annotation line(s) update in-place to show the terminal state (see TRM-08), followed by the full branch list table (see LS-03): + +``` +fi: + * feature-a + * feature-b <- added +fi: #12345 ⏳ + +Branch │ Date │ Author │ Pipeline +──────────┼────────────┼────────┼────────── +feature-a │ 2026-03-30 │ Alice │ 11111 ✅ +feature-b │ 2026-03-30 │ Bob │ 22222 ✅ +``` + +If `GITLAB_ACCESS_TOKEN` is not set, the table has only a Branch column (no CI data). The `fi:` pipeline line (GL-05) is also omitted. + +### Failure Output + +``` +Failed trying to merge branch(es): + + * feature-a + * feature-b + +Aborted due to merge failures +``` + +If new untracked files were created during the failed merge: + +``` +Some extra untracked files have been left as a result of the failed merge(s): + + * conflict-file.txt + +You can delete these by running: + rm "conflict-file.txt" +``` + +## Branch List Storage + +`BL-01` git-fi shall store the list of branches merged into fi in the fi branch's commit message using the format `(branch-a, branch-b)@[shorthash]`. When no branches are present, git-fi shall use the format `@[shorthash]`. Branch names shall be stored without the `origin/` prefix. + +`BL-02` When reading the branch list, git-fi shall parse the commit message of `origin/fi` using the regex pattern `\(([^)]+)\)@\[` and split on commas. git-fi shall prepend the `origin/` prefix during parsing and filter out the default branch. + +### Legacy Commit Message Format + +`BL-03` A previous version of the tool used the standard git merge commit message format: + +```text +Merge remote-tracking branches 'origin/86b8nre6n_New_endpoint_to_complete_cko_flow_order', 'origin/Try-fix_get_api_orders_for_company' and 'origin/prorated-sub-checkout-successful' into fi +``` + +When parsing the fi branch's commit message, if this legacy format is detected — a message matching `Merge remote-tracking branch(es) 'origin/'...into fi` — git-fi shall extract branch names from the quoted `'origin/'` segments and shall continue using the legacy format for subsequent commit messages in the same repository. When the preferred brief format (BL-01) is detected, git-fi shall use that instead. When no `origin/fi` exists (bootstrap), git-fi shall use the preferred brief format. + +## Default Branch Detection + +`BR-05` git-fi shall determine the mainline branch for the repository (typically `main` or `master`) via `git symbolic-ref refs/remotes/origin/HEAD`, extracting the last path component. If the symbolic ref is not set, then git-fi shall fall back to probing `origin/main` and `origin/master`. + +## Formatting Helpers + +- `FMT-01` git-fi shall render bullet lists with each item prefixed with ` * `. When the list is empty, git-fi shall render ``. +- `FMT-02` git-fi shall update action annotations in-place through initial, intermediate, and terminal states as defined in TRM-08. + +## GitLab CI Status + +`GL-01` When `GITLAB_ACCESS_TOKEN` is set, git-fi shall fetch pipeline status for each branch from the GitLab API and display a table with columns: Branch, Date, Author, Pipeline. git-fi shall show status with emoji indicators: + +| Emoji | Status | +| --- | --- | +| ✅ | SUCCESS | +| ❌ | FAILED | +| ⏰ | TIMEOUT | +| ⏳ | RUNNING / PENDING | +| ➖ | MISSING | +| ⏭️ | SKIPPED | + +`GL-02` git-fi shall parse the origin URL to extract the GitLab project path. git-fi shall support both SSH (`git@gitlab.example.com:path/to/repo`) and HTTPS (`https://gitlab.example.com/path/to/repo`) formats, with optional `.git` suffix removed. + +`GL-03` If a GitLab API call fails with a non-404 HTTP error, then git-fi shall abort with a clear error message explaining what failed and suggest unsetting `GITLAB_ACCESS_TOKEN` to use basic mode. When the API returns HTTP 404 for an individual branch (e.g. a deleted branch), git-fi shall treat it as `missing` status rather than a fatal error. + +`GL-04` When a GitLab project is detected, git-fi shall render branch names and pipeline IDs as clickable terminal hyperlinks (OSC 8) pointing to the corresponding GitLab URLs. + +`GL-05` **Pipeline link after merge:** When `GITLAB_ACCESS_TOKEN` is set and a merge operation succeeds, git-fi shall fetch the pipeline for the `fi` branch matching the just-pushed SHA and display it as `fi: # `, where `#` is a clickable hyperlink (OSC 8) and the emoji uses the same set as GL-01. The `fi:` prefix distinguishes this integration pipeline from the per-branch pipelines shown in the list table. If the pipeline has not yet been created by GitLab, git-fi retries up to 4 times with 1.5 s delays. If the API call fails or no matching pipeline appears, the line is silently omitted. + +`GL-06` When the GitLab commits API returns HTTP 404 for a branch in the CI table, git-fi shall display a warning indicator next to the branch name to signal the branch no longer exists on the remote. + +## CI Integration + +`MG-13` When git-fi runs inside a GitLab CI pipeline (detected via the `CI` environment variable), git-fi shall include pipeline context in the commit message for traceability: + +```text +Re-merge fi branch triggered by build due to commit on . Was originally: --- + +(branch-a, branch-b)@[shorthash] +``` + +The trailing signature line ensures that BL-01 round-tripping works even when the previous commit message is embedded in the `Was originally:` preamble. + +| Variable | Purpose | +|----------------------|----------------------------------------------------------------| +| `CI` | When set, enables CI-aware commit messages | +| `CI_PIPELINE_ID` | Pipeline number included in commit message | +| `CI_COMMIT_REF_NAME` | Branch that triggered the pipeline, included in commit message | + +These are standard [GitLab predefined variables](https://docs.gitlab.com/ci/variables/predefined_variables/) and do not need to be configured manually. + +## Environment Variables + +| Variable | Purpose | +|----------|---------| +| `GITLAB_ACCESS_TOKEN` | When set (and non-empty), enables GitLab CI status display in `list`. If set to an empty string, abort with a clear error. | +| `GIT_FI_NO_HINTS` | When set, suppresses the hint about `GITLAB_ACCESS_TOKEN` | +| `NO_COLOR` | When set, disables all color output ([no-color.org](https://no-color.org)) | + +## JSON Output + +`JS-01` If `--json` is specified with any action other than `list`, then git-fi shall abort with an error. + +`JS-02` When `--json` is specified, git-fi shall write a single JSON object to stdout. git-fi shall direct all human-readable output (progress, hints, warnings) to stderr only. + +```json +{ + "command": "list", + "branches": ["feature-a", "feature-b"], + "ci": [ + {"branch": "feature-a", "status": "success", "author": "Name", "date": "2026-03-13"}, + {"branch": "feature-b", "status": "failed", "author": "Name", "date": "2026-03-12"} + ] +} +``` + +`JS-03` Where `GITLAB_ACCESS_TOKEN` is set, git-fi shall include a `ci` array in the JSON output. When the variable is not set, git-fi shall omit the `ci` array. + +## Exit Codes + +- `EX-01` When an operation completes successfully, git-fi shall exit with code `0`. +- `EX-02` When an operation fails, git-fi shall exit with a non-zero code. + +## Platform Compatibility + +- `PLT-01` git-fi shall suppress stderr from git commands. When `--debug` is set or `show_errors` is explicitly requested, git-fi shall allow stderr output. diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..2b07b12 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,163 @@ +# Requirement Coverage + +Tracks implementation status of each requirement in [SPEC.md](/SPEC.md). + +**Last updated:** 2026-04-02 + +## Summary + +| Status | Count | +|---------|-------| +| Covered | 61 | +| Total | 61 | + +## Pre-flight Checks + +| ID | Description | Status | Location | +|-------|----------------------------|---------|---------------------------| +| PF-01 | Repository root | Covered | `src/git.ts:58-59` | +| PF-02 | Git version | Covered | `src/git.ts:62-73` | +| PF-03 | Push config | Covered | `src/git.ts:75-81` | +| PF-04 | Fetch | Covered | `src/git.ts:84-95` | + +## Global Options + +| ID | Description | Status | Location | +|--------|-------------|---------|------------------------| +| OPT-01 | `--debug` | Covered | `src/index.ts:24-26` | +| OPT-02 | `--bare` | Covered | `src/index.ts:28-30` | +| OPT-03 | `--json` | Covered | `src/index.ts:32-34` | +| OPT-04 | `--select` | Covered | `src/index.ts:36-38` | +| OPT-05 | `--version` | Covered | `src/index.ts:40-43` | +| OPT-06 | `--help` | Covered | `src/index.ts:44-69` | +| OPT-07 | `--bare` list-only | Covered | `src/index.ts:134-135` | + +## Terminal Output + +| ID | Description | Status | Location | +|--------|------------------------------|---------|-----------------------| +| TRM-01 | `fi` styled as code token | Covered | `src/style.ts:29` | +| TRM-02 | Base 8 ANSI colors only | Covered | `src/style.ts:22-28` | +| TRM-03 | Text attributes for emphasis | Covered | `src/style.ts:27-28` | +| TRM-04 | Color assignments | Covered | `src/style.ts:22-29`, `src/merge.ts:199,263` | +| TRM-05 | Color disabled conditions | Covered | `src/style.ts:6-9` | +| TRM-06 | Progress on stderr | Covered | `src/style.ts:37-50`, `src/gitlab.ts:43` | +| TRM-07 | Suppress progress when !TTY | Covered | `src/style.ts:12-14` | +| TRM-08 | Annotation lifecycle | Covered | `src/merge.ts:63-77,227-290` | + +## Branch Name Resolution + +| ID | Description | Status | Location | +|-------|--------------------------|---------|------------------------| +| BR-01 | Prepend `origin/` | Covered | `src/git.ts:158-161` | +| BR-02 | Default to current branch| Covered | `src/git.ts:174-179` | +| BR-03 | Existence check on add | Covered | `src/git.ts:182-197` | +| BR-04 | No check on remove | Covered | `src/git.ts:182` (skip)| +| BR-05 | Default branch detection | Covered | `src/git.ts:97-112` | + +## List Command + +| ID | Description | Status | Location | +|-------|----------------------|---------|---------------------------| +| LS-01 | Precondition check | Covered | `src/commands.ts:36-41` | +| LS-02 | Bare mode | Covered | `src/commands.ts:59-61` | +| LS-03 | Normal mode / CI | Covered | `src/commands.ts:83-105` | +| LS-04 | Hint suppression | Covered | `src/commands.ts:109-118` | +| LS-05 | Filter mode | Covered | `src/commands.ts:46-55` | +| LS-06 | Insertion order | Covered | `src/git.ts:150-156` | +| LS-07 | Empty list omits table | Covered | `src/style.ts:114` | + +## Interactive Selection + +| ID | Description | Status | Location | +|--------|-----------------------------|---------|---------------------------| +| SEL-01 | `--select` with `--add` | Covered | `src/commands.ts:129-153` | +| SEL-02 | `--select` with `--remove` | Covered | `src/commands.ts:170-198` | +| SEL-03 | TTY requirement | Covered | `src/index.ts:126-127` | +| SEL-04 | Invalid combinations | Covered | `src/index.ts:122-123` | +| SEL-05 | Empty selection exits | Covered | `src/commands.ts:146-148` | +| SEL-06 | Standalone unified picker | Covered | `src/commands.ts:276-323` | + +## Commands + +| ID | Description | Status | Location | +|--------|--------------------------|---------|----------------------------| +| AD-01 | Clean index precondition | Covered | `src/merge.ts:102-105` | +| AD-02 | Parse current branch list| Covered | `src/commands.ts:155` | +| AD-03 | Append and deduplicate | Covered | `src/commands.ts:156` | +| AD-04 | Run merge | Covered | `src/commands.ts:158` | +| CMD-01 | Remove behavior | Covered | `src/commands.ts:162-198` | +| CMD-02 | Remove non-existent noop | Covered | `src/commands.ts:193-194` | +| CMD-03 | Force replaces list | Covered | `src/commands.ts:200-208` | +| CMD-04 | Force with no branches | Covered | `src/commands.ts:204-205` | +| CMD-05 | Again re-merges | Covered | `src/commands.ts:211-224` | +| CMD-06 | Prune dead/merged | Covered | `src/commands.ts:226-253` | +| CMD-07 | Nothing to prune | Covered | `src/commands.ts:246-248` | +| CMD-08 | Abort re-pulls fi | Covered | `src/commands.ts:255-274` | +| CMD-09 | Abort no origin/fi | Covered | `src/commands.ts:266-267` | + +## Merge Process + +| ID | Description | Status | Location | +|-------|--------------------------|---------|---------------------------| +| MG-01 | Ambiguous ref check | Covered | `src/merge.ts:93-100` | +| MG-02 | Dirty index check | Covered | `src/merge.ts:102-105` | +| MG-03 | Capture untracked | Covered | `src/merge.ts:107-110` | +| MG-04 | Fetch | Covered | `src/merge.ts:111` | +| MG-05 | Bootstrap confirmation | Covered | `src/merge.ts:127-139` | +| MG-06 | Prune dead branches | Covered | `src/merge.ts:142-160` | +| MG-07 | Warn about merged | Covered | `src/merge.ts:162-177` | +| MG-08 | Create temp fi branch | Covered | `src/merge.ts:296-298`, `src/merge.ts:337-339` | +| MG-09 | Merge command | Covered | `src/merge.ts:352-366` | +| MG-10 | On success | Covered | `src/merge.ts:368-401` | +| MG-11 | On failure | Covered | `src/merge.ts:402-446` | +| MG-12 | Cleanup | Covered | `src/merge.ts:390-397`, `src/merge.ts:419-426` | +| MG-13 | CI commit message | Covered | `src/merge.ts:46-57` | +| MG-14 | Bootstrap link | Covered | `src/ui.ts:140` | + +## Branch List Storage + +| ID | Description | Status | Location | +|-------|-----------------------|---------|-------------------------| +| BL-01 | Brief format | Covered | `src/merge.ts:34-39` | +| BL-02 | Parsing | Covered | `src/git.ts:121-148` | +| BL-03 | Legacy format | Covered | `src/git.ts:116-119` | + +## Formatting + +| ID | Description | Status | Location | +|--------|------------------|---------|------------------------| +| FMT-01 | Bullet list | Covered | `src/style.ts:74-95` | +| FMT-02 | Annotation line | Covered | `src/merge.ts:227-245` | + +## GitLab CI + +| ID | Description | Status | Location | +|-------|---------------------|---------|---------------------------| +| GL-01 | CI status table | Covered | `src/gitlab.ts:179-204` | +| GL-02 | Project detection | Covered | `src/gitlab.ts:16-27` | +| GL-03 | No fallback on fail | Covered | `src/gitlab.ts:66-71`, `src/gitlab.ts:113-119` | +| GL-04 | Hyperlinks (OSC 8) | Covered | `src/style.ts:30-31`, `src/gitlab.ts:191-199` | +| GL-05 | Pipeline ID+status after merge | Covered | `src/gitlab.ts:131-177`, `src/commands.ts:89-95` | +| GL-06 | Deleted branch indicator | Covered | `src/gitlab.ts:62-65`, `src/gitlab.ts:96-97`, `src/gitlab.ts:188-190` | + +## JSON Output + +| ID | Description | Status | Location | +|-------|-----------------------|---------|---------------------------| +| JS-01 | JSON only for list | Covered | `src/index.ts:130-131` | +| JS-02 | JSON to stdout | Covered | `src/commands.ts:64-80` | +| JS-03 | CI array conditional | Covered | `src/commands.ts:69-77` | + +## Exit Codes + +| ID | Description | Status | Location | +|-------|-------------|---------|-------------| +| EX-01 | 0 = success | Covered | (implicit) | +| EX-02 | Non-zero | Covered | `src/style.ts:130-134` | + +## Platform + +| ID | Description | Status | Location | +|--------|--------------------|---------|-----------------------| +| PLT-01 | Stderr suppression | Covered | `src/git.ts:25-26` | diff --git a/docs/_sidebar.md b/docs/_sidebar.md new file mode 100644 index 0000000..c19c69a --- /dev/null +++ b/docs/_sidebar.md @@ -0,0 +1,7 @@ +- [Home](/home) +- [Quick Start](/quickstart) +- [Basic Commands](/commands) +- [Advanced Commands](/advanced) +- [Merge Process](/merge-process) +- [CI Integration](/ci-integration) +- [Specification](/spec) diff --git a/docs/advanced.md b/docs/advanced.md new file mode 100644 index 0000000..fe08c1d --- /dev/null +++ b/docs/advanced.md @@ -0,0 +1,90 @@ +# Advanced Commands + +Power-user commands for rebuilding, replacing, and maintaining the `fi` branch. + +## force + +Replace the entire `fi` branch list with only the specified branches. Everything else is removed. + +```bash +git fi -f feature-auth +``` + +This is useful when `fi` has accumulated stale branches and you want a clean slate with just your branch. + +### Empty fi + +With no branch arguments, force removes all features from `fi`: + +```bash +git fi -f +``` + +## again + +Re-merge all branches currently in `fi` without changing the branch list. + +```bash +git fi -g +``` + +This is the command to reach for when: + +- You've force-pushed a feature branch and want `fi` to pick up the new commits +- A transient merge conflict has been resolved upstream +- You want to verify that the current set of branches still integrates cleanly + +Does not accept branch arguments. + +## Dead Branch Pruning + +During any merge operation, git-fi automatically detects branches that no longer exist on the remote. These "dead" branches are pruned from the `fi` branch list and a warning is printed: + +```text + * origin/deleted-branch (pruned — no longer exists on origin) +``` + +No manual intervention is needed. + +## Merged Branch Warnings + +Branches that have already been merged to the default branch (`main` or `master`) are flagged during the merge process: + +```text + * origin/already-merged (warning — already merged to main) +``` + +These branches are still included in `fi` but the warning helps teams identify stale entries that can be removed. + +## CI Mode + +When running inside a CI pipeline (`CI=true`), git-fi adjusts its behavior: + +- Bootstrap confirmation is skipped automatically +- Commit messages include pipeline context for traceability + +The typical CI use case is a post-build job that runs `git fi -g` after a successful feature branch build, keeping the integration branch continuously up to date. + +See [CI Integration](/ci-integration) for full details. + +## Command Dispatch + +See also `--debug` (`-d`) to watch git commands as they execute — useful for diagnosing unexpected merge behavior. + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +flowchart TD + A[Parse flags] --> B{Which action?} + B -- none --> L[list] + B -- -a --> ADD[add: append to branch list] + B -- -r --> REM[remove: subtract from branch list] + B -- -f --> FRC[force: replace branch list] + B -- -g --> AGN[again: keep branch list as-is] + ADD --> M[Merge Process] + REM --> M + FRC --> M + AGN --> M + L --> OUT[Print branch list] +``` + +All mutation commands feed into the same [Merge Process](/merge-process). The only difference is how the branch list is computed before merging begins. diff --git a/docs/ci-integration.md b/docs/ci-integration.md new file mode 100644 index 0000000..051dbbd --- /dev/null +++ b/docs/ci-integration.md @@ -0,0 +1,82 @@ +# CI Integration + +git-fi integrates with GitLab CI to show pipeline status alongside branch names and to provide context when running inside a pipeline. + +## GitLab CI Status + +Set the `GITLAB_ACCESS_TOKEN` environment variable to enable CI status display: + +```bash +export GITLAB_ACCESS_TOKEN="glpat-xxxxxxxxxxxxxxxxxxxx" +``` + +When set, `git fi` (list mode) shows the pipeline status of each branch: + +```text + * feature-auth OK + * feature-search RUN + * bugfix-nav FAIL +``` + +### Status Indicators + +| Indicator | Meaning | +|-----------|---------| +| `OK` | Pipeline succeeded | +| `FAIL` | Pipeline failed | +| `RUN` | Pipeline is running | +| `TIME` | Pipeline timed out | +| `MISS` | No pipeline found | +| `SKIP` | Pipeline was skipped | + +If the GitLab API is unreachable or returns an error, git-fi falls back to listing branches without status indicators. + +## Pipeline Context + +When git-fi runs inside a CI pipeline (`CI=true`), it adjusts its behavior: + +- Bootstrap confirmation is skipped (auto-creates `fi`) +- Commit messages include pipeline context: + +```text +Re-merge fi branch triggered by build 12345 due to commit on feature-auth. Was originally: --- ... + +(feature-auth, feature-search)@[a1b2c3d] +``` + +### CI Environment Variables + +| Variable | Purpose | +|----------|---------| +| `CI` | Detected as truthy to enable CI mode | +| `CI_PIPELINE_ID` | Included in commit message for traceability | +| `CI_COMMIT_REF_NAME` | Included in commit message for traceability | + +## Typical CI Workflow + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +flowchart LR + A[Push to feature branch] --> B[Feature branch pipeline] + B --> C{Build passes?} + C -- yes --> D[Post-build: git fi -g] + D --> E[fi branch pipeline] + E --> F[Deploy fi to staging] + C -- no --> G[Fix and push again] +``` + +1. Developer pushes to a feature branch +2. Feature branch CI pipeline runs tests +3. On success, a post-build job runs `git fi -g` to rebuild `fi` +4. The updated `fi` branch triggers its own pipeline +5. The `fi` pipeline deploys to a staging/candidate environment + +This gives teams a continuously updated integration environment that reflects all in-flight work. + +## Environment Variables + +| Variable | Purpose | +|----------|---------| +| `GITLAB_ACCESS_TOKEN` | Enable GitLab CI status in branch listings | +| `GIT_FI_NO_HINTS` | Suppress hint messages | +| `NO_COLOR` | Disable color output (respects [no-color.org](https://no-color.org) convention) | diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..4afd017 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,124 @@ +# Basic Commands + +The essentials: listing, adding, removing, and picking branches interactively. + +## Overview + +```bash +git fi [options] [...] +``` + +git-fi is invoked as a git subcommand from the repository root. + +## list (default) + +When invoked with no action flag, git-fi lists the branches currently merged into `fi`. + +```bash +git fi +``` + +```text + * feature-auth + * feature-search + * bugfix-nav +``` + +### Machine-readable output + +With `--bare` (`-b`), output is space-separated branch names with no formatting — suitable for piping: + +```bash +git fi -b | xargs -I {} echo "Branch: {}" +``` + +With `--json` (`-j`), output is a structured JSON object: + +```json +{ + "branches": ["feature-auth", "feature-search", "bugfix-nav"], + "ci": [ + { "branch": "feature-auth", "status": "OK", "url": "..." } + ] +} +``` + +The `ci` array is present only when `GITLAB_ACCESS_TOKEN` is set. `--json` is only valid with the `list` command. + +### Filtering + +Pass a pattern to filter the list: + +```bash +git fi feature +``` + +Shows only branches matching the filter. + +## add + +Append one or more branches to `fi`. + +```bash +git fi -a feature-auth feature-search +``` + +If no branch name is given, the current working branch is used: + +```bash +git fi -a +``` + +All specified branches must exist on the remote. The `origin/` prefix is optional — `feature-auth` and `origin/feature-auth` are equivalent. + +## remove + +Remove one or more branches from `fi`. + +```bash +git fi -r feature-auth +``` + +If no branch name is given, the current working branch is used. Removing a branch that isn't in `fi` is silently ignored. + +## select + +Open an interactive branch picker. Requires a TTY. + +```bash +git fi -s +``` + +When used alone, the picker shows all recent remote branches plus current `fi` branches. Current `fi` branches are pre-selected. Toggle branches on/off and confirm — git-fi computes the adds and removes automatically. + +When combined with `-a`: + +```bash +git fi -s -a +``` + +Shows only branches not already in `fi`. Select which to add. + +When combined with `-r`: + +```bash +git fi -s -r +``` + +Shows only branches currently in `fi`. Select which to remove. + +## Global Options + +| Flag | Long | Description | +|------|------|-------------| +| `-d` | `--debug` | Show git commands as they execute | +| `-b` | `--bare` | Machine-readable output (space-separated, no decoration) | +| `-j` | `--json` | Structured JSON output (list command only) | +| `-s` | `--select` | Interactive branch picker (requires TTY) | + +## Branch Name Resolution + +1. The `origin/` prefix is optional — `feature-auth` and `origin/feature-auth` are equivalent +2. When no branch is specified (for `-a`, `-r`), the current working branch is used +3. Adding or forcing requires branches to exist on the remote +4. Removing a non-existent branch is a no-op diff --git a/docs/home.md b/docs/home.md new file mode 100644 index 0000000..c70b78d --- /dev/null +++ b/docs/home.md @@ -0,0 +1,209 @@ +# git-fi + +A git plugin that maintains a temporary integration branch named `fi`. Merge multiple in-progress feature branches together to detect conflicts early and test features in collaboration — before they land on `main`. + +## The Problem + +Feature branches keep work isolated, but isolation is also the problem. Two features that each pass their own tests can still conflict when combined. You don't find out until one merges to `main` — and by then the other has diverged further. + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +gitGraph + commit id: "main" + branch feature-auth + commit id: "auth work" + checkout main + branch feature-search + commit id: "search work" + checkout main + merge feature-auth id: "merge auth" + merge feature-search id: "conflict!" type: HIGHLIGHT +``` + +Two developers work on separate features that both touch `routes.ts`. Auth merges first. Search tries to merge the next day and hits conflicts that could have been caught days earlier — if the branches had been tested together while both were still in flight. + +## The Solution + +git-fi creates a throwaway integration branch where work-in-progress meets early. The `fi` branch is ephemeral — rebuilt from scratch on every operation — so it never interferes with your real branches or with `main`. + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +gitGraph + commit id: "main" + branch fi + branch feature-auth + commit id: "auth work" + checkout fi + merge feature-auth id: "add auth to fi" + checkout main + branch feature-search + commit id: "search work" + checkout fi + merge feature-search id: "conflict caught early!" +``` + +Teams use `fi` to: +- **Test-drive** features together before they're ready to merge +- **Detect conflicts** between in-flight work before they reach `main` +- **Deploy combinations** of features to a staging environment for validation + +When your team has a finite number of pre-production environments — one staging server, one QA box — `fi` replaces the mutex. Instead of deploying one feature branch at a time while others wait, `fi` combines them so the environment serves all in-flight work simultaneously. + +## Quick Example + +```bash +git fi # see what's in fi +git fi -a my-feature # add your branch +git fi -r my-feature # remove it when done +git fi -g # rebuild fi with the same branches +``` + +## How git-fi Compares + +git-fi is not the only approach to integration pain. Here is how it relates to techniques you may already use. + +### vs. Traditional CI/CD + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +flowchart TD + subgraph traditional["Traditional CI"] + direction LR + T1[feature A] --> T2[test ✓] --> T3[merge to main ✓] + T4[feature B] --> T5[test ✓] --> T6[merge to main ✗] + T6 -.-> T4 + end + subgraph gitfi["With git-fi"] + direction LR + subgraph features[" "] + direction TB + F1[feature A] + F2[feature B] + end + features --> F3[fi] + F3 --> F4[test ✗] + F4 -.-> features + end + traditional ~~~ gitfi + style T6 fill:#5c1a1a,stroke:#FF5252,stroke-width:2px,color:#FF5252 + style F4 fill:#4a2800,stroke:#FF9800,stroke-width:2px,color:#FF9800 + style gitfi fill:#1a1a2e,stroke:#6F43D6,stroke-width:2px +``` + +With [traditional CI](https://martinfowler.com/articles/continuousIntegration.html), integration issues surface after merging to `main`. With git-fi, they surface before — while the work is still in progress and easier to fix. + +### vs. Merge Trains + +[GitLab merge trains](https://docs.gitlab.com/ee/ci/pipelines/merge_trains.html) and [GitHub merge queues](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue) serialize merges to `main` by testing each PR against the combined state of all PRs ahead of it in the queue. + +| | Merge Trains | git-fi | +|---|---|---| +| **Goal** | Safe merge to main | Early conflict detection | +| **Timing** | At merge time | During development | +| **Scope** | PRs ready to merge | Any in-flight branch | +| **Branch** | Temporary per-train | Single persistent `fi` | +| **Automation** | Fully automated | Developer-driven | + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +flowchart LR + subgraph "Merge Train" + direction LR + D1[Develop] --> R1[PR Ready] + R1 --> Q1[Enter Queue] + Q1 --> T1[Test in queue] + T1 --> C1{Conflict found} + end + subgraph "git-fi" + direction LR + D2[Develop] --> A2[Add to fi] + A2 --> T2[Test in fi] + T2 --> C2{Conflict found} + end +``` + +A PR passes all checks on its own branch. Another PR merges to `main`. The first PR now has an integration bug that only appears when both changes coexist. Merge trains catch this at merge time; git-fi catches it during development. They are complementary — git-fi for early feedback, merge trains for safe landing. + +### vs. Stacked PRs + +Tools like [Graphite](https://graphite.com/guides/stacked-diffs), [ghstack](https://github.com/ezyang/ghstack), and [spr](https://github.com/ejoffe/spr) manage chains of dependent PRs that build on each other. + +| | Stacked PRs | git-fi | +|---|---|---| +| **Relationship** | Linear dependency chain | Independent branches | +| **Conflict model** | Each PR against its parent | All branches merged together | +| **Use case** | Large features split into reviewable chunks | Multiple independent features tested together | + +**Stacked PRs:** + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +gitGraph + commit id: "main" + branch stacked-pr-1 + commit id: "part 1" + branch stacked-pr-2 + commit id: "part 2" + branch stacked-pr-3 + commit id: "part 3" +``` + +**git-fi:** + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +gitGraph + commit id: "main" + branch fi + branch feature-a + commit id: "work a" + checkout fi + merge feature-a + checkout main + branch feature-b + commit id: "work b" + checkout fi + merge feature-b + checkout main + branch feature-c + commit id: "work c" + checkout fi + merge feature-c +``` + +Stacked PRs solve "this PR is too big." git-fi solves "these PRs don't know about each other." + +### vs. Feature Flags + +[Feature flags](https://martinfowler.com/articles/feature-toggles.html) allow incomplete features to exist in `main` behind runtime toggles. + +| | Feature Flags | git-fi | +|---|---|---| +| **Isolation** | Runtime (deploy-time) | Branch-time | +| **Merge timing** | Merge early, toggle off | Merge late, test early via fi | +| **Complexity** | Flag management, cleanup | Branch management | +| **Risk** | Flag leaks, stale flags | Merge conflicts | + +Feature flags and git-fi address the same tension from opposite directions. If feature flags already handle your isolation needs, you may not need git-fi. + +### vs. Trunk-Based Development + +[Trunk-based development](https://trunkbaseddevelopment.com/) advocates short-lived branches (or no branches at all) with frequent merges to `main`. git-fi bridges the gap for teams that aren't ready to go fully trunk-based — providing early integration and a shared view of combined in-flight work while keeping `main` stable. If your branches are already short-lived enough, git-fi adds little value. + +## When git-fi Fits + +- You have a limited number of pre-production environments and multiple teams need to deploy to them concurrently +- Multiple developers work on features that touch overlapping code +- You deploy from an integration or staging branch before merging to `main` +- Your team uses feature branches but wants earlier integration feedback +- Merge trains or merge queues aren't available or are too heavyweight for your workflow + +A team has one staging environment and three features in flight. Without git-fi, staging is a mutex: one branch deploys, the others wait. With git-fi, all three merge into `fi`, deploy together, and get tested in parallel on the same environment. + +git-fi is less useful when you practice trunk-based development with very short-lived branches, when feature flags handle all your isolation needs, or when only one developer works on the codebase at a time. + +## Next Steps + +- [Quick Start](/quickstart) — install and run your first command +- [Basic Commands](/commands) — list, add, remove, and interactive select +- [Advanced Commands](/advanced) — force, again, pruning, and CI mode diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..8da343a --- /dev/null +++ b/docs/index.html @@ -0,0 +1,103 @@ + + + + + git-fi + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/merge-process.md b/docs/merge-process.md new file mode 100644 index 0000000..2da6d67 --- /dev/null +++ b/docs/merge-process.md @@ -0,0 +1,115 @@ +# Merge Process + +Every mutation command (`-a`, `-r`, `-f`, `-g`) triggers the same merge process. git-fi rebuilds the `fi` branch from scratch each time — it never amends or cherry-picks onto an existing `fi`. + +## Flow + +```mermaid +%%{ init: { 'look': 'handDrawn' } }%% +flowchart TD + A[Start merge] --> B[Assert clean state] + B --> C[Capture untracked files] + C --> D{fi exists?} + D -- no --> E[Bootstrap confirmation] + E --> F[Compute final branch list] + D -- yes --> F + F --> G[Prune dead branches] + G --> H[Warn about merged branches] + H --> I[Create fi from default branch] + I --> J{Merge each branch} + J -- success --> K[Commit and push fi] + J -- conflict --> L[Record failure and continue] + K --> M[Restore untracked files] + L --> M + M --> N[Return to original branch] + N --> O[Print summary] +``` + +## Step by Step + +### 1. Clean state + +git-fi asserts that the working tree has no uncommitted changes. This protects your work from being lost during branch switching. + +### 2. Untracked files + +Untracked files are captured before the merge starts. If the merge fails, git-fi prints `rm` commands to clean up any untracked files that were created during the process. + +### 3. Bootstrap confirmation + +The first time `fi` is created in a repository, git-fi asks for confirmation: + +```text +No fi branch detected. Create one? [y/n] +``` + +In CI mode (`CI=true`), this prompt is skipped and `fi` is created automatically. + +### 4. Branch list computation + +The final branch list depends on the command: + +| Command | Result | +|---------|--------| +| `-a` | Current branches + new branches | +| `-r` | Current branches - removed branches | +| `-f` | Only the specified branches | +| `-g` | Current branches (unchanged) | + +### 5. Dead branch pruning + +Branches that no longer exist on the remote are automatically removed from the list. git-fi warns when this happens. + +### 6. Merged branch warnings + +Branches that have already been merged to the default branch are flagged with a warning. They're still included in `fi` but the warning helps teams clean up stale entries. + +### 7. Merge execution + +git-fi creates a fresh `fi` branch from `origin/main` (or `origin/master`), then merges each branch sequentially. If a branch fails to merge: + +- The conflict is recorded +- The merge is aborted +- The remaining branches continue + +This means a single conflicting branch doesn't block the rest. + +### 8. Commit and push + +The resulting merge is committed with a message listing all included branches: + +```text +(feature-auth, feature-search, bugfix-nav)@[a1b2c3d] +``` + +The `fi` branch is then force-pushed to origin. + +### 9. Summary + +After completion, git-fi prints a summary: + +```text +== SUMMARY == + * feature-auth + * feature-search + * bugfix-nav +``` + +If any branches failed to merge, they're listed separately: + +```text +FAILED: + * feature-broken +``` + +## Conflict Handling + +When a branch conflicts during the merge: + +1. The merge is aborted (`git merge --abort`) +2. The branch is added to the failure list +3. Remaining branches are still merged +4. The final `fi` branch contains all successful merges +5. Failed branches are reported in the summary + +This non-blocking approach means one bad branch doesn't prevent the rest of the team from using `fi`. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..b5d7543 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,57 @@ +# Quick Start + +See the [README](https://github.com/gettyimages/git-fi#install) for installation instructions. + +## Your First Integration + +### 1. List branches in fi + +From any git repository: + +```bash +git fi +``` + +If `fi` doesn't exist yet, you'll see an empty list. + +### 2. Add a branch + +```bash +git fi -a my-feature +``` + +git-fi will: +1. Fetch from origin +2. Create (or rebuild) the `fi` branch from `main` +3. Merge `origin/my-feature` into it +4. Push `fi` to origin + +### 3. Add more branches + +```bash +git fi -a another-feature +``` + +Now `fi` contains both branches merged together. If they conflict, git-fi tells you immediately. + +### 4. Remove a branch + +```bash +git fi -r my-feature +``` + +The `fi` branch is rebuilt with only the remaining branches. + +### 5. Use the interactive picker + +```bash +git fi -s +``` + +Browse remote branches and select which ones to add or remove. + +## Next Steps + +- [Basic Commands](/commands) — list, add, remove, and select +- [Advanced Commands](/advanced) — force, again, pruning, and CI mode +- [Merge Process](/merge-process) — what happens under the hood diff --git a/docs/spec.md b/docs/spec.md new file mode 100644 index 0000000..26ac529 --- /dev/null +++ b/docs/spec.md @@ -0,0 +1 @@ +[SPEC.md](../SPEC.md ':include') diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a7de319 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,597 @@ +{ + "name": "git-fi", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "git-fi", + "version": "0.1.0", + "license": "MIT", + "bin": { + "git-fi": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..838728a --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "git-fi", + "version": "0.1.0", + "description": "Git plugin that maintains a temporary integration branch for early conflict detection", + "type": "module", + "bin": { + "git-fi": "./dist/index.js" + }, + "scripts": { + "start": "tsx src/index.ts", + "build": "tsc", + "prepare": "tsc", + "test": "echo 'no tests yet'" + }, + "files": [ + "dist" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/gettyimages/git-fi.git" + }, + "keywords": [ + "git", + "integration", + "branch", + "merge", + "ci" + ], + "engines": { + "node": ">=18" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + } +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..b7d647a --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,323 @@ +import type { Options, CIResult } from "./types.js"; +import { makeStyle, printTable, abort } from "./style.js"; +import { + git, + gitExitCode, + defaultBranch, + currentFiBranches, + resolveBranches, + allRemoteBranches, + remoteBranchesNoMergedSince, + ensureFetched, + isInteractive, +} from "./git.js"; +import { fetchGitlabCI, printCITable, detectGitlabProject, fetchFiPipeline, STATUS_EMOJI } from "./gitlab.js"; +import { mergeProcess } from "./merge.js"; +import { pickBranches } from "./ui.js"; + +async function fetchPickerCI( + branches: string[], + opts: Options +): Promise | undefined> { + if (!process.env.GITLAB_ACCESS_TOKEN) return undefined; + const results = await fetchGitlabCI(branches, opts); + const map = new Map(); + for (const r of results) map.set(r.branch, r); + return map; +} + +export async function cmdList( + opts: Options, + filterPattern?: string, + pushedSha?: string | null +): Promise { + const s = makeStyle(opts); + + const fiExists = git(["rev-parse", "--verify", "origin/fi"], { + allowFailure: true, + }); + if (fiExists === null) { + abort(`there is no ${s.fi()} branch for this project.`, opts); + } + + const defBranch = defaultBranch(); + let branches = currentFiBranches(defBranch); + + if (filterPattern !== undefined) { + const re = new RegExp(filterPattern); + branches = branches.filter((b) => + re.test(b.replace(/^origin\//, "")) + ); + if (branches.length === 0) { + process.stderr.write(`no branches in fi match '${filterPattern}'\n`); + process.exit(1); + } + } + + const shortNames = branches.map((b) => b.replace(/^origin\//, "")); + + if (opts.bare) { + process.stdout.write(shortNames.join(" ") + "\n"); + return; + } + + if (opts.json) { + const obj: Record = { + command: "list", + branches: shortNames, + }; + if (process.env.GITLAB_ACCESS_TOKEN) { + const ci = await fetchGitlabCI(branches, opts); + obj.ci = ci.map((r) => ({ + branch: r.branch.replace(/^origin\//, ""), + status: r.status, + author: r.author, + date: r.date, + branchMissing: r.branchMissing, + })); + } + process.stdout.write(JSON.stringify(obj, null, 2) + "\n"); + return; + } + + const gitlab = detectGitlabProject(); + + if (process.env.GITLAB_ACCESS_TOKEN) { + const ci = await fetchGitlabCI(branches, opts); + printCITable(ci, opts, gitlab); + + if (gitlab) { + const pipeline = fetchFiPipeline(opts, gitlab, pushedSha ?? undefined); + if (pipeline) { + const emoji = STATUS_EMOJI[pipeline.status] || ""; + const idText = s.link(s.dim(`#${pipeline.id}`), pipeline.url); + process.stdout.write(`fi: ${idText} ${emoji}\n`); + } + } + } else { + const rows = shortNames.map((name) => { + const label = gitlab + ? s.link(s.cyan(name), `https://${gitlab.host}/${gitlab.project}/-/tree/${encodeURIComponent(name)}`) + : s.cyan(name); + return [label]; + }); + printTable(["Branch"], rows, opts); + } + + process.stdout.write("\n"); + + if ( + !process.env.GITLAB_ACCESS_TOKEN && + !process.env.GIT_FI_NO_HINTS && + !opts.bare && + !opts.json + ) { + process.stdout.write( + "For enhanced CI status, export GITLAB_ACCESS_TOKEN. To suppress this hint, export GIT_FI_NO_HINTS.\n" + ); + } +} + +export async function cmdAdd( + branches: string[], + opts: Options +): Promise { + const s = makeStyle(opts); + const defBranch = defaultBranch(); + let resolved: string[]; + + if (opts.select && isInteractive(opts)) { + const existing = currentFiBranches(defBranch); + const existingSet = new Set(existing); + const available = allRemoteBranches(defBranch).filter( + (b) => !existingSet.has(b) + ); + const ciData = await fetchPickerCI(available, opts); + const picked = await pickBranches( + available, + `Select branches to add to ${s.fi()}:`, + [], + ciData + ); + if (picked === null) { + process.stderr.write("Cancelled.\n"); + process.exit(0); + } + if (picked.length === 0) { + process.stderr.write("No branches selected.\n"); + process.exit(0); + } + resolved = picked; + } else { + resolved = resolveBranches(branches, "add", opts); + } + + const existing = currentFiBranches(defBranch); + const combined = [...new Set([...existing, ...resolved])]; + + const sha = await mergeProcess("add", resolved, combined, opts); + await cmdList(opts, undefined, sha); +} + +export async function cmdRemove( + branches: string[], + opts: Options +): Promise { + const s = makeStyle(opts); + const defBranch = defaultBranch(); + let resolved: string[]; + + if (opts.select && isInteractive(opts)) { + const existing = currentFiBranches(defBranch); + const ciData = await fetchPickerCI(existing, opts); + const picked = await pickBranches( + existing, + `Select branches to remove from ${s.fi()}:`, + [], + ciData + ); + if (picked === null) { + process.stderr.write("Cancelled.\n"); + process.exit(0); + } + if (picked.length === 0) { + process.stderr.write("No branches selected.\n"); + process.exit(0); + } + resolved = picked; + } else { + resolved = resolveBranches(branches, "remove", opts); + } + + const existing = currentFiBranches(defBranch); + const removeSet = new Set(resolved); + const combined = existing.filter((b) => !removeSet.has(b)); + + const sha = await mergeProcess("remove", resolved, combined, opts); + await cmdList(opts, undefined, sha); +} + +export async function cmdForce( + branches: string[], + opts: Options +): Promise { + const resolved = + branches.length > 0 ? resolveBranches(branches, "force", opts) : []; + + const sha = await mergeProcess("force", resolved, resolved, opts); + await cmdList(opts, undefined, sha); +} + +export async function cmdAgain( + branches: string[], + opts: Options +): Promise { + if (branches.length > 0) { + abort("--again does not accept branch names", opts); + } + + const defBranch = defaultBranch(); + const existing = currentFiBranches(defBranch); + + const sha = await mergeProcess("again", [], existing, opts); + await cmdList(opts, undefined, sha); +} + +export async function cmdPrune( + branches: string[], + opts: Options +): Promise { + if (branches.length > 0) { + abort("--prune does not accept branch names", opts); + } + + const defBranch = defaultBranch(); + const existing = currentFiBranches(defBranch); + + const dead = existing.filter( + (b) => git(["rev-parse", "--verify", b], { allowFailure: true }) === null + ); + const merged = existing.filter( + (b) => + dead.indexOf(b) === -1 && + gitExitCode(["merge-base", "--is-ancestor", b, `origin/${defBranch}`]) === 0 + ); + + if (dead.length === 0 && merged.length === 0) { + process.stdout.write("Nothing to prune.\n"); + return; + } + + const sha = await mergeProcess("prune", [], existing, opts); + await cmdList(opts, undefined, sha); +} + +export async function cmdAbort( + branches: string[], + opts: Options +): Promise { + const s = makeStyle(opts); + if (branches.length > 0) { + abort("--abort does not accept branch names", opts); + } + + await ensureFetched(opts); + + if (gitExitCode(["rev-parse", "--verify", "origin/fi"]) !== 0) { + abort("origin/fi does not exist — nothing to re-pull", opts); + } + + git(["fetch", "--quiet", "origin", "fi"], { debug: opts.debug }); + git(["update-ref", "refs/remotes/origin/fi", "FETCH_HEAD"], { debug: opts.debug }); + + process.stderr.write(`${s.bold("Re-pulled")} ${s.fi()} from origin.\n`); +} + +export async function cmdSelect(opts: Options): Promise { + const s = makeStyle(opts); + const defBranch = defaultBranch(); + const existing = currentFiBranches(defBranch); + const existingSet = new Set(existing); + + const unmerged = remoteBranchesNoMergedSince(defBranch, 3); + + const allBranches = [ + ...existing, + ...unmerged.filter((b) => !existingSet.has(b)), + ]; + + if (allBranches.length === 0) { + process.stderr.write("No candidate branches found.\n"); + process.exit(0); + } + + const ciData = await fetchPickerCI(allBranches, opts); + const selected = await pickBranches( + allBranches, + `Toggle branches for ${s.fi()} (current fi branches are pre-selected):`, + existing, + ciData + ); + + if (selected === null) { + process.stderr.write("Cancelled.\n"); + process.exit(0); + } + + const selectedSet = new Set(selected); + const toAdd = selected.filter((b) => !existingSet.has(b)); + const toRemove = existing.filter((b) => !selectedSet.has(b)); + + if (toAdd.length === 0 && toRemove.length === 0) { + process.stderr.write("No changes.\n"); + process.exit(0); + } + + const combined = existing + .filter((b) => selectedSet.has(b)) + .concat(toAdd); + + const action = toRemove.length > 0 && toAdd.length === 0 ? "remove" : "add"; + const sha = await mergeProcess(action, toAdd.length > 0 ? toAdd : toRemove, combined, opts); + await cmdList(opts, undefined, sha); +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..84270d6 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,253 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { basename } from "node:path"; +import type { Options } from "./types.js"; +import { abort, makeStyle, bulletList, createSpinner } from "./style.js"; + +let fetchDone = false; + +interface GitOpts { + quiet?: boolean; + debug?: boolean; + showErrors?: boolean; + allowFailure?: boolean; +} + +export function git( + args: string[], + { + quiet = true, + debug = false, + showErrors = false, + allowFailure = false, + }: GitOpts = {} +): string | null { + const stderrDest = debug || showErrors ? "pipe" : "ignore"; + if (debug) { + process.stderr.write(`+ git ${args.join(" ")}\n`); + } + try { + const out = execFileSync("git", args, { + encoding: "utf-8", + stdio: ["pipe", "pipe", stderrDest], + maxBuffer: 50 * 1024 * 1024, + }); + return out.trimEnd(); + } catch (err) { + if (allowFailure) return null; + throw err; + } +} + +export function gitLines(args: string[], gitOpts?: GitOpts): string[] { + const out = git(args, gitOpts); + if (out === null || out === "") return []; + return out.split("\n"); +} + +export function gitExitCode(args: string[], gitOpts: GitOpts = {}): number { + try { + git(args, gitOpts); + return 0; + } catch (err: unknown) { + return (err as { status?: number }).status ?? 1; + } +} + +export function preflightChecks(opts: Options): void { + if (!existsSync(".git")) { + abort("No .git directory found.", opts); + } + + const verStr = git(["--version"]) ?? ""; + const match = verStr.match(/(\d+\.\d+\.\d+)/); + if (match) { + const parts = match[1].split(".").map(Number); + const ver = parts[0] * 10000 + parts[1] * 100 + parts[2]; + if (ver < 25000) { + abort( + `git version ${match[1]} is too old, please upgrade to at least 2.50.0.`, + opts + ); + } + } + + const pushDefault = git(["config", "push.default"], { allowFailure: true }); + if (pushDefault === "upstream" || pushDefault === "tracking") { + abort( + "Your default git push config is set to a hazardous option.", + opts + ); + } +} + +export async function ensureFetched(opts: Options): Promise { + if (fetchDone) return; + fetchDone = true; + const spin = createSpinner("Fetching from origin...", opts); + try { + const fetchArgs = ["fetch", "--prune", "origin"]; + if (!opts.debug) fetchArgs.splice(1, 0, "--quiet"); + git(fetchArgs, { debug: opts.debug }); + } finally { + spin.stop(); + } +} + +export function defaultBranch(): string { + const ref = git(["symbolic-ref", "refs/remotes/origin/HEAD"], { + allowFailure: true, + }); + if (ref !== null) return basename(ref); + for (const candidate of ["main", "master"]) { + if ( + git(["rev-parse", "--verify", `origin/${candidate}`], { + allowFailure: true, + }) !== null + ) { + return candidate; + } + } + return "main"; +} + +export type CommitFormat = "brief" | "legacy"; + +export function detectCommitFormat(commitMsg: string): CommitFormat { + if (/Merge remote-tracking branch(es)? '/.test(commitMsg)) return "legacy"; + return "brief"; +} + +export function parseBranchList(commitMsg: string, defBranch: string): string[] { + if (detectCommitFormat(commitMsg) === "legacy") { + const branches: string[] = []; + const re = /'origin\/([^']+)'/g; + let m; + while ((m = re.exec(commitMsg)) !== null) { + const name = `origin/${m[1]}`; + if (name !== `origin/${defBranch}` && name !== "origin/fi") branches.push(name); + } + return [...new Set(branches)]; + } + + const match = commitMsg.match(/^\(([^)]+)\)@\[/m); + if (match) { + return [ + ...new Set( + match[1] + .split(",") + .map((b) => `origin/${b.trim()}`) + .filter((b) => b !== `origin/${defBranch}`) + ), + ]; + } + if (/^@\[[0-9a-f]+\]/m.test(commitMsg)) { + return []; + } + return []; +} + +export function currentFiBranches(defBranch: string): string[] { + const msg = git(["log", "-1", "--format=%B", "origin/fi"], { + allowFailure: true, + }); + if (msg === null) return []; + return parseBranchList(msg, defBranch); +} + +export function resolveBranchName(name: string): string { + if (!name.startsWith("origin/")) return `origin/${name}`; + return name; +} + +export function currentBranchName(): string | null { + return git(["symbolic-ref", "--short", "HEAD"], { allowFailure: true }); +} + +export function resolveBranches( + names: string[], + action: string, + opts: Options +): string[] { + let resolved = names.map(resolveBranchName); + + if (resolved.length === 0 && (action === "add" || action === "remove")) { + const cur = currentBranchName(); + if (!cur || ["main", "master", "fi", "HEAD"].includes(cur)) { + abort("No branch was specified.", opts); + } + resolved = [resolveBranchName(cur)]; + } + + if (action === "add" || action === "force") { + const missing: string[] = []; + for (const b of resolved) { + if (git(["rev-parse", "--verify", b], { allowFailure: true }) === null) { + missing.push(b); + } + } + if (missing.length > 0) { + const s = makeStyle(opts); + process.stderr.write( + `${s.redBold("the following branches do not exist on origin:")}\n` + ); + process.stderr.write(bulletList(missing, opts)); + process.exit(1); + } + } + + return resolved; +} + +export function allRemoteBranches(defBranch: string): string[] { + const lines = gitLines([ + "branch", + "-r", + "--format=%(refname:short)", + ]); + return lines.filter( + (b) => + !b.includes("->") && + b !== "origin/HEAD" && + b !== "origin/fi" && + b !== `origin/${defBranch}` + ); +} + +export function remoteBranchesNoMergedSince( + defBranch: string, + sinceMonths: number = 3 +): string[] { + const since = new Date(); + since.setMonth(since.getMonth() - sinceMonths); + const sinceStr = since.toISOString().slice(0, 10); + + const lines = gitLines([ + "branch", + "-r", + "--no-merged", `origin/${defBranch}`, + "--sort=-committerdate", + "--format=%(refname:short)", + ]); + + const candidates = lines.filter( + (b) => + !b.includes("->") && + b !== "origin/HEAD" && + b !== "origin/fi" && + b !== `origin/${defBranch}` + ); + + return candidates.filter((b) => { + const date = git(["log", "-1", "--format=%ci", b], { allowFailure: true }); + if (!date) return false; + return date.slice(0, 10) >= sinceStr; + }); +} + +export function isInteractive(_opts: Options): boolean { + return ( + process.stdin.isTTY === true && + process.stdout.isTTY === true + ); +} diff --git a/src/gitlab.ts b/src/gitlab.ts new file mode 100644 index 0000000..dad6863 --- /dev/null +++ b/src/gitlab.ts @@ -0,0 +1,204 @@ +import { execFileSync } from "node:child_process"; +import type { Options, CIResult } from "./types.js"; +import { makeStyle, createSpinner, printTable, abort } from "./style.js"; +import { git } from "./git.js"; + +export const STATUS_EMOJI: Record = { + success: "\u2705", + failed: "\u274C", + timeout: "\u23F0", + running: "\u23F3", + pending: "\u23F3", + missing: "\u2796", + skipped: "\u23ED\uFE0F", +}; + +export function detectGitlabProject(): { host: string; project: string } | null { + const url = git(["remote", "get-url", "origin"], { allowFailure: true }); + if (!url) return null; + + let m = url.match(/@([^:]+):(.+?)(?:\.git)?$/); + if (m) return { host: m[1], project: m[2] }; + + m = url.match(/https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/); + if (m) return { host: m[1], project: m[2] }; + + return null; +} + +export async function fetchGitlabCI( + branches: string[], + opts: Options +): Promise { + const token = process.env.GITLAB_ACCESS_TOKEN; + if (!token) { + abort("GITLAB_ACCESS_TOKEN is set but empty", opts); + } + + const proj = detectGitlabProject(); + if (!proj) { + abort("Could not detect GitLab project from origin URL", opts); + } + + const spin = createSpinner("Fetching CI status...", opts); + const results: CIResult[] = []; + const encodedProject = encodeURIComponent(proj.project); + try { + for (const branch of branches) { + const ref = branch.replace(/^origin\//, ""); + const encodedRef = encodeURIComponent(ref); + const apiUrl = `https://${proj.host}/api/v4/projects/${encodedProject}/pipelines?ref=${encodedRef}&per_page=1`; + + const response = execFileSync( + "curl", + ["-s", "-w", "\n%{http_code}", "-H", `PRIVATE-TOKEN: ${token}`, apiUrl], + { encoding: "utf-8", timeout: 10000 } + ); + + const lastNewline = response.lastIndexOf("\n"); + const httpCode = parseInt(response.slice(lastNewline + 1), 10); + const body = response.slice(0, lastNewline); + + if (httpCode === 404) { + results.push({ branch, status: "missing", pipelineId: "", author: "", date: "", branchMissing: true }); + continue; + } + if (httpCode < 200 || httpCode >= 300) { + abort( + `GitLab API returned HTTP ${httpCode} for branch '${ref}': ${body}\n\nTo use git-fi without CI status, unset GITLAB_ACCESS_TOKEN and try again.`, + opts + ); + } + + const pipelines = JSON.parse(body); + if (Array.isArray(pipelines) && pipelines.length > 0) { + const p = pipelines[0]; + const commitUrl = `https://${proj.host}/api/v4/projects/${encodedProject}/repository/commits/${encodedRef}`; + let author = ""; + let date = ""; + + const commitResp = execFileSync( + "curl", + ["-s", "-w", "\n%{http_code}", "-H", `PRIVATE-TOKEN: ${token}`, commitUrl], + { encoding: "utf-8", timeout: 10000 } + ); + const commitLastNl = commitResp.lastIndexOf("\n"); + const commitHttpCode = parseInt(commitResp.slice(commitLastNl + 1), 10); + const commitBody = commitResp.slice(0, commitLastNl); + + let branchMissing = false; + if (commitHttpCode >= 200 && commitHttpCode < 300) { + const commit = JSON.parse(commitBody); + author = commit.author_name || ""; + date = commit.committed_date + ? commit.committed_date.slice(0, 10) + : ""; + } else if (commitHttpCode === 404) { + branchMissing = true; + } + + results.push({ + branch, + status: p.status || "missing", + pipelineId: String(p.id || ""), + author, + date, + branchMissing, + }); + } else { + results.push({ branch, status: "missing", pipelineId: "", author: "", date: "", branchMissing: false }); + } + } + return results; + } catch (err) { + spin.stop(); + const msg = err instanceof Error ? err.message : String(err); + abort( + `GitLab API request failed: ${msg}\n\nTo use git-fi without CI status, unset GITLAB_ACCESS_TOKEN and try again.`, + opts + ); + } finally { + spin.stop(); + } +} + +export interface FiPipelineInfo { + url: string; + id: string; + status: string; +} + +export function fetchFiPipeline( + opts: Options, + gitlab: { host: string; project: string }, + pushedSha?: string +): FiPipelineInfo | null { + const token = process.env.GITLAB_ACCESS_TOKEN; + if (!token) return null; + + const encodedProject = encodeURIComponent(gitlab.project); + const maxAttempts = pushedSha ? 4 : 1; + const delayMs = 1500; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (attempt > 0) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs); + } + + try { + let apiUrl = `https://${gitlab.host}/api/v4/projects/${encodedProject}/pipelines?ref=fi&per_page=1`; + if (pushedSha) { + apiUrl += `&sha=${pushedSha}`; + } + + const response = execFileSync( + "curl", + ["-s", "-f", "-H", `PRIVATE-TOKEN: ${token}`, apiUrl], + { encoding: "utf-8", timeout: 10000 } + ); + const pipelines = JSON.parse(response); + if (Array.isArray(pipelines) && pipelines.length > 0) { + const p = pipelines[0]; + return { + url: `https://${gitlab.host}/${gitlab.project}/-/pipelines/${p.id}`, + id: String(p.id), + status: p.status || "unknown", + }; + } + } catch (err) { + if (opts.debug) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`Pipeline lookup failed: ${msg}\n`); + } + } + } + + return null; +} + +export function printCITable( + ciResults: CIResult[], + opts: Options, + gitlab?: { host: string; project: string } | null +): void { + const s = makeStyle(opts); + const headers = ["Branch", "Date", "Author", "Pipeline"]; + const rows = ciResults.map((item) => { + const branchName = item.branch.replace(/^origin\//, ""); + const nameText = item.branchMissing + ? s.yellow(`${branchName} (deleted)`) + : gitlab + ? s.link( + s.cyan(branchName), + `https://${gitlab.host}/${gitlab.project}/-/tree/${encodeURIComponent(branchName)}` + ) + : s.cyan(branchName); + const branchLabel = nameText; + const emoji = STATUS_EMOJI[item.status] || STATUS_EMOJI.missing; + const pipeline = item.pipelineId + ? `${gitlab ? s.link(item.pipelineId, `https://${gitlab.host}/${gitlab.project}/-/pipelines/${item.pipelineId}`) : item.pipelineId} ${emoji}` + : emoji; + return [branchLabel, item.date, item.author, pipeline]; + }); + printTable(headers, rows, opts); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ed91de6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,185 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import type { Options } from "./types.js"; +import { abort } from "./style.js"; +import { preflightChecks, ensureFetched } from "./git.js"; +import { cmdList, cmdAdd, cmdRemove, cmdForce, cmdAgain, cmdPrune, cmdAbort, cmdSelect } from "./commands.js"; + +const require = createRequire(import.meta.url); +const { version } = require("../package.json"); + +function parseArgs(argv: string[]) { + const opts: Options = { + debug: false, + bare: false, + json: false, + select: false, + }; + let action: string | null = null; + let filterPattern: string | undefined; + const branches: string[] = []; + + for (const arg of argv) { + switch (arg) { + case "--debug": + case "-d": + opts.debug = true; + break; + case "--bare": + case "-b": + opts.bare = true; + break; + case "--json": + case "-j": + opts.json = true; + break; + case "--select": + case "-s": + opts.select = true; + break; + case "--version": + case "-V": + process.stdout.write(`git-fi ${version}\n`); + process.exit(0); + case "--help": + case "-h": + process.stdout.write( + `Usage: git fi [options] [...]\n` + + `\n` + + `Maintain a temporary integration branch for early conflict detection.\n` + + `\n` + + `Actions:\n` + + ` -a, --add Add branch(es) to fi\n` + + ` -r, --remove Remove branch(es) from fi\n` + + ` -f, --force Replace fi contents with only the given branch(es)\n` + + ` -g, --again Re-merge all branches currently in fi\n` + + ` -p, --prune Remove dead/already-merged branches from fi\n` + + ` -A, --abort Re-pull fi from origin\n` + + `\n` + + `Options:\n` + + ` -d, --debug Print git commands as they execute\n` + + ` -b, --bare Machine-readable output (space-separated branch names)\n` + + ` -j, --json Structured JSON output for list\n` + + ` -s, --select Interactive branch picker\n` + + ` -V, --version Print version and exit\n` + + ` -h, --help Show this help\n` + + `\n` + + `Full documentation: https://github.com/gettyimages/git-fi/wiki\n` + ); + process.exit(0); + case "--add": + case "-a": + if (action) abort(`Cannot combine --${action} with ${arg}`, opts); + action = "add"; + break; + case "--remove": + case "-r": + if (action) abort(`Cannot combine --${action} with ${arg}`, opts); + action = "remove"; + break; + case "--force": + case "-f": + if (action) abort(`Cannot combine --${action} with ${arg}`, opts); + action = "force"; + break; + case "--again": + case "-g": + if (action) abort(`Cannot combine --${action} with ${arg}`, opts); + action = "again"; + break; + case "--prune": + case "-p": + if (action) abort(`Cannot combine --${action} with ${arg}`, opts); + action = "prune"; + break; + case "--abort": + case "-A": + if (action) abort(`Cannot combine --${action} with ${arg}`, opts); + action = "abort"; + break; + default: + if (arg.startsWith("-")) { + abort(`Unknown option: ${arg}`, opts); + } + branches.push(arg); + break; + } + } + + if (!action && branches.length === 0 && !opts.select) action = "list"; + if (!action && branches.length > 0) { + if (branches.length > 1) { + abort("list filter accepts exactly one pattern", opts); + } + action = "list"; + filterPattern = branches[0]; + } + + if (opts.select && !action) { + action = "select"; + } + + if (opts.select && action !== "add" && action !== "remove" && action !== "select") { + abort("--select is only valid with --add or --remove", opts); + } + + if (opts.select && (!process.stdin.isTTY || !process.stdout.isTTY)) { + abort("--select requires an interactive terminal", opts); + } + + if (opts.json && action !== "list") { + abort("--json is only valid with the list command", opts); + } + + if (opts.bare && action !== "list") { + abort("--bare is only valid with the list command", opts); + } + + return { opts, action: action!, branches, filterPattern }; +} + +async function main() { + const argv = process.argv.slice(2); + const { opts, action, branches, filterPattern } = parseArgs(argv); + + preflightChecks(opts); + + if (action !== "list") { + await ensureFetched(opts); + } + + switch (action) { + case "list": + await ensureFetched(opts); + await cmdList(opts, filterPattern); + break; + case "add": + await cmdAdd(branches, opts); + break; + case "remove": + await cmdRemove(branches, opts); + break; + case "force": + await cmdForce(branches, opts); + break; + case "again": + await cmdAgain(branches, opts); + break; + case "prune": + await cmdPrune(branches, opts); + break; + case "abort": + await cmdAbort(branches, opts); + break; + case "select": + await cmdSelect(opts); + break; + default: + abort(`Unknown action: ${action}`, opts); + } +} + +main().catch((err: Error) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); +}); diff --git a/src/merge.ts b/src/merge.ts new file mode 100644 index 0000000..9a430ff --- /dev/null +++ b/src/merge.ts @@ -0,0 +1,447 @@ +import type { Options } from "./types.js"; +import { + makeStyle, + bulletList, + createSpinner, + abort, +} from "./style.js"; +import { + git, + gitLines, + gitExitCode, + ensureFetched, + defaultBranch, + currentBranchName, + detectCommitFormat, + type CommitFormat, +} from "./git.js"; +import { confirm } from "./ui.js"; +import { detectGitlabProject } from "./gitlab.js"; + +function buildLegacyMessage(branches: string[]): string { + const shortNames = branches.map((b) => b.replace(/^origin\//, "")); + if (shortNames.length === 0) { + return "Merge remote-tracking branch into fi"; + } + const quoted = shortNames.map((b) => `'origin/${b}'`); + if (quoted.length === 1) { + return `Merge remote-tracking branch ${quoted[0]} into fi`; + } + const last = quoted.pop()!; + return `Merge remote-tracking branches ${quoted.join(", ")} and ${last} into fi`; +} + +function buildBriefSignature(branches: string[], defBranch: string): string { + const baseHash = git(["rev-parse", "--short", `origin/${defBranch}`])!; + const shortNames = branches.map((b) => b.replace(/^origin\//, "")); + if (shortNames.length === 0) return `@[${baseHash}]`; + return `(${shortNames.join(", ")})@[${baseHash}]`; +} + +function buildCommitMessage( + branches: string[], + defBranch: string, + format: CommitFormat +): string { + if (process.env.CI) { + const pipelineId = process.env.CI_PIPELINE_ID || "unknown"; + const refName = process.env.CI_COMMIT_REF_NAME || "unknown"; + const previousMsg = + git(["log", "-1", "--format=%B", "origin/fi"], { allowFailure: true }) || + ""; + const signature = format === "legacy" + ? buildLegacyMessage(branches) + : buildBriefSignature(branches, defBranch); + const preamble = `Re-merge fi branch triggered by build ${pipelineId} due to commit on ${refName}. Was originally: --- ${previousMsg.trim()}`; + return `${preamble}\n\n${signature}`; + } + + if (format === "legacy") return buildLegacyMessage(branches); + return buildBriefSignature(branches, defBranch); +} + +const ACTION_INITIAL: Record = { + add: "new", + remove: "removing", + force: "replacing", + again: "re-merging", + prune: "pruning", +}; + +const ACTION_DONE: Record = { + add: "added", + remove: "removed", + force: "replaced", + again: "re-merged", + prune: "pruned", +}; + +export async function mergeProcess( + action: string, + actionBranches: string[], + allBranches: string[], + opts: Options +): Promise { + const s = makeStyle(opts); + const defBranch = defaultBranch(); + const gitlab = detectGitlabProject(); + const initialVerb = ACTION_INITIAL[action] || action; + const doneVerb = ACTION_DONE[action] || action; + const actionSet = new Set(actionBranches); + const tty = process.stdout.isTTY === true; + + const fiRefs = gitLines([ + "for-each-ref", + "--format=%(refname)", + "refs/remotes/origin/fi", + ]); + if (fiRefs.length > 1) { + abort("There is more than one origin/fi!", opts); + } + + const statusOut = git(["status", "--porcelain"]); + if (statusOut && statusOut.length > 0) { + abort("Your index is dirty", opts); + } + + const untrackedBefore = new Set( + gitLines(["ls-files", "--other", "--exclude-standard"]) + ); + + await ensureFetched(opts); + + const fiExistsAfterFetch = git(["rev-parse", "--verify", "origin/fi"], { + allowFailure: true, + }); + + let commitFormat: CommitFormat = "brief"; + if (fiExistsAfterFetch !== null) { + const existingMsg = git(["log", "-1", "--format=%B", "origin/fi"], { + allowFailure: true, + }); + if (existingMsg) { + commitFormat = detectCommitFormat(existingMsg); + } + } + + if (fiExistsAfterFetch === null) { + const repoPath = process.cwd(); + const remoteUrl = + git(["remote", "get-url", "origin"], { allowFailure: true }) || repoPath; + + const confirmed = await confirm( + `Bootstrap ${repoPath} with ${s.fi()} capability?`, + remoteUrl + ); + if (!confirmed) { + process.exit(1); + } + } + + // Filter dead and merged branches + const deadBranches: string[] = []; + const liveBranches: string[] = []; + for (const b of allBranches) { + if (git(["rev-parse", "--verify", b], { allowFailure: true }) === null) { + deadBranches.push(b); + } else { + liveBranches.push(b); + } + } + if (deadBranches.length > 0) { + process.stderr.write( + `${s.yellow("Ignoring branches that no longer exist:")}\n` + ); + for (const b of deadBranches) { + process.stderr.write( + ` ${s.yellow(b.replace(/^origin\//, ""))}\n` + ); + } + } + + const mergeable: string[] = []; + for (const b of liveBranches) { + const isMerged = gitExitCode([ + "merge-base", + "--is-ancestor", + b, + `origin/${defBranch}`, + ]); + if (isMerged === 0) { + process.stderr.write( + `${s.yellow(`${b.replace(/^origin\//, "")} already in ${defBranch}`)}\n` + ); + } else { + mergeable.push(b); + } + } + + // Build compact display + interface AnnotationInfo { + lineIndex: number; + branch: string; + baseLine: string; + } + const displayLines: string[] = []; + const annotations: AnnotationInfo[] = []; + + for (const b of mergeable) { + const name = b.replace(/^origin\//, ""); + const label = gitlab + ? s.link( + s.cyan(name), + `https://${gitlab.host}/${gitlab.project}/-/tree/${encodeURIComponent(name)}` + ) + : s.cyan(name); + + if (action === "add" && actionSet.has(b)) { + const baseLine = ` ${s.dim("*")} ${label}`; + displayLines.push(`${baseLine} ${s.dim("<- " + initialVerb)}`); + annotations.push({ lineIndex: displayLines.length - 1, branch: b, baseLine }); + } else { + displayLines.push(` ${s.dim("*")} ${label}`); + } + } + + if (action === "remove") { + for (const b of actionBranches) { + const name = b.replace(/^origin\//, ""); + const baseLine = ` ${s.dim(name)}`; + displayLines.push(`${baseLine} ${s.dim("<- " + initialVerb)}`); + annotations.push({ lineIndex: displayLines.length - 1, branch: b, baseLine }); + } + } + + if (["again", "force", "prune"].includes(action) || annotations.length === 0) { + const baseLine = ""; + displayLines.push(`${s.dim("<- " + initialVerb)}`); + annotations.push({ lineIndex: displayLines.length - 1, branch: "", baseLine }); + } + + process.stdout.write(`${s.fi()}:\n`); + for (const line of displayLines) { + process.stdout.write(line + "\n"); + } + + // Cursor helpers for inline progress + function rewriteAnnotation(ann: AnnotationInfo, content: string) { + const linesUp = displayLines.length - ann.lineIndex; + process.stdout.write( + `\x1b[${linesUp}A\r\x1b[2K${content}\x1b[${linesUp}B\r` + ); + } + + function updateAnnotation(ann: AnnotationInfo, status: string) { + if (!tty) return; + const prefix = ann.baseLine ? `${ann.baseLine} ` : ""; + rewriteAnnotation(ann, `${prefix}${s.dim("<- " + status)}`); + } + + function updateLastAnnotation(status: string) { + if (!tty) return; + const lastAnn = annotations[annotations.length - 1]; + if (!lastAnn) return; + updateAnnotation(lastAnn, status); + } + + function finalizeDone() { + if (!tty || annotations.length === 0) { + process.stderr.write(`${s.greenBold("Done!")}\n`); + return; + } + for (const ann of annotations) { + let highlighted: string; + if (ann.branch) { + const name = ann.branch.replace(/^origin\//, ""); + if (action === "remove") { + highlighted = ` ${s.dim(name)} ${s.dim("<-")} ${s.greenBold(doneVerb)}`; + } else { + const label = gitlab + ? s.link( + s.green(name), + `https://${gitlab.host}/${gitlab.project}/-/tree/${encodeURIComponent(name)}` + ) + : s.green(name); + highlighted = ` ${s.dim("*")} ${label} ${s.dim("<-")} ${s.greenBold(doneVerb)}`; + } + } else { + highlighted = `${s.dim("<-")} ${s.greenBold(doneVerb)}`; + } + rewriteAnnotation(ann, highlighted); + } + } + + function finalizeError() { + if (!tty) return; + for (const ann of annotations) { + let highlighted: string; + if (ann.branch) { + const name = ann.branch.replace(/^origin\//, ""); + if (action === "remove") { + highlighted = ` ${s.dim(name)} ${s.dim("<-")} ${s.redBold("failed")}`; + } else { + highlighted = ` ${s.dim("*")} ${s.redBold(name)} ${s.dim("<-")} ${s.redBold("failed")}`; + } + } else { + highlighted = `${s.dim("<-")} ${s.redBold("failed")}`; + } + rewriteAnnotation(ann, highlighted); + } + } + + const originalBranch = currentBranchName() || git(["rev-parse", "HEAD"])!; + + if (mergeable.length === 0) { + let pushedSha: string | null = null; + try { + git(["checkout", "--quiet", "-B", "fi", `origin/${defBranch}`], { + debug: opts.debug, + }); + const commitMsg = buildCommitMessage([], defBranch, commitFormat); + + updateLastAnnotation("committing"); + git( + [ + "commit", + "--no-verify", + "--allow-empty-message", + "--allow-empty", + "--quiet", + "--no-edit", + "-m", + commitMsg, + ], + { debug: opts.debug } + ); + + updateLastAnnotation("pushing"); + pushedSha = git(["rev-parse", "HEAD"]); + git(["push", "--no-verify", "-f", "origin", "fi"], { + debug: opts.debug, + }); + } finally { + git(["checkout", "--quiet", originalBranch], { + allowFailure: true, + debug: opts.debug, + }); + git(["branch", "--quiet", "-D", "fi"], { + allowFailure: true, + debug: opts.debug, + }); + } + + finalizeDone(); + return pushedSha; + } + + try { + git(["checkout", "--quiet", "-B", "fi", `origin/${defBranch}`], { + debug: opts.debug, + }); + } catch { + abort(`Failed to checkout fi from origin/${defBranch}`, opts); + } + + let mergeSuccess = false; + updateLastAnnotation("merging"); + const mergeSpin = createSpinner( + `Merging ${mergeable.length} branches...`, + opts + ); + try { + const mergeArgs = [ + "merge", + "--no-commit", + "--no-ff", + "--no-edit", + ...mergeable, + ]; + if (!opts.debug) mergeArgs.splice(1, 0, "--quiet"); + git(mergeArgs, { debug: opts.debug }); + mergeSuccess = true; + } catch { + mergeSuccess = false; + } finally { + mergeSpin.stop(); + } + + if (mergeSuccess) { + updateLastAnnotation("committing"); + const commitMsg = buildCommitMessage(mergeable, defBranch, commitFormat); + git( + [ + "commit", + "--no-verify", + "--allow-empty-message", + "--allow-empty", + "--quiet", + "--no-edit", + "-m", + commitMsg, + ], + { debug: opts.debug } + ); + + updateLastAnnotation("pushing"); + const pushedSha = git(["rev-parse", "HEAD"]); + git(["push", "--no-verify", "-f", "origin", "fi"], { + debug: opts.debug, + }); + + git(["checkout", "--quiet", originalBranch], { + allowFailure: true, + debug: opts.debug, + }); + git(["branch", "--quiet", "-D", "fi"], { + allowFailure: true, + debug: opts.debug, + }); + + finalizeDone(); + return pushedSha; + } else { + const conflictFiles = + gitLines(["diff", "--name-only", "--diff-filter=U"], { + allowFailure: true, + }) || []; + + git(["reset", "--hard", "HEAD"], { debug: opts.debug }); + + const untrackedAfter = gitLines([ + "ls-files", + "--other", + "--exclude-standard", + ]); + const newUntracked = untrackedAfter.filter( + (f) => !untrackedBefore.has(f) + ); + + git(["checkout", "--quiet", originalBranch], { + allowFailure: true, + debug: opts.debug, + }); + git(["branch", "--quiet", "-D", "fi"], { + allowFailure: true, + debug: opts.debug, + }); + + finalizeError(); + + process.stdout.write("\nFailed trying to merge branch(es):\n\n"); + process.stdout.write(bulletList(mergeable, opts)); + + if (newUntracked.length > 0) { + process.stdout.write( + "\nSome extra untracked files have been left as a result of the failed merge(s):\n\n" + ); + process.stdout.write(bulletList(newUntracked, opts)); + process.stdout.write("\nYou can delete these by running:\n"); + for (const f of newUntracked) { + process.stdout.write(` rm "${f}"\n`); + } + } + + process.stdout.write("\n"); + abort("Aborted due to merge failures", opts); + } +} diff --git a/src/style.ts b/src/style.ts new file mode 100644 index 0000000..7aabed7 --- /dev/null +++ b/src/style.ts @@ -0,0 +1,134 @@ +import type { Options } from "./types.js"; + +const isTTY = process.stdout.isTTY === true; +const isStderrTTY = process.stderr.isTTY === true; + +export function colorEnabled(opts: Options): boolean { + if (process.env.NO_COLOR !== undefined) return false; + if (opts.bare || opts.json) return false; + return isTTY; +} + +export function progressEnabled(opts: Options): boolean { + if (opts.bare || opts.json) return false; + return isStderrTTY; +} + +export function makeStyle(opts: Options) { + const on = colorEnabled(opts); + const esc = (code: string) => (on ? `\x1b[${code}m` : ""); + const reset = esc("0"); + return { + cyan: (s: string) => `${esc("36")}${s}${reset}`, + green: (s: string) => `${esc("32")}${s}${reset}`, + greenBold: (s: string) => `${esc("1;32")}${s}${reset}`, + yellow: (s: string) => `${esc("33")}${s}${reset}`, + redBold: (s: string) => `${esc("1;31")}${s}${reset}`, + bold: (s: string) => `${esc("1")}${s}${reset}`, + dim: (s: string) => `${esc("2")}${s}${reset}`, + fi: () => (on ? `${esc("1")}fi${reset}` : "fi"), + link: (text: string, url: string) => + on ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text, + }; +} + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +export function createSpinner(message: string, opts: Options) { + if (!progressEnabled(opts)) return { stop() {} }; + let i = 0; + const id = setInterval(() => { + process.stderr.write( + `\r${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]} ${message}` + ); + }, 80); + return { + stop() { + clearInterval(id); + process.stderr.write("\r\x1b[K"); + }, + }; +} + +export function createProgressLine(opts: Options) { + const tty = progressEnabled(opts); + const s = makeStyle(opts); + return { + update(message: string) { + if (tty) { + process.stderr.write(`\r\x1b[K${message}`); + } else { + process.stderr.write(`${message}\n`); + } + }, + done() { + if (tty) { + process.stderr.write(`\r\x1b[K${s.greenBold("Done!")}\n`); + } else { + process.stderr.write(`${s.greenBold("Done!")}\n`); + } + }, + }; +} + +export function bulletList( + items: string[], + opts: Options, + gitlab?: { host: string; project: string } | null +): string { + const s = makeStyle(opts); + if (items.length === 0) return " \n"; + return ( + items + .map((b) => { + const name = b.replace(/^origin\//, ""); + const label = gitlab + ? s.link( + s.cyan(name), + `https://${gitlab.host}/${gitlab.project}/-/tree/${encodeURIComponent(name)}` + ) + : s.cyan(name); + return ` ${s.dim("*")} ${label}`; + }) + .join("\n") + "\n" + ); +} + +function visibleLength(s: string): number { + return s + .replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "") + .replace(/\x1b\[[0-9;]*m/g, "") + .length; +} + +function padVisible(s: string, width: number): string { + const pad = width - visibleLength(s); + return pad > 0 ? s + " ".repeat(pad) : s; +} + +export function printTable( + headers: string[], + rows: string[][], + opts: Options +): void { + if (rows.length === 0) return; + + const widths = headers.map((h, i) => + Math.max(visibleLength(h), ...rows.map((r) => visibleLength(r[i] || ""))) + ); + const formatRow = (cells: string[]) => + cells.map((c, i) => padVisible(c, widths[i])).join(" │ "); + const separator = widths.map((w) => "─".repeat(w)).join("─┼─"); + + process.stdout.write(formatRow(headers) + "\n"); + process.stdout.write(separator + "\n"); + for (const row of rows) { + process.stdout.write(formatRow(row) + "\n"); + } +} + +export function abort(message: string, opts: Options, exitCode = 1): never { + const s = makeStyle(opts); + process.stderr.write(`${s.redBold(message)}\n`); + process.exit(exitCode); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8e3c9f3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,15 @@ +export interface Options { + debug: boolean; + bare: boolean; + json: boolean; + select: boolean; +} + +export interface CIResult { + branch: string; + status: string; + pipelineId: string; + author: string; + date: string; + branchMissing: boolean; +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..ff3f1b6 --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,156 @@ +import type { CIResult } from "./types.js"; +import { makeStyle } from "./style.js"; + +const ESC = "\x1b["; + +function hideCursor(): void { + process.stdout.write(`${ESC}?25l`); +} + +function showCursor(): void { + process.stdout.write(`${ESC}?25h`); +} + +function clearLines(count: number): void { + for (let i = 0; i < count; i++) { + process.stdout.write(`${ESC}2K`); + if (i < count - 1) process.stdout.write(`${ESC}1A`); + } + process.stdout.write("\r"); +} + +const STATUS_EMOJI: Record = { + success: "\u2705", + failed: "\u274C", + timeout: "\u23F0", + running: "\u23F3", + pending: "\u23F3", + missing: "\u2796", + skipped: "\u23ED\uFE0F", +}; + +function renderPicker( + title: string, + branches: string[], + selected: Set, + cursor: number, + ciData?: Map +): number { + const s = makeStyle({ debug: false, bare: false, json: false, select: false }); + const lines: string[] = []; + lines.push(`\x1b[1m${title}\x1b[0m`); + lines.push(""); + + const maxBranchLen = ciData + ? Math.max(...branches.map((b) => b.replace(/^origin\//, "").length)) + : 0; + + for (let i = 0; i < branches.length; i++) { + const arrow = i === cursor ? "❯ " : " "; + const toggle = selected.has(branches[i]) ? `\x1b[32m◉ \x1b[0m` : "○ "; + const name = branches[i].replace(/^origin\//, ""); + let line = `${arrow}${toggle}\x1b[36m${name}\x1b[0m`; + + if (ciData) { + const ci = ciData.get(branches[i]); + const pad = " ".repeat(maxBranchLen - name.length + 2); + const emoji = STATUS_EMOJI[ci?.status ?? "missing"] ?? STATUS_EMOJI.missing; + const pipeline = ci?.pipelineId ? `${ci.pipelineId} ${emoji}` : emoji; + const date = ci?.date ? `\x1b[2m${ci.date}\x1b[0m` : ""; + const author = ci?.author ? `\x1b[2m${ci.author}\x1b[0m` : ""; + line += `${pad}${date} ${author} ${pipeline}`; + } + + lines.push(line); + } + lines.push(""); + lines.push("\x1b[2m[Space] toggle [a] toggle all [Enter] confirm [Esc] cancel\x1b[0m"); + + process.stdout.write(lines.join("\n") + "\n"); + return lines.length; +} + +function readKey(): Promise { + return new Promise((resolve) => { + const onData = (data: Buffer) => { + process.stdin.removeListener("data", onData); + resolve(data); + }; + process.stdin.on("data", onData); + }); +} + +export async function pickBranches( + branches: string[], + title: string, + initialSelected: string[] = [], + ciData?: Map +): Promise { + if (branches.length === 0) return []; + + const selected = new Set(initialSelected); + let cursor = 0; + + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding("utf-8"); + hideCursor(); + + let lineCount = renderPicker(title, branches, selected, cursor, ciData); + + try { + while (true) { + const key = await readKey(); + const str = key.toString(); + + if (str === "\x1b[A" || str === "k") { + cursor = (cursor - 1 + branches.length) % branches.length; + } else if (str === "\x1b[B" || str === "j") { + cursor = (cursor + 1) % branches.length; + } else if (str === " ") { + const b = branches[cursor]; + if (selected.has(b)) selected.delete(b); + else selected.add(b); + } else if (str === "a") { + if (selected.size === branches.length) selected.clear(); + else branches.forEach((b) => selected.add(b)); + } else if (str === "\r") { + return branches.filter((b) => selected.has(b)); + } else if (str === "\x1b" || str === "q") { + return null; + } else if (str === "\x03") { + process.exit(130); + } + + clearLines(lineCount + 1); + lineCount = renderPicker(title, branches, selected, cursor, ciData); + } + } finally { + showCursor(); + process.stdin.setRawMode(false); + process.stdin.pause(); + } +} + +export async function confirm( + message: string, + _detail?: string +): Promise { + process.stdout.write(`${message}\n`); + process.stdout.write("See: https://github.com/gettyimages/git-fi\n"); + process.stdout.write("\ny - yes\nanything else: no\n\n"); + process.stdout.write("\x1b[1mAre you sure? \x1b[0m"); + + process.stdin.setRawMode(true); + process.stdin.resume(); + + try { + const key = await readKey(); + const str = key.toString(); + process.stdout.write("\n"); + return str === "y"; + } finally { + process.stdin.setRawMode(false); + process.stdin.pause(); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d90d697 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "strict": true, + "esModuleInterop": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "skipLibCheck": true + }, + "include": ["src"] +}