diff --git a/README.md b/README.md index f2798c9..6995c12 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Requires Node.js >= 22 and git >= 2.41.0. The Node floor is the `engines` field in `package.json`, and it tracks active LTS: 22 is the oldest line still supported upstream, and CI runs both LTS lines plus Current. Raising it means moving `engines` and the CI matrix together. -The git floor tracks the newest git feature the code calls rather than a support policy. Today that feature is the `%(ahead-behind:)` format atom, which carries the per-branch behind count (`READY-01`) and which git gained in 2.41.0; next below it are `git merge-tree --write-tree` (2.38.0) for conflict attribution (`READY-03`) and `git branch -r --format=` (2.13.0) for the batched branch listing (`PERF-01`). Dropping the newest of the three would move the floor to 2.38.0, not back to 2.13.0. Reaching for a newer git feature means raising the floor in `PRE-02`, `src/git.ts`, and `scripts/postinstall.mjs` together, and naming the feature that moved it so the next person doesn't have to re-derive the number. The postinstall refuses the install below the floor (`PRE-06`) and cannot import the number from the build, so a test pins the two against each other. +The git floor tracks the newest git feature the code calls rather than a support policy. Today that feature is the `%(ahead-behind:)` format atom, which carries the per-branch behind count (`READY-01`) and which git gained in 2.41.0; next below it are `git merge-tree --write-tree` (2.38.0), which is how the merge itself is built (`MERGE-08`), and `git branch -r --format=` (2.13.0) for the batched branch listing (`PERF-01`). Dropping the newest of the three would move the floor to 2.38.0, not back to 2.13.0. Reaching for a newer git feature means raising the floor in `PRE-02`, `src/git.ts`, and `scripts/postinstall.mjs` together, and naming the feature that moved it so the next person doesn't have to re-derive the number. The postinstall refuses the install below the floor (`PRE-06`) and cannot import the number from the build, so a test pins the two against each other. ```bash git clone https://github.com/gettyimages/git-fi.git diff --git a/SPEC.md b/SPEC.md index d456405..9ed00d6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -289,8 +289,6 @@ Add a branch with git fi --add . ### add / `--add` / `-a` -`ADD-01` If the working index is not clean, then git-fi shall abort. - **Process:** 1. `ADD-02` git-fi shall get the current branch list from fi (via commit message parsing — see [Branch List Storage](#storage)). @@ -346,38 +344,27 @@ The core merge operation that `--add`, `--remove`, `--force`, and `--again` all %%{ init: { 'look': 'handDrawn' } }%% flowchart TD A[Start merge] --> B{Ambiguous origin/fi?} - B -- yes --> B1[ABORT: more than one origin/fi] - B -- no --> C{Tracked files 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 + B -- yes --> C[ABORT: more than one origin/fi] + B -- no --> D[Fetch if needed] + D --> E{origin/fi exists?} + E -- no --> F{User confirms bootstrap?} + F -- no --> G[ABORT] + F -- yes --> H[Prune dead branches] + E -- 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] + I --> J[Merge each branch onto the default branch] + J --> K{Every branch merged?} + K -- yes --> L[Commit the accumulated tree] + L --> M[Push the sha to refs/heads/fi] + M --> N[Print summary] + K -- no --> O[Print the failing branches and their remedies] + O --> P[ABORT: merge failures] ``` ### Flow 1. `MERGE-01` If more than one `origin/fi` ref exists, then git-fi shall abort with: `There is more than one origin/fi!` -2. `MERGE-02` If uncommitted changes to tracked files exist, staged or unstaged, then git-fi shall abort with `Your index is dirty`. Untracked files shall not block the merge. -3. `MERGE-03` git-fi shall capture a snapshot of untracked files via `git ls-files --other --exclude-standard`. +2. `MERGE-02` The merge process shall read and write nothing but the object database: no ref, no index, no working tree, and no `HEAD`. Uncommitted changes, staged or unstaged, and untracked files shall be left exactly as they were, and shall not block the merge. 4. `MERGE-04` git-fi shall run `git fetch --quiet --prune origin` (if not already done). 5. `MERGE-05` If no `origin/fi` ref exists after fetch, then git-fi shall require confirmation before bootstrapping. Unless `--yes` is given (`OPTION-07`, `MERGE-15`), git-fi shall display a bootstrap confirmation prompt; if the user does not enter `y`, then git-fi shall abort. Example: @@ -397,21 +384,18 @@ flowchart TD 6. `MERGE-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. `MERGE-07` When a branch is already an ancestor of the default branch (`READY-07`), git-fi shall exclude it from the merge and warn on stderr: `X already in main`. Because the branch list is stored in the resulting commit message (see [Branch List Storage](#storage)), excluding the branch also drops it from fi. -8. `MERGE-08` git-fi shall create a temporary fi branch via `git checkout --quiet -B fi origin/`. -9. `MERGE-09` git-fi shall merge via `git merge --no-commit --quiet --no-ff --no-edit ...` -10. `MERGE-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`. +8. `MERGE-08` git-fi shall merge the branch list onto `origin/` one branch at a time: `git merge-tree --write-tree ` for each, and `git commit-tree` on each clean result so the next branch has a commit to merge onto. An empty branch list leaves the accumulator at `origin/`, which is how fi is rebuilt with nothing in it. Every branch shall be named to git by its full ref, `refs/remotes/origin/`: git resolves `refs/heads/` ahead of `refs/remotes/`, so a local branch named `origin/` would otherwise win the short form and put work that was never pushed into fi (`MERGE-02`). +9. `MERGE-09` A branch that conflicts shall be left out of the accumulated set and recorded against attribution (`READY-03`), so the walk names every failing branch rather than stopping at the first. Where `merge-tree` cannot run at all (an unresolvable ref, or a shallow clone whose histories look unrelated), the merge shall fail with nothing attributed. +10. `MERGE-10` When every branch merged, git-fi shall: + - Commit the accumulated tree with `git commit-tree`, with `origin/` and each merged branch as its parents, carrying the message from [Commit Message](#commit-message), and update the annotation to `<- committing`. Where `commit.gpgsign` is set, git-fi shall pass `-S`: `git commit-tree` reads none of the signing configuration `git commit` honors, so a repository that signs its commits would otherwise have fi silently stop being signed, and a forge enforcing signatures would refuse the push. + - Push: `git push --no-verify -f origin :refs/heads/fi`, and update the annotation to `<- pushing`. git-fi shall confirm `` is an object id before pushing: an empty left side makes the refspec a delete, which this `-f` would apply to the branch everyone shares. - Finalize annotation line(s) with the action's terminal success state (see TERM-08), or state the outcome once off a TTY (see TERM-09). - Print the branch list table (identical to `list` output, including the fi pipeline per GITLAB-05) so the user sees the final state without running a separate command. 11. `MERGE-11` When the merge fails, git-fi shall: - - Abort the failed merge (leave the working tree clean). - - Attribute each failing branch (`READY-05`) and print the failed branch names with the remedy each one calls for (`READY-04`). - - List any new untracked files created during the failed merge, with suggested `rm` commands. + - Print the failing branches with the remedy each one calls for (`READY-04`, `READY-05`). Where nothing could be attributed (`MERGE-09`), git-fi shall print the attempted list and say that nothing in it names the branch at fault. - Abort with: `Aborted due to merge failures` -12. `MERGE-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. + + Nothing is pushed, so `origin/fi` still holds what it held before the attempt. ### Commit Message @@ -470,19 +454,6 @@ Aborted due to merge failures Both branches are in fi here, so both are offered to `-r`. On a failed `--add` the branch never entered fi and the line leaves it out (`READY-04`). -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 -``` - -A filename takes anything but `/` and NUL, so git-fi shall single-quote a name in that `rm` line by the same rule the conflict remedies use (`READY-04`): bare where nothing in it reads as shell, quoted otherwise. - ## Merge Readiness A branch list that merged cleanly yesterday can fail today: the default branch moves, and branches gain commits. When it does fail, the message `Failed trying to merge branch(es)` names the whole failing set without saying whose problem it is, and the reflex it invites is `--force` — replace fi with one branch and start over, discarding everyone else's integration. The usual fix is smaller than that: one or two branches need a rebase. These requirements make that difference visible. @@ -518,19 +489,24 @@ A filename takes any byte but `/` and NUL, and `-z` hands those bytes over intac git-fi shall close the report with the `--remove` command line that takes the failing branches out of fi, marked as temporary and placed below the fixes. Unlike `--force` it drops only the named branches, so the rest of fi survives; it defers the conflict rather than resolving it, which is why it follows the rebases instead of leading. The line shall name only the failing branches fi actually holds: a branch that failed on the way *in* was never added, so there is nothing to remove, and where none of the failing branches is in fi the line is omitted. git-fi shall not offer `--force` as a remedy at all: replacing fi with one branch discards the other branches' integration instead of resolving anything, and naming the pair is what makes the smaller fix visible. -`READY-05` When the merge fails (`MERGE-11`), git-fi shall run attribution (`READY-03`) over the branch list it tried to merge and print the result (`READY-04`) in place of the bare list of failed branch names. +`READY-05` The merge (`MERGE-08`) and attribution (`READY-03`) are one traversal: the branch that fails a step of the merge is the branch attribution then places. git-fi shall print the result (`READY-04`) in place of the bare list of failed branch names. -Attribution can name nobody, in two ways that call for different things to be said, and git-fi shall say which rather than falling back to the bare list alone: +Attribution can name nobody in one way, and git-fi shall say so rather than falling back to the bare list alone: **`merge-tree` could not run** (`MERGE-09`, `READY-03`). git-fi shall report that nothing names the branch at fault, and point at `--debug` for what git reported. -- **Every branch merged cleanly on its own.** The combined merge (`MERGE-09`) is git's octopus strategy, which has no rename detection, while `merge-tree` uses the newer engine, which has — so a branch renaming a file and a branch editing it fail the combined merge and come back clean from every probe. git-fi shall report that the conflict is in the combination. -- **A probe could not run** (`READY-03`). git-fi shall report that nothing names the branch at fault, and point at `--debug` for what git reported. +Where the failing branch merges cleanly against both the default branch and every peer taken singly, the conflict lives in the combination, and `READY-04` names the accumulated set. -`READY-06` A branch list that merges cleanly shall cost attribution (`READY-03`) one `git merge-tree` and one `git commit-tree` per branch. Each branch that fails costs one further probe against the default branch, and where the default branch is not the cause, one pairwise probe per branch already in the set — so a list on which every branch fails that way is quadratic in the branch count. That growth is left uncapped where the path list is capped (`READY-04`), because it is bounded by the branch count rather than by a repository's contents and is only reached after a merge has already failed. Attribution shall read and write nothing outside the object database — no ref, no index, no working tree, and the intermediate commits it writes are unreferenced, so `git gc` reclaims them. It therefore imposes no clean-index precondition of its own (`ADD-01`, `MERGE-02`) and leaves the cleanup in `MERGE-11` and `MERGE-12` to run exactly as it would have. +`READY-06` A branch list that merges cleanly shall cost one `git merge-tree` and one `git commit-tree` per branch. Each branch that fails costs one further probe against the default branch, and where the default branch is not the cause, one pairwise probe per branch already in the set, so a list on which every branch fails that way is quadratic in the branch count. That growth is left uncapped where the path list is capped (`READY-04`), because it is bounded by the branch count rather than by a repository's contents and is only reached once the merge has already failed. The walk shall read and write nothing outside the object database (`MERGE-02`), and the intermediate commits it writes are unreferenced, so `git gc` reclaims them. `READY-07` A branch with nothing ahead of the default branch — the *ahead* half of the same `%(ahead-behind:...)` field (`READY-01`) — has landed: every commit it carries is already on the default branch, and the next mutation drops it from fi (`MERGE-07`). git-fi shall determine already-merged status from that field rather than from a separate `git branch -r --merged` invocation, so one listing answers both questions (`PERF-01`), and shall derive it in one place, so the display and the pruning cannot disagree about what "landed" means. An unknown ahead count (`READY-01`) shall read as *not* merged. Pruning rewrites fi's branch list and force-pushes it, so a missing signal has to fail towards keeping someone's branch: `git branch -r --merged` could not answer "merged" by accident, and a count derived from a parse can. When listing, git-fi shall strike the branch name through and mark it `merged`, and shall suppress the behind marker for it (`READY-02`) — a landed branch trails the default branch by definition, and rebasing is not what it needs. The word accompanies the strikethrough rather than replacing it: not every terminal draws SGR 9, and a name that silently renders unstruck would carry no signal at all, the same reason `GITLAB-06` words a deleted branch instead of only coloring it. +`READY-08` When the action names a branch whose local counterpart has drifted from `origin/`, git-fi shall warn on stderr, naming the count in each direction: `fi merges origin/X, and your X is 2 ahead, 3 behind`. The merge takes `origin/` (`MERGE-08`) and never the caller's checkout, so a local branch carrying commits that were never pushed contributes nothing to fi, and one trailing the remote means fi holds a newer branch than the caller is looking at. + +The comparison is against `origin/` rather than the local branch's configured upstream, because `origin/` is the ref that reaches fi whatever the branch is set to track. Only the branches the action names are checked: `--again` re-merges the whole list without being a statement about any one branch, so warning across it would report every stale local copy of a teammate's branch. git-fi shall say nothing where there is no local branch of that name, where the two share no history, or in a shallow repository, whose truncated walk would describe the fetched window rather than the branch (`READY-01`). + +Shared history shall be established with `git merge-base` before the counts are read, because `git rev-list --left-right --count` succeeds on disjoint histories and returns the full size of each side — every commit on each branch, which is not drift. Both sides shall be named by their full refs, `refs/heads/` and `refs/remotes/origin/`: a tag sharing the branch's name wins the short form, and the count would then describe the tag (`MERGE-02`). + ## `STORAGE` diff --git a/STATUS.md b/STATUS.md index 946a5c9..8104c06 100644 --- a/STATUS.md +++ b/STATUS.md @@ -12,8 +12,8 @@ land on it. | Status | Count | |---------|-------| -| Covered | 136 | -| Total | 136 | +| Covered | 134 | +| Total | 134 | ## `PRE` @@ -133,7 +133,6 @@ Commands | ID | Description | Status | Location | |--------|--------------------------|---------|----------------------------| -| ADD-01 | Clean index precondition | Covered | `src/merge.ts` (`mergeProcess`) | | ADD-02 | Parse current branch list| Covered | `src/commands.ts` (`cmdAdd`) | | ADD-03 | Append and deduplicate | Covered | `src/commands.ts` (`cmdAdd`) | | ADD-04 | Run merge | Covered | `src/commands.ts` (`cmdAdd`) | @@ -152,17 +151,15 @@ Merge Process | ID | Description | Status | Location | |-------|--------------------------|---------|---------------------------| | MERGE-01 | Ambiguous ref check | Covered | `src/merge.ts` (`mergeProcess`) | -| MERGE-02 | Tracked-file dirty check | Covered | `src/merge.ts` (`mergeProcess`) | -| MERGE-03 | Capture untracked | Covered | `src/merge.ts` (`mergeProcess`) | +| MERGE-02 | Checkout left untouched | Covered | `src/merge.ts` (`mergeProcess`), `src/readiness.ts` (`mergeBranches`) | | MERGE-04 | Fetch | Covered | `src/merge.ts` (`mergeProcess`), `src/git.ts` (`ensureFetched`) | | MERGE-05 | Bootstrap confirmation | Covered | `src/merge.ts` (`mergeProcess`), `src/ui.ts` (`confirm`) | | MERGE-06 | Prune dead branches | Covered | `src/merge.ts` (`mergeProcess`) | | MERGE-07 | Warn about merged | Covered | `src/merge.ts` (`mergeProcess`) | -| MERGE-08 | Create temp fi branch | Covered | `src/merge.ts` (`mergeProcess`) | -| MERGE-09 | Merge command | Covered | `src/merge.ts` (`mergeProcess`) | +| MERGE-08 | Merge in the object database | Covered | `src/readiness.ts` (`mergeBranches`, `mergeTree`, `commitTree`) | +| MERGE-09 | Failing branch recorded | Covered | `src/readiness.ts` (`mergeBranches`) | | MERGE-10 | On success | Covered | `src/merge.ts` (`mergeProcess`) | | MERGE-11 | On failure | Covered | `src/merge.ts` (`mergeProcess`) | -| MERGE-12 | Cleanup | Covered | `src/merge.ts` (`mergeProcess`) | | MERGE-13 | CI commit message | Covered | `src/merge.ts` (`buildCommitMessage`) | | MERGE-14 | Bootstrap link | Covered | `src/ui.ts` (`confirm`) | | MERGE-15 | Bootstrap confirmation / `--yes` | Covered | `src/merge.ts` (`mergeProcess`) | @@ -173,11 +170,12 @@ Merge Process |-------|--------------------------|---------|---------------------------| | READY-01 | Behind count | Covered | `src/git.ts` (`listRemoteBranches`, `cachedListing`, `branchReadiness`) | | READY-02 | Behind marker | Covered | `src/style.ts` (`readinessMarker`, `withReadiness`), `src/commands.ts` (`cmdList`), `src/gitlab.ts` (`printCITable`) | -| READY-03 | Merge-tree attribution | Covered | `src/readiness.ts` (`attributeConflicts`, `mergeTree`, `commitTree`) | +| READY-03 | Merge-tree attribution | Covered | `src/readiness.ts` (`mergeBranches`, `mergeTree`, `commitTree`) | | READY-04 | Per-branch remedy | Covered | `src/readiness.ts` (`renderConflicts`, `pathItems`), `src/style.ts` (`shq`, `quoteCStyle`), `src/git.ts` (`quotePathEnabled`) | | READY-05 | Attribution on failure | Covered | `src/merge.ts` (`mergeProcess`) | -| READY-06 | Bounded probes | Covered | `src/readiness.ts` (`attributeConflicts`) | +| READY-06 | Bounded probes | Covered | `src/readiness.ts` (`mergeBranches`) | | READY-07 | Merged branch marker | Covered | `src/git.ts` (`branchReadiness`, `mergedRemoteBranches`), `src/style.ts` (`strikeIfMerged`) | +| READY-08 | Local drift warning | Covered | `src/git.ts` (`localDivergence`, `existingLocalRefs`), `src/merge.ts` (`mergeProcess`) | | ID | Description | Status | Location | diff --git a/docs/ci-integration.md b/docs/ci-integration.md index 52acaf4..0291c15 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -90,7 +90,7 @@ The outcome verb names the action: `added to fi`, `removed from fi`, `replaced f On a terminal the branch name is a clickable link to that comparison and the table stays narrow. A job log can't render the escape sequence, so the reference is written out as markdown — copy a row into Slack, an issue, or an MR comment and the link works there. Pipeline IDs stay bare: carrying both URLs inline pushed the table past 200 columns, and the comparison is the one you leave the log to read. -On failure there's no outcome line. Each conflicting branch with what it conflicts with and the remedy, any untracked files the failed merge left behind, and `Aborted due to merge failures` are what you get, and the job exits non-zero. Under `--json` that attribution comes back as a `conflicts` array on stdout as well, so a job can act on it without parsing the log; `branches` is `fi` as it stands (unchanged, since nothing was pushed) and `attempted` is the set the merge tried. +On failure there's no outcome line. Each conflicting branch with what it conflicts with and the remedy, then `Aborted due to merge failures`, are what you get, and the job exits non-zero. Under `--json` that attribution comes back as a `conflicts` array on stdout as well, so a job can act on it without parsing the log; `branches` is `fi` as it stands (unchanged, since nothing was pushed) and `attempted` is the set the merge tried. One caveat for CI specifically: in a shallow clone git counts only within the fetched window, so `behind` comes back `null` and no `↓N` marker is drawn. Fetch the full history if you want the count. Already-merged detection still works, though it can miss a branch whose commits fall outside the window — it never goes the other way and drops a live branch. Add `--debug` to see the git commands, how long each took, and their stderr, which git-fi otherwise discards. diff --git a/docs/commands.md b/docs/commands.md index bc8e3c6..8ff22e9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -101,7 +101,7 @@ A merge that fails writes a JSON object too, rather than only the diagnostics on Nothing is pushed when a merge fails, so `branches` is `fi` as it still stands — the same thing it means after an action that succeeded. `attempted` is the set the merge tried. `with` names what the branch conflicts with: peer branches, or `main` by name when the branch simply needs rebasing. -`conflicts` can come back empty on a failed merge. That means no single branch could be blamed: each one merges cleanly on its own, and the combined merge is what failed. The combined merge uses git's octopus strategy, which does not detect renames, so a rename against a concurrent edit fails there while every individual replay comes back clean. +`conflicts` can come back empty on a failed merge. That means `git merge-tree` couldn't run at all (an unresolvable ref, or a shallow clone whose histories look unrelated), so nothing was measured and nothing is blamed. Re-run with `--debug` for what git reported. Both flags work with any action, not just `list` — they pick an output *format*, so a mutation reports its resulting branch list the same way: diff --git a/docs/merge-process.md b/docs/merge-process.md index 48f6e6c..786408f 100644 --- a/docs/merge-process.md +++ b/docs/merge-process.md @@ -7,37 +7,25 @@ Every mutation command (`-a`, `-r`, `-f`, `-g`) triggers the same merge process. ```mermaid %%{ init: { 'look': 'handDrawn' } }%% flowchart TD - A[Start merge] --> B[Assert tracked files clean] - 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 all branches in one merge] - J --> K{Merge clean?} - K -- yes --> L[Commit and force-push fi] - K -- no --> M[Reset hard and abort] - L --> N[Restore original branch, delete local fi] - M --> N - N --> O{Merge succeeded?} - O -- yes --> P[Print branch list table] - O -- no --> Q[Print failed branches and abort message] + A[Start merge] --> B{fi exists?} + B -- no --> C[Bootstrap confirmation] + C --> D[Compute final branch list] + B -- yes --> D + D --> E[Prune dead branches] + E --> F[Warn about merged and drifted branches] + F --> G[Merge each branch in the object database] + G --> H{Every branch clean?} + H -- yes --> I[Commit the tree and push it to fi] + H -- no --> J[Abort without pushing] + I --> K[Print branch list table] + J --> L[Print failing branches and abort message] ``` ## Step by Step -### 1. Clean state +Nothing below reads or writes your working tree, your index, or `HEAD`. A half-finished edit, a staged file, scratch output: all of it stays exactly as it is, and none of it stands in the way of a merge. The whole operation happens in the object database, so there is nothing to undo if it fails and nothing to unwind if you interrupt it. -git-fi asserts that no tracked file carries uncommitted changes, staged or unstaged. This protects your work from being lost during branch switching. Untracked files are left alone, so scratch files and build output do not stand in the way of a merge. - -### 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 +### 1. Bootstrap confirmation The first time `fi` is created in a repository, git-fi asks for confirmation: @@ -47,7 +35,7 @@ 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 +### 2. Branch list computation The final branch list depends on the command: @@ -58,9 +46,9 @@ The final branch list depends on the command: | `-f` | Only the specified branches | | `-g` | Current branches (unchanged) | -Steps 5 and 6 then filter that list, so the set that actually gets merged can be smaller than the table suggests. +Steps 3 and 4 then filter that list, so the set that actually gets merged can be smaller than the table suggests. -### 5. Dead branch pruning +### 3. Dead branch pruning Branches that no longer exist on the remote are removed from the list, with a warning: @@ -69,7 +57,7 @@ Ignoring branches that no longer exist: deleted-branch ``` -### 6. Merged branch pruning +### 4. Merged branch pruning Branches already merged into the default branch are dropped from the list too, with a warning: @@ -79,26 +67,38 @@ landed-branch already in main Both filters apply to every command, so any mutation tidies `fi` on the way through. `-g` with no other change is therefore the way to prune: it re-merges what's left after both filters. Since the surviving list is what gets written to the new `fi` commit message, a dropped branch is gone from `fi` afterwards, not merely flagged. -### 7. Merge execution +### 5. Local drift -git-fi creates a fresh `fi` branch from `origin/main` (or `origin/master`), then merges **all** the branches together in a single `git merge --no-commit --no-ff`. It's all-or-nothing: +`fi` is built from `origin/`, never from your checkout. When you name a branch whose local copy has drifted from its remote, git-fi says so: + +```text +fi merges origin/feature-auth, and your feature-auth is 2 ahead, 3 behind +``` -- If every branch integrates cleanly, git-fi commits and force-pushes `fi`. -- If **any** branch conflicts, git-fi resets the working tree (`git reset --hard`) and aborts. No `fi` is pushed — the remote is left untouched. +*Ahead* is the one that costs you something: those commits are only in your checkout, so nothing integrates them and `fi` says nothing about how they land. Push them and re-run. *Behind* means the opposite: `fi` has integrated a newer `feature-auth` than the one you're looking at. -The combined merge can't say which branch caused a conflict, so a failure is followed by a second pass that can — see [Conflict Handling](#conflict-handling). +Only branches you name are checked. `-g` re-merges the whole list without being a statement about any one branch, so it stays quiet rather than reporting every stale local copy of a teammate's branch. A branch you have no local copy of has nothing to drift, a local branch sharing no history with the remote one has no drift to count (a recreated branch, say), and a shallow clone counts against a truncated history, so none of the three says anything. -### 8. Commit and push +### 6. Merge execution -The resulting merge is committed with a message that records the branches included in `fi`, so the list round-trips on the next run. git-fi currently writes the **legacy** standard git merge message: +Starting from `origin/main` (or `origin/master`), git-fi merges the branches one at a time with `git merge-tree --write-tree`, committing each clean step so the next branch has something to merge onto. It's all-or-nothing: + +- If every branch integrates cleanly, git-fi commits the resulting tree and force-pushes it to `fi`. +- If **any** branch conflicts, git-fi aborts. No `fi` is pushed, and the remote is left untouched. + +Because each step names the branch it was merging, a failure already knows who is responsible (see [Conflict Handling](#conflict-handling)). + +### 7. Commit and push + +The resulting tree is committed with `git commit-tree`, taking `origin/main` and each merged branch as its parents (the same shape a merge commit has), and a message that records the branches included in `fi`, so the list round-trips on the next run. git-fi currently writes the **legacy** standard git merge message: ```text Merge remote-tracking branches 'origin/feature-auth', 'origin/feature-search' and 'origin/bugfix-nav' into fi ``` -git-fi also *reads* a compact **terse** format (`(feature-auth, feature-search, bugfix-nav)@[a1b2c3d]`), so `fi` branches written by other versions are still understood; it will switch to *writing* terse after the migration rollout. The `fi` branch is then force-pushed to origin. +git-fi also *reads* a compact **terse** format (`(feature-auth, feature-search, bugfix-nav)@[a1b2c3d]`), so `fi` branches written by other versions are still understood; it will switch to *writing* terse after the migration rollout. That commit is reachable from nothing local, so it is force-pushed to origin by its sha. -### 9. Output +### 8. Output On success, git-fi prints the branch list table (identical to `list` output, including the `fi` pipeline line when a GitLab token is configured), so you see the final state without running a separate command: @@ -133,20 +133,17 @@ The `git fi -r ...` line at the end names only the branches `fi` actuall ## Conflict Handling -All selected branches are merged together in one `git merge`, which git treats atomically — either every branch integrates or none does. That merge can tell you the set failed but not who is responsible, so git-fi follows it with a second pass that re-merges the list one branch at a time using `git merge-tree`, which reads the object database without touching your working tree. - -That pass answers the question the bare list of failed branches doesn't: +Merging one branch at a time means the failing step names the branch, and git-fi then asks what that branch is actually fighting with: against the default branch alone, then against each branch already in the set. The result answers the question a bare list of failed branches doesn't: - **A branch conflicts with `main`.** `main` has moved somewhere the branch also changed. Its owner rebases and re-pushes; nobody else is involved. - **A branch conflicts with a peer.** Two in-flight branches overlap. This is what `fi` exists to surface — the conflict is real and would have surfaced at release time instead. The two owners settle it now, while both branches are still small. +- **A branch conflicts only with the combination.** It merges cleanly against `main` and against every peer on its own, and fails only against the whole set. The report names the set. -Sometimes the pass names nobody, and says so. The combined merge uses git's octopus strategy, which does not detect renames, while the replay uses the newer engine, which does — so a branch that renames a file and a branch that edits it will fail the combined merge and come back clean from every individual probe. The report says the conflict is in the combination rather than leaving you with an unexplained list. +A failing branch is left out of the accumulated set and the walk carries on, so one bad branch doesn't condemn every branch listed after it, and the report names all of them in one run. So when any branch conflicts: -1. The merge is aborted and the working tree is reset (`git reset --hard`). -2. git-fi attributes each failing branch and prints the remedy it calls for, with the conflicted paths. -3. Any untracked files created by the failed merge are listed with `rm` commands to remove them. -4. git-fi restores your original branch, deletes the local `fi`, and exits with `Aborted due to merge failures`. **No `fi` is pushed** — the remote stays as it was. +1. git-fi prints the remedy each failing branch calls for, with the conflicted paths. +2. It exits with `Aborted due to merge failures`. **No `fi` is pushed**: the remote stays as it was, and so does your checkout. Reach for the named remedy before either escape hatch, and never for `git fi -f `. Forcing `fi` to hold only your branch clears the error by throwing away everyone else's integration, and the conflict it was reporting is still there the next time someone adds their branch back. The `git fi -r` line the report prints is the survivable version — it drops only the branches that failed and leaves everyone else's work in `fi` — but it still just defers the conflict to whenever those branches go back in. diff --git a/src/branches.ts b/src/branches.ts index a73d4c7..0878c1b 100644 --- a/src/branches.ts +++ b/src/branches.ts @@ -13,3 +13,20 @@ export function resolveBranchName(name: string): string { export function localBranchName(name: string): string { return name.replace(/^origin\//, ""); } + +/** + * The spelling to hand git. Its revision lookup tries `refs/tags/` and + * `refs/heads/` before `refs/remotes/`, so a local branch named + * `origin/feature` wins the short `origin/feature` and the merge would take + * work that was never pushed — the one thing MERGE-02 promises cannot happen. + * git warns that the name is ambiguous, on a stderr git-fi discards off + * `--debug`, so the short form fails silently. + */ +export function remoteRef(name: string): string { + return `refs/remotes/${resolveBranchName(name)}`; +} + +/** The same disambiguation for the caller's own branch, against a same-named tag. */ +export function localRef(name: string): string { + return `refs/heads/${localBranchName(name)}`; +} diff --git a/src/git.ts b/src/git.ts index 68bd278..af078e3 100644 --- a/src/git.ts +++ b/src/git.ts @@ -4,7 +4,12 @@ import { existsSync } from "node:fs"; import { basename } from "node:path"; import type { Options, BranchReadiness } from "./types.js"; import { abort, makeStyle, bulletList, createSpinner } from "./style.js"; -import { resolveBranchName } from "./branches.js"; +import { + resolveBranchName, + localBranchName, + localRef, + remoteRef, +} from "./branches.js"; import { DOCS_URL } from "./help.js"; let fetchDone = false; @@ -361,6 +366,22 @@ function shallowRepository(): boolean { return shallow; } +let gpgsign: boolean | null = null; + +/** + * Whether the caller signs commits. `git commit-tree` reads none of the signing + * config that `git commit` honors, so the fi commit has to ask and pass `-S` + * itself. Cannot change mid-invocation. + */ +export function signCommits(): boolean { + if (gpgsign === null) { + gpgsign = + git(["config", "--bool", "commit.gpgsign"], { allowFailure: true }) === + "true"; + } + return gpgsign; +} + // One `git branch -r` invocation carries the name, symref, commit date, ahead // and behind counts, and tip author for every remote branch, so callers never // spawn a `git log` or a `git rev-list --count` per candidate (PERF-01). @@ -523,13 +544,74 @@ export function remoteBranchesNoMergedSince( .map((b) => b.name); } -/** Every remote-tracking ref that exists, as `origin/`. */ -export function existingRemoteRefs(): Set { +// `%(refname:short)` shortens only as far as the name stays unambiguous, so a +// local branch called `origin/feature` turns the remote-tracking ref's short +// form into `remotes/origin/feature` and a tag called `feature` turns the local +// branch's into `heads/feature`. Both sets below are membership tests keyed on +// the plain name, and a name that shortened differently is a branch that reads +// as gone. Taking the full ref and dropping a known prefix is the spelling that +// does not move. +function refsUnder(prefix: string): Set { return new Set( - gitLines(["for-each-ref", "--format=%(refname:short)", "refs/remotes"]) + gitLines(["for-each-ref", "--format=%(refname)", prefix]).map((r) => + r.slice(prefix.length + 1) + ) ); } +/** Every remote-tracking ref that exists, as `origin/`. */ +export function existingRemoteRefs(): Set { + return refsUnder("refs/remotes"); +} + +/** + * Every local branch name, so a lookup costs one listing rather than a + * rev-parse per branch. + */ +function existingLocalRefs(): Set { + return refsUnder("refs/heads"); +} + +/** + * How far the caller's own branch has drifted from the remote-tracking ref the + * merge takes (READY-08): commits it carries that `origin/` does not, and + * the reverse. + * + * Null where there is nothing to say: no local branch of that name (usually a + * teammate's branch), no shared history to count across, or a shallow + * repository, whose walk stops at the graft and would report the fetched window + * as the branch (READY-01). + * + * The comparison is against `origin/` rather than the branch's configured + * upstream, because `origin/` is the ref that reaches fi whatever the + * local branch is set to track. + */ +export function localDivergence( + branch: string +): { ahead: number; behind: number } | null { + const name = localBranchName(branch); + if (!existingLocalRefs().has(name) || shallowRepository()) return null; + + const local = localRef(name); + const remote = remoteRef(branch); + + // rev-list counts disjoint histories rather than refusing them, so without + // this every commit on each side reads as drift. merge-base is what actually + // answers whether there is anything to count across. + if (git(["merge-base", local, remote], { allowFailure: true }) === null) { + return null; + } + + const out = git(["rev-list", "--left-right", "--count", `${local}...${remote}`], { + allowFailure: true, + }); + if (out === null) return null; + + const [ahead, behind] = out.split(/\s+/).map(Number); + if (!Number.isInteger(ahead) || !Number.isInteger(behind)) return null; + return { ahead, behind }; +} + /** * Remote branches already reachable from `origin/`: the batched * equivalent of `git merge-base --is-ancestor origin/`. diff --git a/src/merge.ts b/src/merge.ts index 6230b55..ca43f0c 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -3,7 +3,6 @@ import { makeStyle, bulletList, createSpinner, - shq, abort, } from "./style.js"; import { @@ -11,18 +10,24 @@ import { gitLines, ensureFetched, defaultBranch, - currentBranchName, existingRemoteRefs, + localDivergence, mergedRemoteBranches, branchReadiness, currentFiBranches, isInteractive, + signCommits, type CommitFormat, } from "./git.js"; -import { localBranchName } from "./branches.js"; +import { localBranchName, remoteRef } from "./branches.js"; import { confirm } from "./ui.js"; import { detectGitlabProject } from "./gitlab.js"; -import { attributeConflicts, renderConflicts } from "./readiness.js"; +import { + mergeBranches, + renderConflicts, + OID, + type MergeOutcome, +} from "./readiness.js"; import { branchJson, writeJson } from "./json.js"; // Commit-message format written when bootstrapping a brand-new fi branch (no @@ -133,15 +138,6 @@ export async function mergeProcess( abort("There is more than one origin/fi!", opts); } - const statusOut = git(["status", "--porcelain", "--untracked-files=no"]); - 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"], { @@ -201,6 +197,26 @@ export async function mergeProcess( } } + // The merge takes origin/ and never the caller's checkout, so a local + // branch that has drifted from it means fi holds something other than what + // the caller is looking at (READY-08). Only the branches this action names + // are checked: over the whole list, `--again` would warn about every stale + // local copy of a teammate's branch, which says nothing about the command + // that was run. + for (const b of mergeable) { + if (!actionSet.has(b)) continue; + const drift = localDivergence(b); + if (drift === null || (drift.ahead === 0 && drift.behind === 0)) continue; + const name = localBranchName(b); + const counts = [ + drift.ahead > 0 ? `${drift.ahead} ahead` : "", + drift.behind > 0 ? `${drift.behind} behind` : "", + ].filter(Boolean); + process.stderr.write( + `${s.yellow(`${s.fi()} merges origin/${name}, and your ${name} is ${counts.join(", ")}`)}\n` + ); + } + // Build compact display interface AnnotationInfo { lineIndex: number; @@ -325,195 +341,103 @@ export async function mergeProcess( } } - 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); + // An empty list still produces a commit, fi rebuilt at the default branch, so + // it walks nothing rather than taking a path of its own. The spinner is what + // does not carry over: there is no merge for it to describe. + let mergeSpin = null; + if (mergeable.length > 0) { + updateLastAnnotation("merging"); + mergeSpin = createSpinner(`Merging ${mergeable.length} branches...`, opts); } - - let mergeSuccess = false; - updateLastAnnotation("merging"); - const mergeSpin = createSpinner( - `Merging ${mergeable.length} branches...`, - opts - ); + let outcome: MergeOutcome; 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; + outcome = mergeBranches(mergeable, defBranch); } finally { - mergeSpin.stop(); + mergeSpin?.stop(); } - if (mergeSuccess) { + if (outcome.outcome === "merged") { updateLastAnnotation("committing"); const commitMsg = buildCommitMessage(mergeable, defBranch, commitFormat); - git( - [ - "commit", - "--no-verify", - "--allow-empty-message", - "--allow-empty", - "--quiet", - "--no-edit", - "-m", - commitMsg, - ], + // The parents a merge commit carries: the default branch fi is rebuilt + // from, then each branch in the order it was merged. + const parents = [defBranch, ...mergeable].flatMap((p) => [ + "-p", + remoteRef(p), + ]); + // commit-tree ignores commit.gpgsign where `git commit` honors it, so + // without this a repo that signs its commits would have fi silently stop + // being signed — and a forge that rejects unsigned commits would refuse the + // push with nothing saying why (MERGE-10). + const sign = signCommits() ? ["-S"] : []; + const pushedSha = git( + ["commit-tree", outcome.tree, ...parents, ...sign, "-m", commitMsg], { debug: opts.debug } ); - updateLastAnnotation("pushing"); - const pushedSha = git(["rev-parse", "HEAD"]); - git(["push", "--no-verify", "-f", "origin", "fi"], { - debug: opts.debug, - }); + // An empty left side makes `:refs/heads/fi` a delete refspec, and it would + // run with `-f` against the branch everyone shares. commit-tree throws + // rather than returning empty today, so this is what keeps that true. + if (pushedSha === null || !OID.test(pushedSha)) { + abort( + `Refusing to push: commit-tree did not name a commit (${pushedSha ?? "null"})`, + opts + ); + } - git(["checkout", "--quiet", originalBranch], { - allowFailure: true, - debug: opts.debug, - }); - git(["branch", "--quiet", "-D", "fi"], { - allowFailure: true, + updateLastAnnotation("pushing"); + // The commit is reachable from nothing local, so it is named by sha. A push + // still moves refs/remotes/origin/fi, which is what the branch list printed + // after this reads. + git(["push", "--no-verify", "-f", "origin", `${pushedSha}:refs/heads/fi`], { debug: opts.debug, }); finalizeDone(); return pushedSha; - } else { - 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(); - - // Naming the whole failing set invites `--force` — replace fi with one - // branch and start over — when the fix is usually one or two rebases - // (READY-05). Attribution runs after the working tree is restored: it reads - // the object database only, so it neither needs nor disturbs a checkout. - const attribution = attributeConflicts(mergeable, defBranch); - // Nothing was pushed, so fi still holds what it held before the attempt — - // which is what says whether `-r` is a remedy for a given branch, and what - // `--json` reports below as fi's branch list. - const fiNow = currentFiBranches(defBranch); - const inFi = new Set(fiNow.map(localBranchName)); - - diagnose("\nFailed trying to merge branch(es):\n\n"); - if (attribution.conflicts.length > 0) { - diagnose(renderConflicts(attribution.conflicts, defBranch, inFi, opts)); - } else { - diagnose(bulletList(mergeable, opts)); - // Saying which branch failed is the promise this path makes, so when it - // cannot be kept the report says that rather than leaving a bare list - // that reads as the old behavior. - diagnose( - attribution.attributable - ? "\nEach branch merges cleanly on its own, so the conflict is in the combination.\nThe combined merge uses git's octopus strategy, which does not detect renames,\nso a rename against a concurrent edit fails there and not in the replay.\n" - : "\nAttribution could not run, so nothing above names the branch at fault.\nRe-run with --debug to see what git reported.\n" - ); - } + finalizeError(); - if (newUntracked.length > 0) { - diagnose( - "\nSome extra untracked files have been left as a result of the failed merge(s):\n\n" - ); - diagnose(bulletList(newUntracked, opts)); - diagnose("\nYou can delete these by running:\n"); - for (const f of newUntracked) { - diagnose(` rm ${shq(f)}\n`); - } - } + // Nothing was pushed, so fi still holds what it held before the attempt, which + // is what says whether `-r` is a remedy for a given branch, and what `--json` + // reports below as fi's branch list. + const fiNow = currentFiBranches(defBranch); + const inFi = new Set(fiNow.map(localBranchName)); - diagnose("\n"); - - // The abort below exits non-zero, so this is the only object `--json` will - // ever write for a failed merge (JSON-03). A pipeline that stops on the exit - // code should not have to scrape stderr to learn which branch needs rebasing. - // - // `branches` is fi as it stands, which the failed merge left untouched — - // the same thing it means after every action that succeeded. What was tried - // is a different list, so it gets a different name. - if (opts.json) { - const readiness = branchReadiness(defBranch); - await writeJson({ - command: action, - branches: fiNow.map((b) => branchJson(b, readiness)), - attempted: mergeable.map(localBranchName), - conflicts: attribution.conflicts, - }); - } + diagnose("\nFailed trying to merge branch(es):\n\n"); + // Naming the whole failing set invites `--force` (replace fi with one branch + // and start over) when the fix is usually one or two rebases (READY-05). + if (outcome.outcome === "conflict") { + diagnose(renderConflicts(outcome.conflicts, defBranch, inFi, opts)); + } else { + diagnose(bulletList(mergeable, opts)); + // Saying which branch failed is the promise this path makes, so when it + // cannot be kept the report says that rather than leaving a bare list that + // reads as the old behavior. + diagnose( + "\nThe merge could not run, so nothing above names the branch at fault.\nRe-run with --debug to see what git reported.\n" + ); + } - abort("Aborted due to merge failures", opts); + diagnose("\n"); + + // The abort below exits non-zero, so this is the only object `--json` will + // ever write for a failed merge (JSON-03). A pipeline that stops on the exit + // code should not have to scrape stderr to learn which branch needs rebasing. + // + // `branches` is fi as it stands, which the failed merge left untouched: the + // same thing it means after every action that succeeded. What was tried is a + // different list, so it gets a different name. + if (opts.json) { + const readiness = branchReadiness(defBranch); + await writeJson({ + command: action, + branches: fiNow.map((b) => branchJson(b, readiness)), + attempted: mergeable.map(localBranchName), + conflicts: outcome.outcome === "conflict" ? outcome.conflicts : [], + }); } + + abort("Aborted due to merge failures", opts); } diff --git a/src/readiness.ts b/src/readiness.ts index a10295a..501ecd4 100644 --- a/src/readiness.ts +++ b/src/readiness.ts @@ -6,7 +6,7 @@ import { branchAuthors, quotePathEnabled, } from "./git.js"; -import { localBranchName } from "./branches.js"; +import { localBranchName, remoteRef } from "./branches.js"; /** A branch that could not be merged, and what stopped it (READY-03). */ export interface BranchConflict { @@ -18,24 +18,24 @@ export interface BranchConflict { paths: string[]; } -/** What attribution (READY-03) made of a branch list. */ -export interface Attribution { - conflicts: BranchConflict[]; - /** - * False when a probe could not run at all. An empty `conflicts` then means - * "nothing was measured" rather than "nothing conflicts", and the two call - * for different things to be said. - */ - attributable: boolean; -} +/** + * What the merge (MERGE-08) made of a branch list: the tree every branch + * integrated into, the branches that stopped it, or nothing at all when a + * probe could not run: an unresolvable ref, or a shallow clone whose + * histories look unrelated. + */ +export type MergeOutcome = + | { outcome: "merged"; tree: string } + | { outcome: "conflict"; conflicts: BranchConflict[] } + | { outcome: "error" }; type MergeTreeResult = - | { outcome: "clean"; tree: string } + | { outcome: "merged"; tree: string } | { outcome: "conflict"; paths: string[] } | { outcome: "error" }; // 40 hex for SHA-1, 64 for a SHA-256 repository. -const OID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/; +export const OID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/; // merge-tree writes the tree OID, then (with --name-only) the conflicted paths, // then an empty field and the human-readable "CONFLICT ..." block. `-z` makes @@ -57,7 +57,7 @@ function mergeTree(base: string, other: string): MergeTreeResult { // clone's unrelated histories both take it, writing nothing to stdout. The // tree OID is what separates them: a conflict always writes one. if (!OID.test(tree)) return { outcome: "error" }; - if (status === 0) return { outcome: "clean", tree }; + if (status === 0) return { outcome: "merged", tree }; const paths: string[] = []; for (const field of fields.slice(1)) { @@ -67,10 +67,12 @@ function mergeTree(base: string, other: string): MergeTreeResult { return { outcome: "conflict", paths }; } -// The identity is pinned rather than read from config because attribution runs -// on the failure path, where a repo with no user.email configured would turn a -// merge conflict into an unrelated commit-tree error. Nothing references these -// commits, so gc reclaims them. +// Each clean step of the walk, so the next branch has a commit to merge onto. +// The identity is pinned rather than read from config: in a repo with no +// user.email set, reading it would turn a merge conflict into an unrelated +// commit-tree error before the report naming the branch could be written. The +// fi commit itself is the caller's, and takes only the tree from here. Nothing +// references these, so gc reclaims them. function commitTree(tree: string, parents: string[]): string { const args = [ "-c", @@ -81,37 +83,42 @@ function commitTree(tree: string, parents: string[]): string { tree, ]; for (const p of parents) args.push("-p", p); - args.push("-m", "git-fi conflict probe"); + args.push("-m", "git-fi merge step"); return git(args) ?? ""; } /** - * Merge `branches` incrementally against the default branch without touching a - * ref, the index, or the working tree, and report what each failing branch - * conflicts with (READY-03, READY-06). + * Merge `branches` incrementally onto the default branch in the object database + * and report what each failing branch conflicts with (MERGE-02, MERGE-08, + * READY-03, READY-06). * - * A failing branch is left out of the accumulated set, so one bad branch does - * not condemn every branch listed after it. + * The merge and the attribution are one traversal: a clean walk yields the tree + * to commit, and a failing branch is left out of the accumulated set so one bad + * branch does not condemn every branch listed after it. */ -export function attributeConflicts( +export function mergeBranches( branches: string[], defBranch: string -): Attribution { - const base = `origin/${defBranch}`; +): MergeOutcome { + const base = remoteRef(defBranch); const conflicts: BranchConflict[] = []; let accumulated = base; const merged: string[] = []; + // A probe that could not run says nothing about the branch it was measuring, + // and every branch after it would be measured against a set that branch + // should have joined, so the walk stops. Branches already attributed are + // still the honest answer for themselves, and reporting them beats the bare + // "nothing above names the branch at fault" this used to fall back to. + const giveUp = (): MergeOutcome => + conflicts.length > 0 ? { outcome: "conflict", conflicts } : { outcome: "error" }; + for (const branch of branches) { - const result = mergeTree(accumulated, branch); - // A probe that could not run says nothing about this branch, and every - // branch after it would be measured against a set this one should have - // joined. Reporting the first failure as "conflicts with main" and then - // repeating it down the list is the "everything is broken" verdict - // attribution exists to replace, so stop and say nothing instead. - if (result.outcome === "error") return { conflicts: [], attributable: false }; - if (result.outcome === "clean") { - accumulated = commitTree(result.tree, [accumulated, branch]); + const ref = remoteRef(branch); + const result = mergeTree(accumulated, ref); + if (result.outcome === "error") return giveUp(); + if (result.outcome === "merged") { + accumulated = commitTree(result.tree, [accumulated, ref]); merged.push(branch); continue; } @@ -119,10 +126,8 @@ export function attributeConflicts( // Against the default branch alone the accumulated set is out of the // picture, which is what separates "this branch needs a rebase" from "these // two branches overlap". - const vsDefault = accumulated === base ? result : mergeTree(base, branch); - if (vsDefault.outcome === "error") { - return { conflicts: [], attributable: false }; - } + const vsDefault = accumulated === base ? result : mergeTree(base, ref); + if (vsDefault.outcome === "error") return giveUp(); if (vsDefault.outcome === "conflict") { conflicts.push({ branch: localBranchName(branch), @@ -134,11 +139,11 @@ export function attributeConflicts( const peers: string[] = []; for (const peer of merged) { - const vsPeer = mergeTree(peer, branch); + const vsPeer = mergeTree(remoteRef(peer), ref); // An unrun probe read as "this peer is fine" empties the sweep, and the // fallback below then reports a combination-only failure: the confident // wrong answer, where the two probes above say nothing instead. - if (vsPeer.outcome === "error") return { conflicts: [], attributable: false }; + if (vsPeer.outcome === "error") return giveUp(); if (vsPeer.outcome === "conflict") peers.push(peer); } conflicts.push({ @@ -151,7 +156,17 @@ export function attributeConflicts( }); } - return { conflicts, attributable: true }; + if (conflicts.length > 0) return { outcome: "conflict", conflicts }; + // An empty branch list never enters the loop, so the accumulator is still the + // default branch, and its tree is what fi is rebuilt to hold. That is also + // the one read here that can fail on a ref the walk never resolved, so it + // degrades into the same "could not run" the loop reports rather than + // throwing a raw command failure out of the top level. + const tree = git(["rev-parse", `${accumulated}^{tree}`], { + allowFailure: true, + }); + if (tree === null) return { outcome: "error" }; + return { outcome: "merged", tree }; } // Enough paths to recognize what the branches are fighting over, without a wall diff --git a/test/cli.test.ts b/test/cli.test.ts index e221657..40fb077 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,12 +1,13 @@ import { test, before, after, beforeEach, afterEach, describe } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, readFileSync, readdirSync, writeFileSync, chmodSync } from "node:fs"; -import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, readFileSync, readdirSync, writeFileSync, chmodSync, existsSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; import { tmpdir } from "node:os"; import { join, delimiter } from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -import { runFi, makeSandbox, type Sandbox } from "./helpers.ts"; +import { runFi, makeSandbox, DIST_INDEX, type Sandbox } from "./helpers.ts"; const { name, version } = createRequire(import.meta.url)("../package.json"); @@ -612,23 +613,190 @@ describe("working tree state (MERGE-02)", () => { writeFileSync(join(sb.work, "scratch.txt"), "notes\n"); const r = runFi(["--add", "feature-a"], sb.work); assert.equal(r.status, 0, r.stderr); - assert.doesNotMatch(r.stderr, /index is dirty/); assert.deepEqual(listedBranches(sb), ["feature-a"]); }); - test("an unstaged edit to a tracked file blocks the merge", () => { + test("an unstaged edit to a tracked file is left exactly as it was", () => { writeFileSync(join(sb.work, "README.md"), "edited\n"); const r = runFi(["--add", "feature-a"], sb.work); - assert.notEqual(r.status, 0); - assert.match(r.stdout + r.stderr, /Your index is dirty/); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.deepEqual(listedBranches(sb), ["feature-a"]); + assert.equal(readFileSync(join(sb.work, "README.md"), "utf-8"), "edited\n"); + assert.equal(sb.git(["status", "--porcelain"]), " M README.md"); }); - test("a staged change blocks the merge", () => { + test("a staged change survives the merge, still staged", () => { writeFileSync(join(sb.work, "staged.txt"), "staged\n"); sb.git(["add", "staged.txt"]); + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.equal(sb.git(["status", "--porcelain"]), "A staged.txt"); + assert.equal(readFileSync(join(sb.work, "staged.txt"), "utf-8"), "staged\n"); + }); + + test("the merge writes nothing into the working tree", () => { + const before = sb.git(["status", "--porcelain"]); + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 0, r.stderr); + // feature-a adds a.txt, so a checkout-based merge would leave it behind. + assert.deepEqual(readdirSync(sb.work).sort(), [".git", "README.md"]); + assert.equal(sb.git(["status", "--porcelain"]), before); + }); + + test("HEAD stays put and no local fi branch is created", () => { + sb.git(["checkout", "--quiet", "-b", "scratch", "main"]); + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 0, r.stderr); + assert.equal(sb.git(["symbolic-ref", "--short", "HEAD"]), "scratch"); + assert.equal(sb.git(["branch", "--list", "fi"]), ""); + }); + + test("a rejected push strands nothing behind", () => { + // A pre-receive hook that refuses is the remote saying no: the failure the + // commit-and-push half has to survive without the caller having moved. + const hook = join(sb.origin, "hooks", "pre-receive"); + writeFileSync(hook, "#!/bin/sh\nexit 1\n"); + chmodSync(hook, 0o755); + sb.git(["checkout", "--quiet", "-b", "scratch", "main"]); + const r = runFi(["--add", "feature-a"], sb.work); assert.notEqual(r.status, 0); - assert.match(r.stdout + r.stderr, /Your index is dirty/); + assert.match(r.stderr, /git push/); + assert.equal(sb.git(["symbolic-ref", "--short", "HEAD"]), "scratch"); + assert.equal(sb.git(["branch", "--list", "fi"]), ""); + assert.equal(sb.git(["status", "--porcelain"]), ""); + }); + + // Windows has no signal to deliver: `kill` there terminates the process + // without one, so the assertion that the run was interrupted rather than + // finished has nothing to read. The invariant it checks is not + // platform-specific, so the other two jobs cover it. + test( + "an interrupted run leaves the tree and HEAD untouched", + { skip: process.platform === "win32" }, + async () => { + sb.git(["checkout", "--quiet", "-b", "scratch", "main"]); + writeFileSync(join(sb.work, "README.md"), "edited\n"); + + // Ctrl-C during a slow merge. A `git` shim ahead of the real one on PATH + // stalls the first merge-tree and says so, which is what makes the kill + // land mid-merge rather than after a delay a fast machine finishes inside. + const shimDir = mkdtempSync(join(tmpdir(), "git-fi-shim-")); + const marker = join(shimDir, "merging"); + const realGit = spawnSync("which", ["git"], { + encoding: "utf-8", + }).stdout.trim(); + const shim = join(shimDir, "git"); + writeFileSync( + shim, + `#!/bin/sh\nfor a in "$@"; do\n if [ "$a" = "merge-tree" ]; then\n : > '${marker}'\n sleep 10\n fi\ndone\nexec '${realGit}' "$@"\n` + ); + chmodSync(shim, 0o755); + + // Its own process group, so one kill reaches the stalled shim as well, + // which is what a terminal does with Ctrl-C. + const child = spawn(process.execPath, [DIST_INDEX, "--add", "feature-a"], { + cwd: sb.work, + env: { + ...process.env, + NO_COLOR: "1", + PATH: `${shimDir}${delimiter}${process.env.PATH}`, + }, + stdio: "ignore", + detached: true, + }); + const exited = new Promise((resolve) => { + child.on("exit", (_code, sig) => resolve(sig)); + }); + + try { + const deadline = Date.now() + 10_000; + while (!existsSync(marker)) { + assert.ok(Date.now() < deadline, "the run never reached merge-tree"); + await sleep(20); + } + process.kill(-child.pid!, "SIGTERM"); + + // A run that finished before the signal landed would satisfy everything + // below without ever having been interrupted. + assert.equal(await exited, "SIGTERM"); + assert.equal(sb.git(["symbolic-ref", "--short", "HEAD"]), "scratch"); + assert.equal(sb.git(["status", "--porcelain"]), " M README.md"); + assert.equal(sb.git(["branch", "--list", "fi"]), ""); + assert.equal(readFileSync(join(sb.work, "README.md"), "utf-8"), "edited\n"); + } finally { + rmSync(shimDir, { recursive: true, force: true }); + } + } + ); +}); + +describe("the pushed fi commit (MERGE-08, MERGE-10)", () => { + let sb: Sandbox; + beforeEach(() => { + sb = makeSandbox(); + sb.pushBranch("feature-a", "a.txt", "a\n"); + sb.pushBranch("feature-b", "b.txt", "b\n"); + sb.bootstrapFi(); + }); + afterEach(() => sb.cleanup()); + + test("carries the octopus tree and one parent per merged branch", () => { + assert.equal(runFi(["--add", "feature-a", "feature-b"], sb.work).status, 0); + sb.git(["fetch", "--quiet", "origin"]); + + // What `git merge --no-commit --no-ff` writes for the same inputs. The + // index holds the merged result, so `write-tree` names it without a commit. + sb.git(["checkout", "--quiet", "-B", "octopus-probe", "origin/main"]); + sb.git([ + "merge", + "--quiet", + "--no-commit", + "--no-ff", + "--no-edit", + "origin/feature-a", + "origin/feature-b", + ]); + const octopusTree = sb.git(["write-tree"]); + + assert.equal(sb.git(["rev-parse", "origin/fi^{tree}"]), octopusTree); + assert.deepEqual( + sb.git(["log", "-1", "--format=%P", "origin/fi"]).split(" "), + ["origin/main", "origin/feature-a", "origin/feature-b"].map((r) => + sb.git(["rev-parse", r]) + ) + ); + }); + + test("an empty branch list rebuilds fi at the default branch", () => { + assert.equal(runFi(["--force"], sb.work).status, 0); + sb.git(["fetch", "--quiet", "origin"]); + + assert.equal( + sb.git(["rev-parse", "origin/fi^{tree}"]), + sb.git(["rev-parse", "origin/main^{tree}"]) + ); + assert.equal( + sb.git(["log", "-1", "--format=%P", "origin/fi"]), + sb.git(["rev-parse", "origin/main"]) + ); + }); + + test("a local branch named origin/ does not stand in for the remote", () => { + // git resolves refs/heads/ before refs/remotes/, so the short name would + // pick this decoy and put work that was never pushed into the shared fi — + // the one thing MERGE-02 promises cannot happen. git warns that the name is + // ambiguous on a stderr git-fi discards, so nothing would say so. + sb.git(["checkout", "--quiet", "-b", "origin/feature-a", "main"]); + writeFileSync(join(sb.work, "a.txt"), "never pushed\n"); + sb.git(["add", "."]); + sb.git(["commit", "--quiet", "-m", "decoy"]); + sb.git(["checkout", "--quiet", "main"]); + + assert.equal(runFi(["--add", "feature-a"], sb.work).status, 0); + sb.git(["fetch", "--quiet", "origin"]); + + assert.equal(sb.git(["show", "origin/fi:a.txt"]), "a"); }); }); diff --git a/test/helpers.ts b/test/helpers.ts index 5a3cd69..a9bbe7f 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -4,7 +4,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -const DIST_INDEX = fileURLToPath(new URL("../dist/index.js", import.meta.url)); +/** The compiled binary, for a test that has to drive the process itself. */ +export const DIST_INDEX = fileURLToPath( + new URL("../dist/index.js", import.meta.url) +); // A developer who has run `git fi --auth=login` has a real token on disk, and // every run below would resolve it (AUTH-01) and start calling GitLab. Point diff --git a/test/readiness.test.ts b/test/readiness.test.ts index c931f4e..5bba373 100644 --- a/test/readiness.test.ts +++ b/test/readiness.test.ts @@ -419,10 +419,11 @@ describe("conflict attribution (READY-03, READY-04, READY-05)", () => { assert.match(r.stdout, /spaced ünïcode\.txt/); }); - test("a combination-only failure says so rather than naming nobody", () => { - // The combined merge is git's octopus strategy, which has no rename - // detection; the replay is ort, which has. A rename against a concurrent - // edit therefore fails the merge and comes back clean from every probe. + test("a rename against a concurrent edit merges cleanly", () => { + // git's octopus strategy has no rename detection, so this pair used to fail + // the merge and then come back clean from every probe, a failure the report + // could only describe as living in the combination. merge-tree is ort, which + // detects the rename and carries the edit across it. const lines = Array.from({ length: 200 }, (_, i) => `${i}\n`).join(""); writeFileSync(join(sb.work, "big.txt"), lines); sb.git(["add", "."]); @@ -443,9 +444,17 @@ describe("conflict attribution (READY-03, READY-04, READY-05)", () => { sb.bootstrapFi(); const r = runFi(["--add", "renamer", "editor"], sb.work); - assert.equal(r.status, 1, r.stdout); - assert.match(r.stdout, /the conflict is in the combination/); - assert.match(r.stdout, /octopus/); + assert.equal(r.status, 0, r.stdout + r.stderr); + + // The rename wins the path and the edit rides along on it, which is the + // resolution the failure used to stand in for. + sb.git(["fetch", "--quiet", "origin"]); + const merged = sb.git(["show", "origin/fi:renamed.txt"]); + assert.equal(merged, lines + "201"); + assert.equal( + sb.git(["ls-tree", "--name-only", "origin/fi", "big.txt"]), + "" + ); }); test("the working tree is left clean and on the original branch (READY-06)", () => { @@ -458,6 +467,138 @@ describe("conflict attribution (READY-03, READY-04, READY-05)", () => { assert.equal(sb.git(["status", "--porcelain"]), ""); assert.equal(sb.git(["symbolic-ref", "--short", "HEAD"]), "scratch"); }); + + test("a failed merge leaves no file for the report to name (MERGE-11)", () => { + // The branch that conflicts also adds a file of its own. A merge that ran in + // the checkout wrote that file before hitting the conflict, and `reset + // --hard` does not remove an untracked one, which is what the report used + // to hand the reader `rm` commands for. + sb.git(["checkout", "--quiet", "-b", "feature-a", "main"]); + writeFileSync(join(sb.work, "shared.txt"), "from-a\n"); + writeFileSync(join(sb.work, "brought-along.txt"), "a\n"); + sb.git(["add", "."]); + sb.git(["commit", "--quiet", "-m", "feature-a"]); + sb.git(["push", "--quiet", "origin", "feature-a"]); + sb.git(["checkout", "--quiet", "main"]); + sb.bootstrapFi(); + advanceMain(sb, "shared.txt", "from-main\n"); + + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 1, r.stdout); + assert.doesNotMatch(r.stdout, /untracked/); + assert.equal(sb.git(["status", "--porcelain"]), ""); + }); +}); + +describe("local drift from the merged ref (READY-08)", () => { + let sb: Sandbox; + beforeEach(() => { + sb = makeSandbox(); + sb.pushBranch("feature-a", "a.txt", "a\n"); + sb.bootstrapFi(); + }); + afterEach(() => sb.cleanup()); + + /** Commit on `branch` without pushing, so the local branch runs ahead. */ + function commitLocally(branch: string, file: string, content: string): void { + sb.git(["checkout", "--quiet", branch]); + writeFileSync(join(sb.work, file), content); + sb.git(["add", "."]); + sb.git(["commit", "--quiet", "-m", `${branch}: ${file}`]); + sb.git(["checkout", "--quiet", "main"]); + } + + /** + * Push two commits, then rewind the local branch off both, so it runs behind + * by a count that cannot be confused with the ahead count below. + */ + function fallBehind(branch: string, file: string, content: string): void { + commitLocally(branch, file, content); + commitLocally(branch, `${file}.2`, content); + sb.git(["push", "--quiet", "origin", branch]); + sb.git(["checkout", "--quiet", branch]); + sb.git(["reset", "--quiet", "--hard", "HEAD~2"]); + sb.git(["checkout", "--quiet", "main"]); + } + + test("an unpushed commit is named, since fi cannot carry it", () => { + commitLocally("feature-a", "later.txt", "not pushed\n"); + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /fi merges origin\/feature-a, and your feature-a is 1 ahead/); + }); + + test("a stale local branch is named, since fi carries more than it", () => { + fallBehind("feature-a", "later.txt", "pushed\n"); + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /fi merges origin\/feature-a, and your feature-a is 2 behind/); + }); + + test("a diverged branch carries both counts", () => { + fallBehind("feature-a", "theirs.txt", "pushed\n"); + commitLocally("feature-a", "mine.txt", "not pushed\n"); + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 0, r.stderr); + assert.match( + r.stderr, + /fi merges origin\/feature-a, and your feature-a is 1 ahead, 2 behind/ + ); + }); + + test("a branch level with its remote says nothing", () => { + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(r.stderr, /fi merges origin/); + }); + + test("a branch with no local copy says nothing", () => { + // The usual case for a teammate's branch: nothing local to have drifted. + sb.pushBranch("feature-b", "b.txt", "b\n"); + sb.git(["branch", "--quiet", "-D", "feature-b"]); + const r = runFi(["--add", "feature-b"], sb.work); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(r.stderr, /fi merges origin/); + }); + + test("--again stays quiet about branches it did not name", () => { + // Re-merging the set is not a statement about any one branch, so a stale + // local copy of someone else's is noise rather than a signal. + assert.equal(runFi(["--add", "feature-a"], sb.work).status, 0); + commitLocally("feature-a", "later.txt", "not pushed\n"); + + const r = runFi(["--again"], sb.work); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(r.stderr, /fi merges origin/); + }); + + test("a branch sharing no history with its remote says nothing", () => { + // `rev-list --left-right --count` answers a disjoint pair with the size of + // each side rather than failing, so without the merge-base probe every + // commit on both branches reads as drift. + sb.git(["checkout", "--quiet", "--orphan", "rebuilt"]); + sb.git(["rm", "--quiet", "-rf", "."]); + writeFileSync(join(sb.work, "fresh.txt"), "fresh\n"); + sb.git(["add", "."]); + sb.git(["commit", "--quiet", "-m", "rebuilt from nothing"]); + sb.git(["branch", "--quiet", "-M", "feature-a"]); + sb.git(["checkout", "--quiet", "main"]); + + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(r.stderr, /fi merges origin/); + }); + + test("a tag sharing the branch's name does not stand in for it", () => { + // git resolves refs/tags/ before refs/heads/, so a bare name would measure + // the tag and report drift the branch does not have. + commitLocally("feature-a", "later.txt", "not pushed\n"); + sb.git(["tag", "feature-a", "origin/feature-a"]); + + const r = runFi(["--add", "feature-a"], sb.work); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /fi merges origin\/feature-a, and your feature-a is 1 ahead/); + }); }); // `git symbolic-ref` reads the symref file without looking at what it names, so