Compare commits

..
Author SHA1 Message Date
Stefan HallerandClaude Opus 5.5 80b468b6e3 Record PR 11 round 2: the branch below in a stack
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 18:09:22 +02:00
Stefan HallerandClaude Opus 5.5 db5d1be665 Record the new foot branch render-git-diffs-after-layout
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:25:45 +02:00
Stefan HallerandClaude Opus 5.5 305b9a72c2 Record PR 8 round 4: the marks without the focus, and the width they leave
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:00:50 +02:00
Stefan HallerandClaude Opus 5.5 a0b75d37e6 Record PR 8 round 3: the actions take buffer lines
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:54:53 +02:00
Stefan HallerandClaude Opus 5.5 a8afcc9ea2 Record the flaky long-commit-message test and its fixup
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:02 +02:00
Stefan HallerandClaude Opus 5.5 970d2d27b8 Record the stack's rebase onto master after #6061–#6071
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:22:33 +02:00
Stefan HallerandClaude Fable 5.1 55122552ec Record the OverwriteLines fix added to PR 2 round 3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:47:38 +02:00
Stefan HallerandClaude Fable 5.1 2b9bf522e1 Record PR 2 round 3, on a path with a space in it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:17:29 +02:00
Stefan HallerandClaude Opus 5 127a8e1389 Correct what PR 8 round 2 costs a diff renderer, and record its Windows pass
Delta and difftastic both rendered the preview normally with the trees a
line ending apart, so only the warning was ever visible. The setting
behind it was core.autocrlf in the system config Git for Windows
installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 19:48:35 +02:00
Stefan HallerandClaude Opus 5 62879dd982 Record PR 8 round 2, on the line endings of the custom patch's trees
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 19:30:27 +02:00
Stefan HallerandClaude Fable 5.1 a27583c200 Record PR 6's first round
The lint failure at PR 6's tip came out of the 2026-09-19 replay, and the
fixups for it sit on two PRs. Write down why the wrapper leaves PR 6 and
comes back in PR 7, and that the PR 7 commit between the two fixups is
meant to stay unbuildable until they are folded in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 09:38:39 +02:00
Stefan HallerandClaude Fable 5.1 9a45724e6a Record PR 4's first round
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 15:12:21 +02:00
Stefan HallerandClaude Fable 5.1 7526f4d9cd Record the review of the branch rendering without a pty on Windows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 21:25:08 +02:00
Stefan HallerandClaude Opus 5 49d59cf29d Record PR 2 round 2's second fixup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:05:43 +02:00
Stefan HallerandClaude Opus 5 f5af3b6204 Record PR 2's second round
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:14:33 +02:00
Stefan Haller eb341e5a76 fixup! Record PR 11's first round 2026-09-17 20:15:55 +02:00
Stefan HallerandClaude Opus 5 893358211f Record PR 11's first round
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:09:18 +02:00
Stefan HallerandClaude Opus 5 8786b2295a Record the copy PR being split out, and its first round
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 21:06:49 +02:00
Stefan HallerandClaude Opus 5 bb580a03de Record that the scroll refinement moved down to PR 5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 17:26:56 +02:00
Stefan HallerandClaude Opus 5 4a67efe1d1 Record PR 12's first round
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 15:59:01 +02:00
Stefan HallerandClaude Opus 5 540911d04d Record PRs 11 and 12
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:02:28 +02:00
Stefan HallerandClaude Opus 5 ccc48bdb46 Record the assertion that keeps the conflict-hint click on the record
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 12:15:29 +02:00
Stefan HallerandClaude Opus 5 8b5c6ce29e Record that the modified click may open a conflict hint after all
The click names its own line, so it needs none of the gate the edit
keybinding needs, and for a modify/delete conflict the file it opens is
in the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 12:02:18 +02:00
Stefan HallerandClaude Opus 5 a509f46311 Record the first review round of the modified-click branch
The branch's opening commit belongs in PR 5, and moving it there proved
much cheaper than the note in the plan had assumed. Four more findings
needed code, two of them in gocui.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 20:55:52 +02:00
Stefan HallerandClaude Opus 5 e6e7d910da Record why the conflict-hint fix is a commit and not a fixup
A fixup on "Show git's own diff when the renderer's can't be acted on"
would have folded two decisions into one commit: which diff to render,
and whether the content can be pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 18:06:06 +02:00
Stefan HallerandClaude Opus 5 c53b72a3fa Record the round that came of testing the wrap option
Two problems from testing PR 9: wrapLinesInDiffView governed everything
the two main panes showed, and the hint for a conflict that has to be
resolved by picking a side had a selection over it. Both are answered by
having a render say whether it holds the panel's diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:08:00 +02:00
Stefan HallerandClaude Opus 5 6c720558a6 Record the three rounds that came of testing the focused main view
Two bugs reported from using the stack and a third found while reproducing the
first (PR 2 round 1, PR 5 round 5, PR 8 round 1), then two more rounds as each
fix in turn was tested and found short: the pane answered "is there anything to
select here?" one screenful too early (round 6), and then took the answer over
from the commit before while it still couldn't tell (round 7).

Round 6 carries the measurements from the reported commit, which say what each
fix is worth. §8 gains the diffstat row the user has since decided to keep, and
loses the row round 7 closes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 14:59:08 +02:00
Stefan HallerandClaude Opus 5 10ae7e4c20 Record the searched-main-view branch at the foot of the stack
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:57:19 +02:00
Stefan HallerandClaude Opus 5 f5de72780a Record the refresh race the Edit hunk test uncovered
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:47:41 +02:00
Stefan HallerandClaude Opus 5 6dbcd28f0e Record that the Edit hunk port belongs to PR 7
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 20:01:21 +02:00
Stefan HallerandClaude Opus 5 bb0297be1c Record PR 9, its review round, and the branch below the stack
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:30:19 +02:00
Stefan HallerandGitHub Copilot cf24d5cf3d Record the modified-click implementation
PR 10 landed independently of the explorer removal and gained visible feedback for non-suspending editors. Capture its actual dependency, six-commit shape, pending terminal matrix, and the separately deferred click-selection behavior.

Co-authored-by: GitHub Copilot <noreply@github.com>
2026-08-20 17:07:29 +02:00
Stefan HallerandClaude Opus 5 7dce049eae Correct PR 8's commit counts
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:46:45 +02:00
Stefan HallerandClaude Opus 5 a87aa06611 Record PR 8 as implemented, with its deviations
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:45:20 +02:00
Stefan HallerandClaude Opus 5 2b5975375d Record round 5's sign-off and its two further fixes
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:26:16 +02:00
Stefan HallerandClaude Opus 5 0a01a1792d Record the round on the main section changing hands
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:35:26 +02:00
Stefan HallerandClaude Opus 5 1f334e1d4d Record the round on scroll preservation and focus handling
Four problems, the one root cause behind three of them, the prep refactor the
focus rule needed, and the two gaps left open. Also corrects a claim made
while the round was being scoped, that a diff renderer is re-laid-out
whenever the view width changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 08:03:08 +02:00
Stefan HallerandClaude Opus 5 c65733ab22 Record the review round on non-conforming diff renderers
Two defects and their fixes, the testing setup that reproduces them, and
the rebase traps the round turned up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 19:51:24 +02:00
Stefan HallerandClaude Opus 5 47ad0f029d Sign off PR 7 in the plan
Record the four review comments the interactive pass came with, what was
decided for each, and where their fixups landed — three of them belong to
PRs 5 and 6 rather than to PR 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 20:24:20 +02:00
Stefan HallerandClaude Opus 5 e4d462eab8 Record PR 7's first review round
The staged-side branch grew out of it, PR 7 was rebuilt on top, and the
selection-visibility rule became a question about content rather than about
which kind of task rendered it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 15:06:22 +02:00
Stefan HallerandClaude Opus 5 ad852cdb22 Record the branch that moved the staged side to the lower pane
It sits below PR 7, which is rebased onto it and simpler for it: which side a
pane shows is now a property of the pane, and the focus-follow rule is
symmetric.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:51:34 +02:00
Stefan HallerandClaude Opus 5 b000dc3a81 Record PR 7 in the plan
Seventeen deviations, of which the ones that bind later work are the split
seam (PR 8's panels satisfy the extension interface), the stale timing fact
about what a refresh has done by the time it returns, and the restore's new
Done hook.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 08:30:28 +02:00
Stefan Haller cad2e9be9d Sign off PR 6 in the plan 2026-08-15 22:56:57 +02:00
Stefan Haller d7ae6c12ca Record PR 6 in the plan
Two things bind later work: a restore keeps the task reading to the end of
its input, because a rendering that has to be parsed as a diff can only be
parsed whole, and identities match through a key in which every kind of
content line collapses together - which is what lets a change that becomes
a context line still count as the same place, and a hunk header not.
2026-08-15 22:08:58 +02:00
Stefan Haller a19f7705ce Sign off PR 5 and give PR 6 a whitespace-toggle consumer
The interactive pass on PR 5 found nothing to fix; refining the behaviour
of a few special cases can wait until the remaining PRs are written.

Toggling "ignore whitespace" re-renders the diff just like a context-size
change or a renderer switch does, so it belongs in PR 6 as a third
consumer. It is the first one whose anchor can vanish along with its hunk
or its whole file, which invalidates the candidate walk's reason for
stopping at the first change line; the walk goes unbounded instead, so the
restore lands on the nearest surviving line wherever in the diff that is.
And once staging happens in the main view, master's refusal to ignore
whitespace while staging no longer catches it - parked as an open question
for PR 7.
2026-08-15 20:52:55 +02:00
Stefan Haller 87f652b7e6 Reorder sections so that they are in chronological order 2026-08-15 20:27:27 +02:00
Stefan Haller 6017a38098 Note PR 5's diff-line file split in the plan 2026-08-15 19:45:54 +02:00
Stefan Haller 26e069637a Record PR 5's third review round in the plan
The selection commit is split in four, comments no longer point at the
staging view, and the selection commands are described and enabled only
where they apply.
2026-08-15 18:59:29 +02:00
Stefan Haller 0385c85f52 Record PR 5's second review round in the plan
The narrowSelectionHighlight config is dropped in favour of an unconditional
narrow bar, and the selection is clamped to the content at end of input.
2026-08-11 16:27:47 +02:00
Stefan Haller a172026728 Record PR 5's first review round in the plan
Four bugs from the user's pass plus one found while fixing them; also the
open question of whether narrowSelectionHighlight should be a config option.
2026-08-11 07:47:52 +02:00
Stefan Haller 488f742431 Record PR 5 in the productionization plan
Nine commits landed; the jump-to-file menu is skipped and copy moves to
PR 7, where the interface it belongs on gets built.
2026-08-10 07:34:34 +02:00
Stefan Haller 0acbaadcd6 Correct the spec's location and the real-renderer test conditions 2026-08-09 18:10:40 +02:00
Stefan Haller bfb4a4f621 Record PR 4's sign-off and the real-renderer test question 2026-08-09 17:40:21 +02:00
Stefan Haller 9d8e9f6929 Record PR 4 as implemented, with its deviations 2026-08-09 15:53:31 +02:00
Stefan Haller 40bf5fb399 Record PR 2 as implemented, with its deviations 2026-08-09 13:54:45 +02:00
Stefan Haller 81ee437f3a Say newContentPending rather than resetOriginPending
resetOriginPending was the old name that we initially used, it has been
renamed to newContentPending meanwhile.
2026-08-09 12:01:53 +02:00
Stefan HallerandClaude Opus 5 db1734d0ff Record PR 1's final history: no commit repairs an earlier one
The two findings from the interactive pass were folded back, which took the
loading-indicator gate to before the off-screen render — where the blanking
would otherwise arrive — and made the amend! for the origin reset
unnecessary, since the flag it needs now already exists by then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:43:52 +02:00
Stefan HallerandClaude Opus 5 7c136c5755 Sign PR 1 off, and say one thing about the reset flag rather than two
Deviation 6 still described the TaskOpts field as the shape PR 6 should
build on while deviation 8 said it was gone, so a reader hitting 6 first
would follow a design that no longer exists. Deviation 6 now describes the
final shape and what it means for PR 6, and PR 6's own commit 1 says the
same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:09:49 +02:00
Stefan HallerandClaude Opus 5 8e27ed2ff4 Record PR 1's first interactive pass and the branch below the stack
The regression it found retires the PR 6 note from deviation 6, and the
race fix establishes where unrelated bugs found during this work go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:20:19 +02:00
Stefan HallerandClaude Opus 5 8283e997f5 Record PR 1 as implemented, with its deviations
Two of them constrain later work: TaskOpts.ResetOrigin replaces the planned
LinesToRead.ResetOrigin that PR 6 was going to extend, and the screenColMax
gap PR 1 had to fix is still live on the prototype branch that later PRs
transcribe from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 22:57:01 +02:00
Stefan Haller 4ca47482ec Plan for git being able to emit the metadata itself
The plan assumed git would never emit the records, so PR 4 skipped the
advertisement for rawGit renderers and PR 7 listed "how should rawGit
entries decide the raw fallback" as undecided, with a static args-present
rule or a well-formedness gate as the candidates. git can emit them now,
which answers that question better than either candidate: probe a rawGit
entry like any other renderer and the fallback collapses to a single rule
for every renderer type.

Record the two traps as well, since neither is visible from the outside --
the advertisement has to precede newPtyTask's no-pty early return, and the
probe's cache signature has to include the args.

Say explicitly that none of this waits on git upstream. The probe asks the
installed git what it can do, so a stock git degrades to exactly the
behaviour of a non-conforming renderer, and a fork covers the users who
want the feature if the patch is never accepted.
2026-08-08 12:59:00 +02:00
Stefan Haller 001d85153a fixup! Support git's own word diff as a metadata-emitting diff renderer
Probe git for the handshake like the other renderers, not for a record.

git announces itself for exactly the formats it describes, so asked with
the renderer's own arguments the handshake already answers the question,
and on empty input at that. Looking for a record was over-specified and
made git the one renderer probed differently for no reason.

The two probes that now both stand a diff up from two empty temp files
share that setup.
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 5 07ec8a72b6 Advertise the metadata protocol to git as well, not only to a pager
A rawGit diff renderer needs no pty -- git renders the diff itself, and
only a pager needs a terminal to be spawned at all -- so newPtyTask hands
that case straight to newCmdTask. But the OSC1717 advertisement was set
forty lines further down, past that early return, so git was never asked
to annotate its output and the word-diff renderer we just started
trusting emitted no records.

Set it before the branch, next to LAZYGIT_COLUMNS, which is there for the
same reason. Nothing else changes: a renderer that doesn't know the
variable ignores it, and git says nothing for the formats it doesn't
annotate, which is every format a rawGit renderer without word-diff
arguments produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 5 473f83ec49 Support git's own word diff as a metadata-emitting diff renderer
git now emits the diff line metadata records itself, for the word-diff
formats -- the ones whose output can't be read back from its own text,
which is the same reason we need records out of delta and difftastic. So
a rawGit renderer configured with --color-words is no longer a diff we
have to give up on: it names every row it shows, as a patched pager does.

Two things stood in the way. The probe only knew how to ask a stdin
filter or an external diff driver and reported false for anything else,
so git's own records were never looked for; and the focused main view
treated a rawGit renderer with arguments as unresolvable by definition,
re-rendering it raw whatever the probe said.

So probe git the way we will run it, with the renderer's own arguments,
and look for an actual record rather than the handshake the other probes
settle for. git annotates only some of its formats, so an installed git
that doesn't speak the protocol and arguments that select no word diff
both leave us without records, and looking for a record answers both at
once. Arguments that aren't a word diff (-U10, --stat) therefore keep
rendering raw when focused, as they did before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 5 450e295ec3 Correct the diff-metadata notes where they contradict the spec
We stopped updating these notes once the real work moved to
focused-main-view-notes.md and the production plan, but two things in them
have since become actively misleading rather than merely stale: they say in
five places that the protocol is content-lines-only, and they use the env
var name from two renames ago.

Mark the document historical, name the spec as the authority, and correct
§11's outcome banner in place rather than rewriting it, so the reasoning
that led to dropping `f`/`h` is still on record next to the reason it was
reversed -- which is the very cost that banner judged acceptable.

Don't touch the `EMIT_OSC1717_METADATA` spellings further down: they are
what those prototypes were built with, and the status block now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 d0a0046f1c Update the plan for the rebase onto master and the landed config rework
The prototype was rebased onto master past #5854 (gocui mouse gestures)
and #5870 (diff-renderer config rework), so the SHAs quoted in the plan
now resolve only on the pre-rebase copy at
fold-staging-functionality-into-main-view-plan; note that in §1 and in a
dated log in §10.

PR 3 is done: #5870 landed the rename with a bigger config restructure
than planned (per-entry 'type' field, unified 'command', 'rawGit' type,
DiffRendererConfigManager). Rewrite the PR 3 section as a summary of
what landed and its consequences, key the affected decisions in PRs 4-7
off GetDiffRendererType(), add rawGit as a new case for PR 7's raw
fallback, and resolve the open question about the 'pager:' field name.

PR 10 commit 3 is superseded: the rebase dropped the press-only
modifier fix, and #5854's gesture model makes press-only insufficient
anyway; the re-implemented prototype commit carries the press-time
modifiers through the whole gesture, and the plan now points at that
shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 adaca25fa9 Carry the press-time keyboard modifiers through the whole mouse gesture
A click is reported on the button press, but the press never set the
event's modifier, so a modified click (alt/shift/ctrl+click) reached
handlers stripped of its modifier, indistinguishable from a plain
click. An earlier fix for this ("Carry the keyboard modifier on mouse
click events") was lost while rebasing onto master's mouse gesture
handling (#5854) — and that work also changes what the right shape is:
it isn't enough for the modifier to ride the press alone.

Drag events and the release are delivered to the view that owns the
gesture, and bindings match modifiers exactly. If the press carried alt
but the drags carried plain ModMotion, an alt-press that no binding
consumed would start matching unmodified drag bindings mid-gesture
(drag-select), and the release of a modified gesture would look like a
plain one. So snapshot the modifiers at press time and stamp them on
every event of the gesture: the press, each drag event (combined with
ModMotion), and the release; modifier changes while the button is held
don't alter the gesture. TestMouseReleaseDoesNotKeepPressModifiers
asserted the opposite for the release and is inverted accordingly.

As before, this means a modified click that nothing binds is a no-op
rather than silently acting as a plain click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 b50990a8f6 Fix gofumpt formatting slip from the rebase conflict resolution
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 4bcb733b5c Upgrade five prototype-deferred gaps to mandatory production scope
Review clarified that "deferred" in the gap table meant "not addressed
while prototyping", not "optional". Five items are mandatory:

- IsSingleHunkForWholeFile (PR 5 commit 3): regression vs master
  otherwise. Single-file-ness comes from the side panel selection; the
  single-block computation's lean is patch-space over the raw diff
  fetched at focus, with ReadToEnd-on-focus as the accepted fallback.
- Nav targets beyond the loaded content (PR 5 commit 6): ReadToEnd and
  re-scan like openSearch; free if the above reads to end on focus.
  Covers the jump-to-file menu too.
- Copy (PR 5 commit 9): reframed — copy the corresponding raw diff
  lines, never the renderer's output. Dissolves the can't-tell-if-the-
  renderer-preserves-the-prefix-column problem instead of solving it.
- Deleted-file MD-vs-D (new PR 7 commit 5): staging a deleted file's
  entire content stages the file deletion itself.
- Renderer switch shifting the inclusion checkmarks mid-build (new
  PR 8 commit 10): recompute from the new buffer at render completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 bf221a7a7a Update the plan for the difftastic secondary-removal diagnosis
A parallel session committed the zero-width OSC-record carrier
(fe8022827) and diagnosed the secondary patch pane's removal as broken
under difftastic: the ordinal bridge assumes displayed change lines
match the patch's change lines in order and multiplicity (difftastic
reorders d/a per hunk and collapses modification rows), and an external
diff tool over the temp trees emits the literal a/-b/ paths in its
records, so the patch-builder file lookup no-ops. PR 8 commit 7 now
prescribes the identity bridge (all payloads per row, matched against
identities from the raw temp-tree diff, k-th match -> included[k]) and
records the path-leak as an open sub-item to decide with the user; PR 4
commit 1 references the now-committed carrier, which the bridge relies
on for the d half of collapsed modification rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 589c0822ba Keep OSC 1717 records whose region is zero-width
A rendered row can carry several diff-metadata records back-to-back with
nothing painted in between: difftastic's per-hunk banner emits the
file's f immediately followed by the hunk's h, and a modification row
collapsed to a single column emits its d immediately followed by its a
(spec 6.1/6.2). The escape interpreter kept only one accumulating
payload and reset it when the next record started, so every record but
the last was silently dropped -- the banner resolved as a bare hunk
header, and staging a collapsed modification row staged only the
addition half.

Orphan an unconsumed payload instead of dropping it: when a new record
starts (or the line ends) before any cell consumed the current payload,
hand it to the write loop, which materializes it as a content-less
zero-width carrier cell -- the same trick finishLine already used for
delta's metadata-only blank lines, now generalized. Carrier cells hold
their place in left-to-right payload order and are invisible: drawing
paints a transient space that the next cell immediately overwrites at
the same x, and their zero width keeps wrap accounting unchanged.

DiffLineMetadataPayloads thus reports every record of such a row, so
ChangeLinesInViewRange stages both halves of a collapsed modification
row; DiffLineMetadataInLine reports the first (the f of a banner, the d
of a collapsed row), matching the two-column convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 12d3dac8b3 Rework the plan: diff-renderer terminology, landed threading rework
Two corrections from review:

- "Pager" is retired in favor of "diff renderer" throughout (the spec
  already made this change); a new mini PR 3 renames the pagers config
  to diffRenderers with migration and docs, and the remaining PRs are
  renumbered accordingly.

- The locked-decision list wrongly said concurrency stays mutex-based;
  the main-thread-mutation rework has landed on master and the
  prototype is rebased on top of it. The plan now states the landed
  threading contract that all PRs must honor, and PR 1 gained a
  re-validate-against-master preamble. Its reset-after-CopyContent
  commit was verified still needed (master's CopyContent still copies
  the source origin); the two locking fixes were reshaped onto the
  viewLines readers master actually has, since the prototype's
  HyperLinkInLine guard belongs to the dropped hyperlink backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 87701d427f Add the productionization plan
The prototype is complete and signed off; this is the plan for
re-implementing it as a stack of clean PRs off master. It divides the
work into ten PRs (grouped for release-notes value as much as for
technical cohesion), outlines the commits of each, records the scope
decisions made in the planning session (panels removed, enter/dive
gesture dropped, stacked PRs within one release, both extras in scope,
nav/preserve as early standalone PRs), lists everything from the
prototype that must NOT be ported, and carries the sign-off matrix,
separate-lists compatibility seams, and known-gap dispositions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 18e4dba0be Change env var from OSC1717_METADATA to OSC1717 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Fable 5 1fcbc4d5a5 Parse the f/h header records a conforming pager emits
The spec regained file-header (f) and hunk-header (h) records: f never
carries a line number, h always does (the first line of the hunk it
heads). Accept them in the OSC metadata backend, so a conforming pager's
header rows resolve to the same DiffLineFileHeader/DiffLineHunkHeader
identities the buffer parser already reports for raw diffs.

With header rows located, next/previous file navigation and the
jump-to-file menu land on a file's header row, and header rows become
usable scroll-restore anchors. The consumers need a few adjustments:

- File navigation used to reach a file's top by backing up over the
  untagged rows above its first located row. With tagged headers that
  overshoots onto the blank separator row above the file header, so drop
  the back-up (backUpOverHeader) and land on the first located row
  itself: the header for any conforming source, or the first content
  line under a pager that leaves its headers untagged — an accepted
  degradation for non-conforming pagers, now that the spec makes f/h
  mandatory.

- SamePatchLine now requires headers to match headers of the same kind.
  A hunk header shares its line number with the hunk's first content
  line (and a file header shares "0" with a deleted file's hunk header),
  so without this a position restore aiming at one could land on the
  other. This also applied to raw diffs before, but headers used to be
  unlikely restore targets; now that navigation deliberately lands on
  them, the ambiguity would bite.

- Editing a file-header row opens the file without jumping to a line,
  like pressing edit on a whole file in a side panel. (It used to open
  at line 1 for raw diffs, where headers resolved already.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 3b8446d762 Delete the spec from here
We maintain it in the osc-1717-spec branch; I want to avoid confusion as
to which one is newer, so remove it from here.
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 1e6e55a755 Add a jump-to-file menu to the focused main view's diff
n / N step through the files of a multi-file diff one at a time, which
is tedious when the diff spans many files. Add `f`, which pops up a menu
listing every file in the diff — in the order they appear, as
repo-relative paths — so you can jump straight to one.

Picking a file reuses the exact landing logic n / N use (navigate to the
file's first row), computed from the same backUpOverHeader machinery
AdjacentFile uses, so the menu and the step keys agree on where each
file begins and land identically.

The menu title and the keybinding description are hard-coded English for
now; this is a prototype.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 97b1b4cf7f Offer commit and find-fixup-base in the focused main view
The focused main view now plays the role the staging panel used to,
so the working-tree commands the staging panel offered — commit (and
its variants) and find-base-commit-for-fixup — need to be reachable
there as well.

Gate them to when the focused main view actually shows the working-tree
diff (DiffMainViewTypeStaging), which is exactly the role the staging
panel filled. Over a commit's or stash's diff these commands would
operate on the working tree, unrelated to what's on screen, so the keys
are a no-op there and the bindings don't clutter its keybinding menu.

The gate is re-checked on each press rather than captured at
registration time, since one keybinding set serves the main view over
every panel. That requires reading the panel beneath the main view from
GetKeybindings (to decide whether to show the descriptions), which runs
for off-stack contexts too — at startup and during cheatsheet
generation — so a panic-safe IsInStack guards the NextInStack lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 140cc03fd1 Session notes: rebased onto master's rename support — §21.36
Records the two rename-handling gaps the rebase onto f84ada494 leaves
for productionization: the previousPath="" conflict resolutions in the
focused-main-view patch code, and the failing renamed_file_whole e2e
(a view-rendering regression in the prototype's patch package, not a
build regression and not the previousPath issue).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller f8fe248678 Shorten the diff-metadata env var to OSC1717_METADATA
Pager authors reviewing the protocol asked to drop the EMIT_ prefix: the
OSC number already names the protocol, so EMIT_ was redundant. Rename the
handshake variable to OSC1717_METADATA in the spec and in the two places
lazygit advertises it (the pager PTY and the metadata probe).
2026-08-08 12:59:00 +02:00
Stefan Haller 7f5296205d Allow changing context size during custom patch building 2026-08-08 12:59:00 +02:00
Stefan Haller 6e5e2b4612 Session notes: §21.35 follow-ups — temp dir, gutter-on-secondary, toggle auto-advance 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 7f66f7620d Keep the inclusion gutter visible when the secondary patch pane is focused
The inclusion gutter (the ✓ markers on the commit diff showing which lines are
in the custom patch) is painted on the Normal pane but is an affordance of the
whole focused-main-view pair. It was gated on the Normal pane specifically being
current, so tabbing to the secondary (custom-patch) pane hid it — even though
both panes are visible and you're still building the patch.

Show it whenever either pane of the focused main view holds focus, finding the
side panel beneath whichever pane is current. GetOnFocusLost now re-evaluates the
gutter (rather than unconditionally hiding) so it persists across a pane switch
but still hides when focus leaves the pair; the new context is already current by
then, so it decides correctly and doesn't flicker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 ece5f0fb66 Advance to the next hunk after toggling one into a custom patch
Staging a hunk from the focused main view advances the selection to the next
hunk, because staging removes the acted-on lines from the diff so the preserved
change-line ordinal lands on the next change. Toggling a hunk into a custom
patch left the selection sitting on the just-toggled hunk instead — the toggle
doesn't change the diff (only the inclusion set), so the same ordinal lands back
where it was, and you had to navigate by hand to build a patch hunk by hunk.

Give the toggle the same feel by advancing the reveal ordinal past the toggled
change lines (RevealSelectionAfterStaging gains an advanceBy arg, fed the toggled
change-line count). Staging and removal still pass 0 (their lines are consumed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 49df8caf82 Create the custom-patch temp dir under lazygit's configured temp dir
Use osCommand.GetTempDir() (lazygit's own per-session temp dir, which it
creates and cleans up) as the parent for the custom-patch diff trees, instead
of the OS default — so it honors the configured temp dir and is cleaned up with
the rest of lazygit's temp files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller c3d6b39aeb Session notes: patch-building secondary pane — space-removes + pager rendering (§21.35) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 da4dfa17d5 Render the custom-patch secondary pane through the pager, via a real git diff
The secondary pane showed the custom patch with a bespoke in-memory render
(PatchBuilder.RenderAggregatedPatch / FormatView). That had two problems: it
could never be fed to a pager the way the main view's diff is (a stdin pager
might have worked, but an external diff tool like difftastic, which diffs two
files rather than a unified diff, could not), and its hand-rolled hunk/context
handling differed subtly from git's.

Materialize the patch instead as two real file trees under a temp dir — a/ holds
each patched file's "from"-side content, b/ that content with the patch applied —
and render it with `git diff --no-index`, reusing the exact pager wiring the main
view uses (stdin pager, external diff, or git's own colour as the raw fallback).
`--no-index` honors both GIT_PAGER and --ext-diff, so every pager type now renders
the custom patch like any other diff, and git computes the context, fixing the
quirks. Because the secondary is now an async diff task, the post-removal
selection reveal (which rides a task's restore) finally takes effect.

The trees are named a/b so that with --no-prefix the diff shows the real
repo-relative paths; added files are seeded empty in a/ so they pair up and show
their real paths rather than git's directory-comparison "added in b" form. The
patch builder owns the temp dir's lifetime (created on Start, removed on Reset)
and bumps a generation counter on every change, so the trees are rebuilt only
when the patch actually changes — covering the focused-main view and the old
explorer alike, without rebuilding on mere navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 f7e26fec4e Make the patch-building secondary pane's space remove the right lines, disable its discard
The focused-main-view rework made the secondary pane actionable, but the old
patch-building explorer's secondary was inert, so its actions were never thought
through. Pressing space there routed through the same toggle handler as the main
pane, resolving the selection against the secondary's diff and mapping it to
patch-builder indices by line number. But the secondary shows the *aggregated*
custom patch, which renumbers included additions whenever an earlier addition in
the same hunk is excluded (Transform recomputes each hunk's +start). So the
shifted number resolved to the wrong line in the original diff — often adding an
unrelated line instead of removing the selected one.

Resolve the secondary selection by its *ordinal* among the change lines shown
instead: the custom-patch view renders exactly the included change lines in
order, so the k-th change line of a file is that file's k-th included change line
(PatchBuilder.IncludedChangeLineIndices), independent of the renumbering. Space
in the secondary now only ever removes, mirroring how space in the staging view's
staged pane unstages.

Discarding from the commit (the remove key) makes no sense in the custom-patch
preview — it would act on lines shown only as the patch, and space already
removes them — so it's disabled there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 6f83f02d92 Session notes: delta-vs-selection bg fight resolved (§21.34) 2026-08-08 12:59:00 +02:00
Stefan Haller f4384add58 Try yet another solution for the fight over the bg color (delta vs. selection) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 d355cb58a3 Session notes: selection-preserve net across commit rewrites (§21.33)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 3375637d93 Drop the bespoke commit-discard selection reveal
Discarding lines from a commit via the focused main view installed its own
revealSelectionAfterPrimaryAction before the rebase. That rebase rewrites
the commit, so the selection-preserve net now re-establishes the selection
as the diff re-renders — with the same anchor (the selection's first line),
making this install redundant. Files-discard keeps its own reveal: its diff
command is stable, so the net never fires there, and it has staging's
focus-follow to the secondary pane besides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 fdb16a1c7e Preserve the focused main view's selection across commit rewrites
Dropping a hunk with `d` re-establishes the focused main view's selection
on the next surviving change, which feels great. But other operations that
rewrite the commit under the focused main view — moving a custom patch out
into the index, undoing right after a discard or a patch move — don't run
through the focused-main-view action handlers, so nothing was preserving
the selection. The stale gocui selection was left painted over the new
content, often as a large, now-meaningless range.

Rather than teach every such command to capture and restore the selection
(move-patch, undo, redo, and any future one), the focused main view now
preserves it itself, by its change-line ordinal, as the diff re-renders —
the command-agnostic counterpart of revealSelectionAfterPrimaryAction. The
diff side panels call it from their render-to-main before triggering the
render, so the restore rides the re-render.

It stands down unless the focused main view is current and shows a
selection, no precise restore is already pending (escape / post-stage
reveal / context-size place the selection more precisely), and the diff
command is actually changing. That last gate matters: a plain background
refresh re-renders the same commit's diff unchanged, and there the
selection — range and all — must be left alone; only a command change
(e.g. a rebase rewriting the commit's hash) means the content moved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 49e2fbc076 Session notes: drop commit SHAs from §21.32
SHAs go stale on every history rewrite of this throwaway branch; describe
commits by what they did instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller b3ac8fc259 Session notes: correct the patch-explorer claim for the driver fix (§21.32)
The driver fix is a no-op for the old staging panel, not a tightening: that
panel anchors its drag at the click on mouse-down, so it never exposed the
bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 0f99ba625a Session notes: first-drag-event driver fix (§21.32)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 6481ee615a Session notes: drag-to-range anchoring fix (§21.32)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 96fa098845 Anchor a drag-selection at the clicked line in the focused main view
Click-and-drag in the focused main view turns a hunk selection into a range,
but it was anchored at the change block's far end — where selecting a hunk
leaves the range anchor — rather than the clicked line. And on a context
line, where the click leaves no range anchor at all, dragging just moved the
single selected line instead of opening a range. Remember the line each
mouse-down lands on (the click can show a whole hunk, so it can't be read
back from the view) and, as the drag proceeds, anchor the range there while
the cursor end follows the mouse as gocui already moves it. Works for a click
that focuses the view and for one while it's already focused, on change and
context lines alike.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 79ce7f3382 Session notes: two hunk-mode click/pager bug fixes (§21.32)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 3e4207d7fa Preserve a whole selection's extent across a pager or context-size change
When the diff re-renders for a new pager (or a changed context size),
PreserveDiffPositionOnRerender restored the cursor by patch identity but left
the selection's other end — the range anchor — pinned to its old view line. A
pager that restructures the diff (delta side-by-side is the clearest case)
then left the selection spanning the wrong range of patch lines. Remember the
far end by patch identity too and put it back the same way, so the selection
covers the same lines however the new pager lays them out. One mechanism now
restores both ends, covering hunk and range selections alike; a single-line
selection still needs only the cursor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 e03f4b1321 Carry hunk mode into the other pane on a cross-pane click
Clicking the staged/unstaged pane you weren't focused on selected a single
line the first time, even in hunk mode, because that pane's select mode was
still its default until it had been focused once (tabbing to it and back was
the workaround). Seed the clicked pane's mode from the one we're leaving so
the very first click there behaves like every later one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 0aab64e362 Session notes: hunk-on-click UX tweaks in the focused main view (§21.32)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 d80802c1f9 Keep hunk mode when clicking another hunk in the focused main view
Once in hunk mode, clicking another change line reset the selection to a
single line, so you had to press `a` again for each block you wanted to
stage (a behaviour the main view inherited from the staging and patch-
building panels). Preserve hunk mode across clicks instead: a click on a
change line re-selects that whole block, while a click on context — or any
click when we weren't in hunk mode — drops to a single line, where the click
points precisely (e.g. to edit it).

The two click handlers shared an identical selection body, so unify them
into one helper and make the change in a single place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 8395658e75 Select the clicked hunk when clicking into the main view in hunk mode
Focusing the main view by keyboard already selects a whole change block in
hunk mode; a click, though, only ever placed a single-line selection, so the
common "click the diff to stage this block" gesture still needed a follow-up
`a`. Now a click on a change line selects that line's block too. A click on
context still selects just that line, since the click points at it precisely
(e.g. to edit it with `e`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 6ac8c47091 Try another solution for the fight over the bg color (delta vs. selection) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 3f3139de71 Spec: add diff-so-fancy as a third reference implementation
§10 now lists diff-so-fancy alongside delta and difftastic: the same #2 case as
delta's default (strips +/- markers, conveys side by color) but a line-oriented
Perl filter, unified-only, and -- the one wrinkle worth stating in the spec --
it strips terminal escapes from its content, so the record is prepended to the
line rather than embedded. Fix the now-stale "all three"/"both pagers" counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 9273bb7e23 Design notes: diff-so-fancy emitter prototype (§12)
Record the third pager emitter (diff-so-fancy, commit c397cd6 on its
prototype-osc-metadata branch): the simplest of the three -- a line-oriented
Perl filter, unified single-column only, the same #2 category as delta's
default. Captures the diff-so-fancy-specific findings: sanitize_display strips
OSC so the record is prepended not embedded; the path is derived from
$file_1/$file_2 (not $last_file_seen, which is empty for a noprefix deletion);
combined diffs skipped; counters are file-scope globals so a chunk-spanning hunk
keeps counting; classification mirrors strip_leading_indicators so the
no-newline marker is correctly skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller fc134d643f Session notes: patched the three pager emitters + the spec (§21.31) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 a6fedd0255 Spec: define the version-only handshake record
Add §4.4: a conforming pager emits a version-only OSC 1717 record (no further
fields) as its first output, so a host can probe it on an empty diff — which
emits no per-line records — and tell "speaks the protocol" apart from
"unsupported pager", content-independently. Used by the focused main view's
raw-diff fallback to decide whether a pager's output is actionable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 40c035fd79 Session notes: detection reworked observe → probe (§21.30) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 c0ee7de776 gocui: swallow the version-only diff-metadata handshake OSC
A metadata-aware pager emits a version-only OSC 1717 record (no fields) as its
first output, to announce it speaks the diff-line-metadata protocol so lazygit
can probe for it. It isn't per-line metadata, so on a real render it must be
swallowed whole rather than lingering in the accumulator and attaching to the
following diff-header line (or, if newline-terminated, producing a phantom blank
line). Drop any OSC 1717 payload with no fields at its terminator; per-line
payloads always have fields, so they're kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller f3ef02556a Session notes: (D) raw-diff fallback for unsupported pagers done (§21.29) 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 ae1ab5ca1e Extend the raw-diff fallback to the commit/stash/patch-building panels
Wire the remaining diff panels — local commits, sub-commits, commit files,
stash, and reflog — into the focused main view's raw-diff fallback, the same way
the files panel already is: each computes DiffMainViewShouldRenderRaw and renders
its diff raw (no pager) when focused under a pager whose output we can't resolve,
so its selection stays toggleable into a custom patch (or, for reflog, navigable
and copyable).

The commit-diff panels build their diff through the diff helper, which can't
reach the staging helper, so the panel computes renderRaw and passes it in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 10f41bed50 Thread ignoreExternalDiff through the remaining diff-cmd builders
Prep for extending the focused main view's raw-diff fallback beyond the files
panel. ShowCmdObj, ShowFileDiffCmdObj, ShowStashEntryCmdObj and DiffCmdObj gain
the ignoreExternalDiff arg WorktreeFileDiffCmdObj already has — forcing git's own
coloured diff regardless of a configured external diff command. All callers pass
false, so behaviour is unchanged.

Also lift the pty-vs-command task choice out of the files controller into
types.NewMainViewDiffTask(WithPrefix), so the commit/stash/patch-building panels
(whose diffs are built in the diff helper, which can't reach the staging helper)
can select the same raw-fallback task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 2a69d4c140 Fall back to the raw diff when focusing a main view under an unresolvable pager
The focused main view is the staging surface, so it has to resolve the diff it
shows to patch-space. A pager that restructures the diff without emitting our
metadata (stock delta-default, plain difftastic, `cat -n`) produces output the
buffer parser can't read, which would leave its diff unstageable.

When such a diff is focused, re-render it raw — git's own colour, no pager — the
same content a no-pager setup shows, which the buffer parser handles. Browsing
keeps the pretty pager output; only focusing to act switches to raw.

Whether the pager is usable is decided by probing it: run it on empty input and
look for the version-only OSC 1717 handshake a metadata-aware pager emits first.
This is a pager-level, content-independent fact (a binary file, which has no
change lines under any pager, can't mislead it) and it's known before we render,
so we never render pretty only to discover mid-flight that we should have rendered
raw. The verdict is cached per pager (reset when the pager changes). No PTY is
needed — git needs a terminal to decide to invoke a pager, but the pager emits the
handshake regardless. A git-config external diff driver (useExternalDiffGitConfig)
is chosen per file via .gitattributes and a single diff can mix drivers, so
there's no one pager to probe; it's treated as unsupported (always raw).

Bypassing the pager needs two things, since a pager reaches the diff by two
routes: an external diff command (suppressed in the cmd via the new
ignoreExternalDiff arg, keeping git's colour, unlike plain) and a stdin pager
(GIT_PAGER, applied by the pty task — so the raw render uses a plain command
task instead).

This wires the files panel; the commit/stash/patch-building panels follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 482acff66d Session notes: (C) ctrl+o copy done (§21.28)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 f0a997705a Copy the selected diff line(s) from the focused main view
`ctrl+o` in the focused main view copies the selection to the clipboard,
mirroring the staging view's copy. Unlike stage / discard, copying is the same
for every diff panel — it just reads the text shown — so it's a direct
MainViewController command rather than a FocusedMainViewActions method. That
keeps it free of per-panel duplication and lets it work over panels that have
no actions, notably the reflog.

The +/- prefix handling is metadata-aware where it matters: with no pager the
main view shows the raw diff, so a homogeneous selection has its +/-/space
column stripped (dropDiffPrefix) to ease pasting into code. With a pager
configured the rendered lines carry no such column, so stripping a leading
character would eat real content — copy verbatim instead. (Refining the raw
case to classify header lines via the diff-line metadata, fixing the
'--- a/file' edge case noted in 159bbb0825, is left for later.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 a0af1b1d9a Session notes: discard bug fixes + selection visibility (§21.27)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 252a26cbc4 Hide the focused main view's selection when there's no diff to act on
The focused main view always showed a selection while it held focus, even when
its content was a placeholder ("No changed files", a merge-conflict message)
rather than a diff — so pressing a navigation key conjured a highlighted line
over "No changed files", and discarding the last change (or changes vanishing
outside lazygit) left a stale selection behind.

A selection should only show when there are change lines to act on. Two
moments establish or change the content:

- Focus: showInitialDiffSelection now leaves the selection off when the view
  has no change lines (ViewHasChangeLines), rather than highlighting a stray
  line at the top.
- Render: the side panel's render-to-main is where it decides between a diff
  and a placeholder, so that's where the selection's visibility is set —
  updateFocusedMainViewSelectionVisibility shows it only on the focused pane
  and only when a diff is being rendered. This covers the refresh cases that
  focus can't: discarding the last change, and changes disappearing or
  reappearing outside lazygit, all hide or restore the selection on the next
  refresh. (Focusing reuses the already-rendered content rather than
  re-rendering, which is why focus needs its own check.)

Wired into the files panel, where placeholders occur; the commit panels always
render a diff. Adds a SelectionIsShown/SelectionIsHidden test assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 26dcab23ae Session notes: (B) d discard a hunk done (§21.26)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 e9c4425df6 Discard the selected diff line(s) from the focused main view
Wire the focused main view's `d` binding to a DiscardSelection action on the
side panel beneath, the discard counterpart of the primary action. As with
stage/toggle, what discard means is the panel's business:

- the files panel discards from the working tree, mirroring the staging
  view's `d` — reverse-apply not cached on the unstaged side (destructive, so
  confirmed), reverse-apply cached on the staged side (i.e. unstage);
- the commit panels (commit files, and the whole-commit diff of the local
  commits / sub-commits / stash panels) remove the selected lines from the
  commit via a rebase, mirroring the patch builder's "discard lines from
  commit" — building a one-off patch from the selection and deleting it.

The commit-discard backend and its guard are shared free functions in
patch_building_from_main_view.go, paralleling the patch toggle. Discarding
from a commit is only possible on a local branch, so DiscardSelection grows a
disabled-reason companion on the FocusedMainViewActions interface: the `d`
binding is greyed with a reason on stash and other-branch sub-commits (never
rebaseable) and while a rebase is in progress, exactly as the patch builder
greys it. The files panel is always available (a zero-context diff is the one
error it reports inline, matching the staging view).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 99f80b1bc8 Generalize FilesController.stageDiffLines to applyDiffLines
The focused main view's discard command (coming next) needs to read either
side's diff and apply a patch with an arbitrary direction and target: discard
from the working tree reads the unstaged diff but applies a reverse patch that
is not cached. stageDiffLines couldn't express that — it used a single reverse
flag both to pick which diff to read and to reverse the apply, and hardcoded
Cached: true.

The read side and the apply direction are independent (they coincide only for
staging and unstaging), so split them: applyDiffLines now takes sourceCached
(which diff to read) and a git_commands.ApplyPatchOpts (how to apply). Renamed
from stageDiffLines since it now covers stage, unstage, and discard. Staging
passes the same values as before, preserving behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 2d5d149f5e Session notes: (A) dispatch refactor done (§21.25)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 88dae2f2c6 Collapse the focused-main-view handler channels into one
Each command a side panel handles in the focused main view needed its own
delegation channel: a HasKeybindings getter, an IBaseContext Add method, a
BaseContext field + getter, a baseController nil default, and an attach.go
registration — roughly five touch points per command. With three commands
(click, stage, toggle-patch) that was already a lot of boilerplate, and it
doesn't scale to the discard / copy commands coming next.

Replace the three channels with one: a side panel exposes a single
FocusedMainViewActions interface via GetFocusedMainViewActions (nil when its
diff offers no actions), and the controllers implement that interface
directly. The stage and toggle-patch handlers, already unified to a plain
error return, become one PrimaryAction method whose meaning is the panel's
business (stage for the files panel, patch toggle for the commit panels);
the click handler becomes OnClick. MainViewController is now a thin
dispatcher: fetch the actions from the panel beneath, call the method.
Adding a command is now one interface method, an implementation in the two
or three controllers, and a keybinding — no plumbing.

The toggle-patch channel did double duty as the "this panel builds a custom
patch" signal for the inclusion gutter (shown only beneath a patch-building
panel, not the staging files panel). Collapsing the channels removes that
proxy, so make the classification explicit on DiffMainViewContext, whose
marker method now returns a DiffMainViewType (none / staging / patch-building)
instead of being a bare marker. The gutter shows only beneath a panel whose
type is patch-building (commit files, local commits, sub-commits, stash).

Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 96b09bb41d Push the staging focus-follow into the side-panel handler
The focused main view's space binding delegates to the side panel beneath
it, but the two handlers were asymmetric: the patch-toggle handler did its
own re-render and re-established the selection itself, while the staging
handler returned a focusViewName so the dispatcher (stageRange) could do the
reveal-and-focus dance on its behalf. That split blocks collapsing the
per-command handler channels into one, since the two have different return
types.

Make staging match the toggle: GetOnStageFocusedMainView returns plain
error, and the FilesController handler does the post-staging reveal and pane
focus itself. The reveal/select-mode logic the two handlers shared (collapse
a range to a line, preserve the change-line ordinal across the re-render,
re-expand a hunk) is extracted into revealSelectionAfterPrimaryAction, with
mainContextForViewName resolving a main view name to its context. The
dispatcher is now uniform: read the selected range, hand it to whichever
handler the panel registered.

Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 1b02020022 Session notes: revised forward plan (§21.24) — drop steps 7/8, add commands + dispatch refactor + pager fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 4fe5211da0 Session notes: crash fix for resetting a patch built from the commits main view
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 f4e180c128 Session notes: step 6c done (patch building from the whole-commit main view)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 86ce3ba798 Build a custom patch from the commits / sub-commits / stash main views
Until now space toggled lines into a custom patch only from the commit files
main view (the per-file diff). Register the same toggle handler on the commits,
sub-commits and stash panels, so a patch can be built straight from the
whole-commit (multi-file) diff their main view shows, without first diving into
the commit files panel.

The handler lives on SwitchToDiffFilesController, which is already bound to
exactly those three panels and already knows how to derive the patch target
(from/to/reverse/canRebase) for the selected ref — pulled out of enter() into a
shared canRebase helper and the reused FromAndToForDiff. The post-toggle refresh
is the cheap one: these panels have no per-file patch indicator to update, so we
just re-render their own main + secondary views rather than reloading the whole
commit list on every keystroke.

Sub-commits and stash didn't render the secondary patch view at all; give them
the same secondaryPatchPanelUpdateOpts the commits panel uses, and recompute the
inclusion gutter as their diff (re-)renders, so the cumulative patch and the
gutter track the toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 26b27ed69b Extract the patch-toggle back end out of CommitFilesController
The next commit adds patch building from the commits / sub-commits / stash
main views, which toggle the same way as the commit files main view; only the
patch target and the post-toggle refresh differ. Pull the panel-agnostic back
end (the toggle skeleton, the line toggle, the post-toggle reveal, the patch
filename mapping) into shared free functions so both controllers reuse it.

The skeleton now takes the patch target (from/to/reverse/canRebase) and a
refresh callback as parameters rather than reading them from the commit files
context and hardcoding Refresh({COMMIT_FILES}). The commit files handler passes
exactly what it did before, so behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 e074c40cc9 Session notes: first interactive feedback on the gutter (4 of 5 fixed)
Records the visibility model (gutter visible iff patch-active AND main
focused), the selection re-select after the split re-render, the
all-wrapped-segments marker, and the deferred pager-switch bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 c14d9432cb Session notes: toggle refreshes normally; scroll preserved for free
Records the fix for the §21.20 live-update gap: the patch toggle now
refreshes (updating the browser indicators + secondary patch summary +
the split), scroll/selection are preserved automatically because the
re-render uses the same diff command, and the gutter rides the same
re-render via the pipeline's own content-equality test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 115582e9bc Session notes: step 6 built — inclusion gutter + patch toggle (6a/6b)
Records what landed, the confirmed sync (no re-render) insight, what
needs interactive sign-off, the known limitations, and the 6c/step-7
next steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 67ba1899b4 Build a custom patch from the focused main view of a commit's files
Pressing space in the commit-files focused main view now toggles the
selected line(s)/hunk into or out of the custom patch, instead of being a
no-op. It's the patch-building counterpart of working-tree staging from
the main view: the selection is resolved to change-line identities the
same way (ChangeLinesInViewRange), then mapped to the patch builder's
per-file line indices and added or removed.

The crux is that the commit's diff is unchanged by a toggle — only the
inclusion set changes — so unlike staging there's no async re-render to
ride. Membership is shown by an on-demand inclusion gutter: a reserved
left column painted with a marker on every change line currently in the
patch, recomputed synchronously after each toggle and on focusing the
main view, over the existing pager output. This is what lets patch
building work over a restructuring pager at all, where the old green
first-char overlay (which rewrites the rendered bytes) can't.

space routes to staging or patch building based on which handler the
panel beneath registers, via a new onTogglePatchFocusedMainView channel
(kept separate from staging because the post-action differs: sync gutter
repaint vs async reveal). Scoped to the commit-files panel for now;
commits/sub-commits/stash and the whole-commit multi-file diff follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 2fee7653f2 Extract stageRange from stageSelectedLine in the main view
Prep for routing space to custom-patch building when the panel beneath
the focused main view is a commits/commit-files panel. stageSelectedLine
becomes the router; the staging-specific body (async re-render + reveal)
moves to stageRange. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 4c123f9414 Add identity-based line accessors to PatchBuilder
The patch builder works in patch-line-index space, which the patch
explorer can supply directly from its parsed-patch state. The focused
main view can't: it only knows a selection as diff-line metadata
identities (file line number + deletion?), and those indices differ
between the raw diff and however a pager renders it.

Add the conversion at the boundary so the main view stays in identity
space: PatchLineIndicesForLines maps identities to indices (for
toggling) and IncludedLineIdentities reports the included change lines
as identities (for the inclusion gutter to match rendered rows against).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 12523c8a20 Add an on-demand inclusion gutter to gocui views
A reserved left-hand column that draws a per-line marker glyph and shifts
the content right past it, for the custom-patch inclusion overlay (which
needs to show which change lines are in the patch over arbitrary pager
output, where it can't touch the rendered bytes).

It's pure draw-time decoration: the content buffer, and so the diff-line
metadata, click resolution and wrapping inputs, are untouched. The marker
is drawn on a line's first wrapped segment only, and the gutter narrows
the content wrap width while shown. Off by default; SetInclusionGutter
turns it on per view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 01882cf2fd Session notes: step 6 design — the on-demand inclusion gutter
Records the plan for patch-building from the main view: the sync (no
re-render) vs async (staging) split, the included-set mapping, the
user's gutter decisions (on-demand, inclusion-only, checkmark), the
draw-time gocui rendering approach, and the 6a/6b/6c decomposition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 22e1c82405 Session notes: prototype-branch callout; step 5 signed off; step 6 next 2026-08-08 12:59:00 +02:00
Stefan Haller 2421e19f5d Session notes: reveal reworked to preserve change-line ordinal 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 cf89ef9d4f Preserve the change-line ordinal in the post-stage reveal
The reveal advanced the selection by capturing the next/previous change as a
patch identity and finding it again after the re-render. No line-number
identity is stable and unique across a stage, though: a deletion shares its
new-file number with the rest of its block (so staging one deletion of a block
landed back on the block's first line), and in the staged pane the new-file
number is the index, which staging/unstaging shifts (so unstaging a
modification's deletion missed its replacement line and fell back to an earlier
block). The previous worktree-line and adjacent-change-line matching each fixed
one case and left another.

Do instead what the staging view does: preserve the selection's ordinal among
change lines. Read the acted-on line's ordinal from the pane it was in before
the op; after the re-render, select the change line at that ordinal in the
target pane (clamped to the last). Since the op removes the acted-on change
line(s), that ordinal then holds the next surviving change — the next line of
the same block, or the next block when a whole block was staged. This needs no
reasoning about which side's line numbers are stable, so it handles deletions
and the staged pane uniformly.

The matching machinery (matchByWorktreeChange, AdjacentChangeLine) is gone; the
identity-matched restore (escape, -U preserve) and the new positional restore
now share installDiffLineRestore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 133dabb394 Session notes: line-mode reveal advances to next line, not next block 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 f461378598 Advance to the next change line, not the next block, in the reveal
The post-stage reveal picked its primary candidate with AdjacentChangeBlock,
which skips the rest of the acted-on line's block. That's right in hunk mode,
where the whole block is staged, but wrong in line mode: staging the first
line of a multi-line block left the rest of the block in place, yet the
selection jumped past it to the next block instead of landing on the next
(now first) line of the same block.

Use AdjacentChangeLine — the first change line strictly after/before the
selection — for the next/previous candidates. It unifies both modes: after a
whole block (whose last line is followed by context) the next change line is
the next block's, so hunk mode is unchanged; after a single line the next
change line is the next line of the same block. place stays mode-aware
(hunk-expand vs single line), so nothing else changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller df09df1940 Hack: inset the selection highlight by 3 in the main view
This makes it possible to see what lines delta renders as red or green.

It's not pretty, but makes testing easier for now. Needs a better
solution eventually.
2026-08-08 12:59:00 +02:00
Stefan Haller 377a352075 Session notes: rebased onto wrapped-fill branch; blank-line metadata + reveal worktree-match fixes 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 dcdc919a0e Match the post-stage reveal by worktree line, not old-file line
The reveal that advances the focused main view's selection after staging
captured the next/previous change block as a patch identity and found it
again in the re-rendered diff via SamePatchLine. SamePatchLine keys a
deletion on its old-file (index-side) line number, which staging shifts:
staging a hunk that changes the line count moves the index-side numbers of
every hunk below it. So a deletion-led "next hunk" candidate no longer
matched, and the reveal fell back to a worse candidate — often colliding
with a header or context row (SamePatchLine doesn't require a change line)
and, in hunk mode, snapping to the first change block. The visible result
was the selection jumping to an *earlier* hunk after staging.

Match the reveal's candidates by their worktree (new-file) line number,
which staging never moves, and require a change line of the same side. This
is safe here because the reveal only ever targets change blocks and
selectHunkAround expands to the whole block, so the new-file number's
ambiguity between two consecutive deletions (the reason SamePatchLine uses
the old-file number) doesn't matter.

The escape restore and the -U context-size preserve keep SamePatchLine:
they re-render the same staged/unstaged state, so no index-side shift, and
the latter deliberately anchors on context lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 131a315b7f Keep a metadata-carrying cell for a blank changed line
delta, fed a colored diff, renders some blank changed lines (an empty
deleted/added line) as just the OSC 1717 metadata followed by an empty
line — either with no fill at all, or as a background plus an ESC[0K that
the buffer now turns into a draw-time trailing fill rather than content
cells. Either way the line ends up with no cells, so its metadata had
nowhere to live and the line resolved to "not a change".

In the focused main view that broke change-block detection: a blank
deleted line in the middle of a hunk split it in two, so pressing 0
selected only the lines above the blank and the down arrow jumped to the
rest below it.

Restore the content-less sentinel cell finishLine used to add, but only
when there is pending OSC metadata to carry — so an empty line stays
resolvable as the change it is, without reintroducing the unconditional
sentinel (and its prevFgColor coupling) that was removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan Haller 2088408f43 Session notes: step 5 part 2 done — focus follows the acted-on side to the right pane 2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 05c7b82a84 Follow the acted-on side to the right pane after staging from the main view
After staging or unstaging from the focused main view, focus stayed on the
pane the user acted in even when the side they were acting on moved to the
other pane. Two cases got it wrong:

- Unstaging the first hunk of an only-staged file splits the diff: the main
  half flips to show the just-unstaged change, and the staged remainder jumps
  to the secondary half — but focus stayed on the main half, away from the
  staged content the user was working through.
- Unstaging the last staged hunk from the secondary half empties the staged
  side and collapses the split, hiding the secondary half — leaving focus
  stranded on a hidden pane.

The rule is the same in both: focus the staged side while it survives. The
stage handler now reports which focused-main pane should hold focus —
the secondary half when unstaging leaves the file split, the main half
otherwise — and the controller re-selects the revealed change in that pane
and focuses it. The split is read from the model, which Refresh has already
updated synchronously by the time the handler returns; the re-render it
queues is what the reveal rides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 a2e1254a96 Let the post-stage reveal target a different pane than the one acted on
Staging or unstaging can move the acted-on side to the other half of the
focused main view — unstaging the first hunk of an only-staged file splits
it, pushing the staged remainder to the secondary pane. The reveal that
re-selects the nearest surviving change after the op then has to ride the
re-render of that other pane, not the one the user acted in.

Split RevealSelectionAfterStaging's single view into a source (where the
candidate lines are read) and a target (where they're found again and
re-selected), and get-or-create the target's buffer manager so the restore
can be installed on a pane that hasn't rendered yet. Behavior-preserving:
the sole caller passes the same view for both, as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00
Stefan HallerandClaude Opus 4.8 e58f6d43ad Session notes: part 1 signed off; part-2 focus switch unifies with only-staged unstage bug; deleted-file note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 c2dc9c329f Session notes: step 5 part 1 (post-stage reveal) done; part 2 (empty-pane focus switch) scoped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4882760690 Advance the selection to the next change after staging from the main view
Staging or unstaging a selection re-renders the focused main view's diff (the
staged lines move to the other side), which left the selection at a now-stale
view position — often off the new, shorter content, so it looked like there was
no selection at all.

Install a restore before the re-render that lands the selection on the change
nearest the one just acted on, the same way the staging view advances: the
change block after the selection, else the one before it, else the acted-on line
itself (which only survives when the whole side was staged and the view flips to
the other side). It re-selects in the current mode, so hunk staging walks hunk to
hunk. A range selection collapses to a single line first, since staging consumes
it. This rides the existing restore-by-identity machinery (as the escape restore
does), so it works over any conforming rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 01b2f621f9 Session notes: step 3-4 signed off (SxS works incl. difftastic); unstage focus issues -> step 5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 8b04f49c2b Session notes: step 4 done (SxS multi-record + multi-file staging)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4a33b42d19 Stage both sides of a side-by-side row from the focused main view
A side-by-side rendering (patched delta in side-by-side mode) carries more than
one diff-line record per rendered row: the deletion on the left, the addition
replacing it on the right. DiffLineContent.Metadata keeps only the first payload
— enough to identify a single-column row, but it drops the right side — so a
range staged from such a view would miss half the changes.

Add a gocui accessor for all the distinct metadata payloads a row's cells carry,
and have the range collector resolve every one of them, so staging a
side-by-side row includes both sides. You can't stage just one side of a
side-by-side row; that's an accepted restriction (switch to a single-column
rendering to do it). Rows without metadata (no pager, or the buffer-parse /
hyperlink backends) keep falling back to their single resolved record, so
single-column staging is unchanged.

The full delta-side-by-side chain can't run in the integration harness (no real
pager), so it's covered by a gocui unit test for the multi-payload accessor;
end-to-end behaviour needs interactive verification with the patched delta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 a5a0506913 Stage a selection spanning several files from the focused main view
The focused main view of a directory node shows a multi-file diff, so a range
selected in it can span more than one file. Group the selected change lines by
file and apply one patch per file, logging the action and refreshing once around
the whole batch. The stage/unstage direction is uniform — the whole diff is
rendered on one side — so it's decided once for the batch.

Each change line is mapped back to its file by the path its diff-line metadata
carries, which also lets a single-file selection go through the same path
instead of relying on the selected node being the file; staging from the focused
main view no longer bails on a directory selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ec1bdaafb3 Session notes: record session-13 interactive feedback on step 3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3acbaac361 Session notes: step 3 done (range + hunk selection + range staging)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 26a784ef74 Select the first hunk when focusing the main view in hunk mode
With hunk mode the default (gui.useHunkModeInStagingView, on by default),
focusing the main view now selects the whole first visible change block rather
than a single line, matching what entering the staging view does and what the
just-added hunk staging expects. In line mode it still anchors on a single line,
as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4b57cc9efe Select and stage ranges and hunks from the focused main view
Step 1 made the focused main view a staging surface for a single line. This
adds the range and hunk selection the staging view has, so you can stage (or
unstage) a range or a whole hunk in place — `v` starts a range, shift-up/down
extend one, `a` toggles hunk selection — without diving into the staging view.

The selection mode (line / range / hunk, plus sticky-range and "the user turned
hunk mode on themselves") lives on MainContext rather than the controller,
because three places need a pane's mode: the main view controller that drives
it, the focus controller that resets it when you focus the view, and togglePanel
which sets it on the *other* pane. The selected line and the range anchor stay
in the gocui view itself (its cursor and rangeSelectStartY), so the existing
native range-select rendering draws the highlight — no new highlight machinery.
Hunk bounds come from the diff-line metadata (the isChange run around the
cursor, via ChangeBlockBounds), not from a parsed patch, so they work over any
conforming rendering. The up/down keys are mode-aware: hunk mode steps hunk to
hunk, a non-sticky range collapses on a plain move, a sticky range extends.

GetOnStageFocusedMainView now takes the selected view-line range instead of one
line; the files handler collects the change lines in the range and applies a
single patch. It identifies each change line's patch line by scanning the parsed
patch and matching (file line number, deletion?) identities, rather than looking
each number up with PatchLineForLineNumber. That lookup can't tell an addition at
the very start of a hunk from the deletion above it, nor an addition from the
deletion it replaces on a modified line — both of which a range routinely spans.
As a side effect this also fixes staging a change on the first line of a file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ee0077a157 Session notes: step 3 design settled, ViewSelectionController fold done
Record §21.10: step 1 is committed (always-show selection + single-line
staging), step 3's prep (the ViewSelectionController fold) is done, and the
step-3 feature design is fully settled — mode state mirrored from the staging
machine in view-line space, gocui's native range-select for rendering, ↑/↓ made
hunk-aware, config-driven hunk default, and range-aware staging. Captured so the
work resumes cleanly in a new session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 8c7a41c559 Fold ViewSelectionController into MainViewController
ViewSelectionController owned the focused main view's line navigation (up/down
move the selection or scroll, page up/down, goto top/bottom). It was extracted
long ago in case other scrollable views (e.g. a focused command log) would reuse
it, but none ever did — it's only attached to Normal and NormalSecondary, the
two focused main views MainViewController already controls.

Keeping the navigation in a separate controller is now in the way: the next
change makes up/down hunk-aware (move by hunk when hunk-select mode is on),
which needs the selection mode that lives in MainViewController. Rather than
teach the generic-looking ViewSelectionController about that mode, fold its
bindings and handlers into MainViewController and delete it. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 843543b596 Session notes: plan to merge staging into the main view, and step 1
Record the session-11 reframe (use the diff-line-metadata primitive to fold
staging and custom-patch-building into the focused main view, dropping the
separate explorer views) with its feasibility analysis and an 8-step plan, plus
the session-12 findings from implementing step 1 (always-show selection +
single-line staging): the DiffMainViewContext predicate, the first-visible-change
anchor, the tab-selection fix, and two carried-forward concerns — the
per-command handler-channel won't scale, and delta's background-conveyed side is
hidden under the selection highlight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 494b9ce7cf Stage the selected diff line from the focused main view
The focused main view already lets you point at a diff line and act on it
(dive into staging, edit it, open it in a PR). This makes it a staging surface
in its own right: press space to stage — or unstage — the selected line without
diving into the separate staging view.

To make that usable, the selection is now shown automatically whenever the main
view holds a diff, anchored on the first change line already visible (so the
view doesn't jump), rather than being toggled on demand from the middle of the
view. That frees space for staging and means there's always a line to act on.
Which contexts show a diff is marked by a new types.DiffMainViewContext, because
"shows a diff" is the right signal, not "is stageable": reflog shows a diff (its
selection drives edit/PR/navigation) but can't be staged, while a branch's log
or the status dashboard show no diff and get no selection at all.

Staging is delegated to the side panel beneath via a GetOnStageFocusedMainView
handler mirroring GetOnClickFocusedMainView, so what "stage" means stays the
panel's concern (the working tree stages/unstages; commits will later add to a
custom patch). The files handler resolves the line's patch identity from the
diff-line metadata, maps it to a patch line, and applies a one-line patch,
choosing stage vs unstage from whether the shown diff is the unstaged or staged
side (diffSplitState).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 d23fe24100 Extract diffSplitState from the files diff renderer
The files diff renderer decides, from the selected file's staged/unstaged
status and the splitDiff config, whether the focused main view is split into
unstaged/staged halves and — when not split — whether the single view shows the
staged diff. A second consumer is about to need the same decision (staging a
line directly from the focused main view must know whether the shown diff is
staged or unstaged, to pick apply vs apply --reverse). Pull it into a method so
the two can't drift from each other.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 9bb070be76 Session notes: the context-change flicker, pinned and fixed
Updates §20.5 with the pinned cause (the restore scan ran after the swap, so
the new content was briefly shown at the stale scroll) and the fix, plus
productionization notes: the resolve-then-swap ordering is an invariant of the
restore mechanism, the irreducible post-swap mapping window, and that fast
renders unmask latent transients slowness was hiding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 fced2c616a Scan for the restore target before revealing the new content
On a position-preserving re-render the first paint swapped the off-screen
content in and only then ran Apply, which for the buffer-parse backend (no
pager) scans the whole diff to locate the line to land on. That scan takes
tens of milliseconds on a large diff, during which the new content was already
displayed at the *old* scroll position — a layout draw landing in that window
showed a frame at the stale (and now out-of-range) scroll, a pronounced
flicker when changing context size while scrolled down. The metadata/hyperlink
backends didn't show it because they resolve the target during the load, so
their Apply is instant.

Let Apply own the swap: it locates the target against the still-off-screen
(and, at end of input, complete) buffer first, then calls swapIn and settles
the scroll. The scan now runs while the previous content is still displayed, so
the new content is revealed already at the right position — matching what the
early-resolving backends already did.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 7f8c6f9a89 Session notes: the O(n2) diff-line scan, batched to O(n)
Records the scan-cost diagnosis and the batch-resolver fix (§20), and the
context-change flicker the speedup unmasked (§20.5), still to be pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ecc8b177fa Resolve diff lines in one batch pass instead of once per line
Changing the -U context size or switching pagers preserves the scroll
position by scanning the re-rendered diff for the line to land on. Each scan
resolved every buffer line through the per-line resolver, whose buffer-parse
backend re-parses that line's whole file section on every call — so a scan was
O(n2) in the diff length. On a 9600-line diff, changing context took ~33s with
no pager (worse with delta); the file/hunk navigation scans had the same cost.

Route the whole-buffer scans (the position restore's nearbyDiffLines and
end-of-load resolution, and the file/hunk navigation) through a new
resolveDiffLines, which parses each file section once for the whole buffer and
applies the metadata/buffer/hyperlink precedence on top — O(n). The incremental
restore scan now resolves only the rows that loaded since it last looked, using
the per-row backends (metadata/hyperlink) that don't need surrounding context;
the buffer-parse case still resolves once the diff is complete. The single-line
resolver (clicks) is unchanged.

Measured on a synthetic single-file diff, the whole-buffer scan drops from
1.7s to 1ms at 1700 lines and from ~107s to 15ms at 6800 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 b2685cae95 Add gocui accessor for the newly-loaded rows of an off-screen render
The restore scan polls the off-screen render after each line read to find its
target. OffscreenDiffLineContents rebuilds a snapshot of every loaded row each
call, so polling per line is O(n2) on a large diff. Add
OffscreenDiffLineContentsFrom, which returns only the rows from a given index
onward, so a scan that remembers how far it has read can process just the new
lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 9a5c1ac82d Add a batch buffer-diff parser that parses each file section once
The diff-line buffer-parse backend (mechanism #1) resolves one line at a
time, re-parsing that line's whole file section on every call. The position-
restore and navigation scans resolve every line of the buffer, so they pay
that whole-section parse once per line — O(n2) on a large single-file diff.

Extract the per-section parse (parseFileSection) and the section-bounds
search (fileSectionBounds) out of parseDiffLineFromBuffer, and add
parseAllDiffLinesFromBuffer, which walks the file sections once and resolves
every line in a single O(n) pass. Behavior-preserving: parseDiffLineFromBuffer
now delegates to the same per-section parser, so a single-line lookup is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller 3c9a30c620 Drop f/h types again 2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 39556e80f3 Keep the middle line stable across a context-size change
When {/} change the -U context size with no selection showing, we re-anchor
the re-rendered diff on the top visible line. Anchor on the middle visible
line instead, so the diff appears to pivot around the line you're most
likely looking at rather than around its top edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4c6af62424 Select the line in the middle of the content, not the viewport
Pressing space in the focused main view starts the selection at the middle
row of the viewport. When the diff is shorter than the viewport that row is
empty space below the content, so the selection clamps onto the last line
instead of landing somewhere useful. Anchor on the middle of the visible
content instead; once the content fills the view this is the same row as
before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 9db3094e80 Drop the synthesized-deletion limitation from the OSC spec
Re-verified empirically: a pure deletion's new-line is exact at default context
(difftastic emits d;4;4) and only drifts at --context 0 (d;1;4, low by the elided
count); old-line is exact either way. lazygit renders at default context, so the
drift isn't reachable in normal use, and even at zero context the only effect is
that 'e' on a deleted line opens the new file a few lines off — inherently
approximate, since the line isn't in the new file. Too marginal to keep as a v1
limitation, so §8 collapses to the single token-vs-line note. Re-verification
recorded in the design notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 1eb032abe8 Revise the OSC spec draft after the first review pass
Incorporate the round of feedback before circulating to pager developers. The
substantive decisions:

- Make f and h (file-header / hunk-header) mandatory v1 record types and drop the
  speculative o; a header carries new-line = its hunk's first line so e lands near
  what the user is looking at, and a multi-row header block (delta's box) re-emits
  the record on every row, mirroring the wrapped-row rule. This replaces the old
  reserved-and-unused framing and the host-side backUpOverHeader guessing.
- A host must ignore an unrecognized type rather than reject the record, so the
  type set can grow without a version bump.
- Drop sections that were redundant or over-stated: §5.4 (the side is already
  motivated in §1), §8.1 (c/a old-line is a host concern with no consumer), §8.4
  (word-diff), and the at-most-two-records and trailing-fields paragraphs.
- Tighten the difftastic token-vs-line note (now §8.1): the c-on-a-pure-addition
  old line only happens in difftastic's AST mode and the record faithfully matches
  what difftastic renders (dim context); its line/Text fallback emits d/a normally.
- Document the rename rule for the file field, and reframe §7 as the host access
  model the carrier is designed for rather than a description of the prototype.

Also record in the design notes the one user-visible bite of the token-vs-line
model: cross-pager selection preservation (delta d vs difftastic-AST c) — a host
concern kept out of the spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 a1eec2f3b6 Session notes: alt/shift-click a diff line to open it in the editor
Record session 9 (§19): the modifier-click interaction, the per-terminal
probe findings that drove the alt+shift choice, the two gocui pieces, and
a planning hint that it lands as a separate PR at the very end of the
productionization stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4b5858cf7a Alt- or shift-click a diff line to open it in the editor
delta's clickable line-number hyperlinks let you jump to the editor from
the diff, but only on the line-number gutter (a small, fiddly target,
present only on added/context lines and costing horizontal space). Add a
modifier-click that opens whatever diff line is under the cursor — the
whole line is the target, deletions included, and the gutter is no longer
needed.

Both alt- and shift-click are bound because no single modifier survives
every terminal's mouse handling: Ghostty forwards alt (and keeps shift
for text selection), iTerm2 forwards only shift, and VS Code forwards
both. Whichever a terminal delivers triggers the edit; the one it keeps
for itself never reaches us. Right-click and ctrl-click were ruled out —
terminals variously claim them for context menus, promote ctrl-click to a
secondary click, or strip the modifier.

Unlike the `e` keybinding it doesn't require focusing the main view or
holding a selection, and being registered with HandleWhenPopupPanelFocused
it works while a popup covers the view — so you can jump to code shown
behind the commit-message panel, just like the hyperlinks did.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 5b0091f650 Extract editDiffLine from editLine
The `e` keybinding resolves the file/line of the selected diff line and
opens it in the editor. A forthcoming right-click handler needs the same
resolve-and-edit step for the clicked line rather than the selected one,
so split it out behind a view-line-index argument. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 aa4ff00898 Let a mouse binding opt into firing while a popup panel is focused
Mouse clicks on a view other than the focused popup panel are normally
swallowed by the ShouldHandleMouseEvent gate, so a registered click
handler can't run while a modal is up. Hyperlink clicks already dodge
this by being handled in an earlier phase; generalize that to ordinary
mouse bindings via a HandleWhenPopupPanelFocused flag, dispatched before
the gate. No binding sets it yet, so behavior is unchanged.

This is what lets a click on the main view stay live behind a popup
(e.g. opening a diff line in the editor while the commit-message panel is
in front), the way the wheel already scrolls it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 a31755de22 Rename the diff-line-metadata OSC from 456 to 1717
The terminal-allocation audit settled the protocol's OSC number on 1717 (unused
by every surveyed terminal — see diff-line-metadata-osc-spec.md), retiring the
456 placeholder. Rename the host side to match: the gocui carrier that accumulates
and reads back the sequence, the parser, and the handshake env var the pager
subprocess is given (now EMIT_OSC1717_METADATA). Flip the design notes and spec
from 'rename pending' to done.

The delta and difftastic emitters are renamed in their own repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4572f5ebfb Draft the diff-line-metadata OSC spec and settle the OSC number
Resolve the OSC number from the 456 placeholder to 1717. There is no central
registry for OSC numbers, so the audit reduces to: pick a high, distinctive
number that no real terminal *acts on* — an unknown OSC is skipped harmlessly,
but a recognized one can fire a visible side-effect (OSC 555 flashes foot, OSC
777 raises a desktop notification), and the metadata flows through real
terminals whenever the pager runs outside a host. Audited the live OSC
allocations of xterm, VTE, kitty, foot, WezTerm, iTerm2, Windows Terminal,
Ghostty, VS Code, ConEmu and urxvt; 1717 collides with none and sits in the
empty 1400-5000 band (only iTerm2's 1337 is nearby).

Write the spec as a standalone draft to circulate to pager developers for
feedback — separate from the internal session notes, with motivation, the v1
wire format, the env handshake, semantics, emit rules (including the per-row
wrapping correction and the side-by-side two-records-per-row case), and the
known v2 candidates (both-numbers-always, the difftastic token-vs-line
mismatch). The prototype code still emits the 456 placeholder; the 456->1717
rename across delta/difftastic/gocui/lazygit and the EMIT_OSC<n>_METADATA env
var is a tracked follow-up, not part of this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 4701aca3fe Design notes: pager-switch scroll preservation (a new diff-line consumer)
Record session 8 — preserving scroll/selection when cycling pagers, as
the seventh consumer of the identity-based restore. Captures why both the
plain-pager (GIT_PAGER, line-number kept) and externalDiffCommand
(command changes, reset to top) cases needed it, the "main"-window-only
scope, the graceful no-op fallback, and that the side-by-side<->inline
structural win awaits the §17.4 row+column resolver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 de8b19c247 Preserve the diff scroll position when switching pagers
Cycling pagers re-renders the diff into the main view. Until now that
either lost the scroll position outright — an entry with its own
externalDiffCommand changes the actual git command, so the re-render
reset the view to the top — or kept it only by raw line number, which a
plain pager swap got for free because the git command was unchanged.

Raw line number is the wrong anchor: two pagers can structure the same
diff very differently (side-by-side vs inline), so the same screen line
means something different afterwards. Reuse the identity-based restore
(PreserveDiffPositionOnRerender, already driving the -U context-size
consumer) to re-anchor on the same patch line instead. That both keeps
the position meaningful when the structure changes and covers the
externalDiffCommand case the line-number approach couldn't.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0cceaecfda Design notes: delta wrapped-row metadata bug now fixed
The same pager-level wrapping gap found in difftastic (§10.8) was fixed in delta
too. Delta only wraps in side-by-side mode, so the bug was SxS-only; each wrapped
continuation row now re-emits the record of the primary line it continues,
without advancing delta's line-number counters (so subsequent lines keep correct
numbers). Update §9.3, §10.8, and the §17.1 correction note accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 08f2105636 Design notes: wrapped rows must carry metadata (a spec correction)
Testing the difftastic prototype in lazygit exposed that the "wrapped
continuation rows carry no attachment" convention is a bug whenever the pager
itself wraps a long line (difftastic side-by-side, delta with wrap-max-lines):
each wrapped row is a distinct host buffer line, so e/enter and hunk navigation
break on the un-tagged continuation rows. The fix -- emit the line's record on
every output row, not just the first -- was applied to difftastic; delta needs
the same. Record the underlying distinction (terminal/host wrapping vs
pager wrapping) and state the spec rule positively (§10.8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3189c7535a Design notes: record the difftastic OSC metadata prototype findings
difftastic is now prototyped as an OSC-metadata emitter in both its modes
(side-by-side and inline), the one pager mechanism #1 categorically cannot
serve. The notes capture what was built and, more importantly, what it reveals
about the v1 format: it holds, but difftastic's token-granular structural model
surfaces a model mismatch the unified-diff pagers hid -- an aligned row whose
old side has no novel tokens is faithfully a context cell plus an addition cell,
with no deletion record for the old side. Also resolves the "how many regions
per row" open question (two, not N) and amplifies the §17.3 "context/added carry
no old-line" limitation, since difftastic's old/new line numbers diverge with no
constant offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 b87607a5b5 .claude/settings.json: allow access to difftastic repo
The OSC-metadata prototype now extends to difftastic (the categorical #2-only
pager), checked out at /Users/stk/Stk/Dev/Builds/difftastic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 d4cdeca8ca Design notes: record the side-by-side delta prototype findings
A parallel session added per-line OSC metadata to delta's side-by-side mode
(in the delta repo, not lazygit) and recorded its findings here as §17, with
the §15 roadmap updated to mark step 3 done.

The headline, which feeds the OSC spec draft: side-by-side needs no change to
the v1 wire format — `type` already implies the column (a→right/new, d→left/
old) and context is symmetric, so no side discriminator is warranted. This
corrects §15's earlier guess that side-by-side would likely add payload, and
leaves the OSC number as the spec's only remaining open item. Also flagged for
productionization: the host reader becomes row+column→identity (a changed row
carries two records), which the §16 consumers haven't yet been exercised
against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller 312d5710c2 .claude/settings.json: allow access to delta repo 2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 1479a51cb9 Design notes: record the two showcase consumers (session 7)
Hunk/file navigation (#4) and -U-context-change scroll preservation (#5) are
built; document the design, decisions (main-view-only scope, offset-preserving
placement, file nav landing on the file top, the visibility guard) and the
pending interactive sign-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 a967fc3099 Navigate the focused main view by file and hunk
Add file and change-block ("hunk") navigation to the focused main view,
mirroring the staging view's hunk keys: `<left>`/`<right>` jump to the
previous/next hunk and `n`/`N` to the next/previous file. A "hunk" here is
lazygit's notion — a run of consecutive added/deleted lines separated by
context, not a git `@@` section — matching what the staging view jumps
between.

This is a consumer of the diff-line primitive in its forward direction:
resolve each rendered row's patch identity, then scan for the next/previous
change block (by the line type) or file boundary (by the path changing). The
file scan lands on the top of the neighbouring file even when a restructuring
pager leaves the header rows untagged, by backing up over them from the
file's first identifiable row — which is impossible without the per-line
metadata once the pager stops emitting a parseable unified diff. The
boundary arithmetic is pulled out into pure functions and unit-tested.

The anchor is the selected line if a selection is showing, else the top
visible line. With a selection we move it to the target and scroll it into
view, like the staging view; with none we stay in scroll mode, bringing the
target to the top without creating a selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3b6568cf9f Preserve the diff scroll and selection when the context size changes
Increasing or decreasing the diff context size (the `{`/`}` keybindings)
re-renders the diff with a different `git diff -U<n>` command. Because the
command key changes, the render reset the main view to the top — losing the
spot the user was reading, which is exactly the spot the context change is
about.

Preserve it instead, reusing the identity-based restore built for the escape
path. This is its sibling consumer: capture the lines around the anchor (the
selection, or the top visible line when there's none) as restore candidates,
and after the re-render land on the nearest one that survived, put back at the
same screen row. Prefer the anchor line itself, falling back outward only when
it didn't survive: a context line vanishes when the context size shrinks,
whereas additions and deletions always survive, so expansion stops at the
first change line in each direction and the candidate list always contains a
survivor. Landing on the nearest survivor keeps scrolling to a minimum, and a
context line that is still in the patch stays put (or stays selected).

This generalizes the shared restore helper to take a prioritized candidate
list (the escape path passes a single candidate). It covers the focused main
view and every side panel's diff, since they all render into the same "main"
view through the same path. A showing selection is re-established on the landed
line; otherwise the view stays in scroll mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ef01d66412 Extract the identity-based restore into a context-neutral helper
The escape restore's mechanism — set a RenderRestore that scans the
re-rendering content for a target patch identity and, once it loads,
positions the view on the matched row — is about to gain a second caller:
preserving a diff view's scroll/selection when its -U context size changes
re-renders it (the sibling consumer of the diff-line primitive). That caller
positions the row differently (put it back where it was, rather than scroll to
and select it), so split the positioning out behind a `place` callback and
keep the scan/swap machinery shared.

Behaviour-preserving: RestoreFocusedMainViewOnEscape passes the same
FocusPoint-and-select closure the inline Apply used.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller ed72851579 Reset the scroll to the top at first paint, not when the task starts
When a main view re-renders content different from what it last showed, the
scroll resets to the top. That reset fired synchronously when the task started —
but with the off-screen render the previous content stays displayed until the
swap, so resetting the origin up front scrolled that still-visible content to the
top before the new content replaced it: a distracting jump when switching commits
(or any item) while scrolled down.

Defer the reset to the task's first paint (the swap), alongside the restore that
already runs there: the previous content stays at its scroll until the new content
takes its place, then the new content appears at the top. A same-content re-render
keeps its scroll (no reset); a restore places the scroll itself. The "loading..."
indicator path also resets the origin now, since it clears the previous content to
show the message and must put it at the top.

The reset moves out of NewTask (it no longer needs the task key or the pending
restore for this) into the read loop, driven by LinesToRead.ResetOrigin, which the
cmd/pty wrappers set from the key comparison the reset used to do. The manager's
onNewKey callback is renamed resetOrigin to match its now-decoupled timing.
2026-08-08 12:58:59 +02:00
Stefan Haller af147d49f7 Lock the view while reading viewLines on mouse move
onMouseMove (and findHyperlinkAt, which it calls) read v.viewLines without
holding writeMutex, unlike every other reader. They run on the event-handling
goroutine, so a re-render on the task goroutine can shrink or rebuild viewLines
between onMouseMove's bounds check and findHyperlinkAt's indexing, causing an
out-of-range panic (observed: "index out of range [60] with length 0" while
hovering during a diff re-render).

Take writeMutex for the duration, like the other viewLines readers do, so the
check and the access see the same slice. Pre-existing, but the off-screen
re-render rebuilds viewLines on the task goroutine more often, widening the window.
2026-08-08 12:58:59 +02:00
Stefan Haller 35c3bbdee5 Design notes: record the identity-based restore (part 3, session 6)
§14: the escape restore now anchors on a patch identity scanned from the loading
re-render (items 1+3 of the part-3 plan, which collapsed into one mechanism), the
partial §12.2 routing fix, and the analysis for the deferred pieces (the (b)
no-clobber lever's interaction with the entry origin reset, NormalSecondary
routing, the hyperlink-backend match limitation, the O(n^2) scan). Records that
interactive sign-off — including the still-pending session-5 scrollbar/stopped-task
checks — remains, since the agent couldn't drive the TUI.
2026-08-08 12:58:59 +02:00
Stefan Haller 63b794b278 Return to the focused main view when escaping the staged half too
Diving into staging from a focused main view records a snapshot so escape can
return there. But it was recorded only on the half we entered (unstaged), and
staging the last unstaged hunk moves the selection to the staged half
(RefreshStagingPanel pushes StagingSecondary when the unstaged state goes empty).
Escaping from there found no snapshot and fell back to the files panel, instead
of returning to the focused main view.

Record the snapshot on both staging halves at entry, and clear both on escape.
Escaping from either half now returns to the focused main view, and the
identity-based restore lands on the line the explorer ended up selecting — which,
with the last unstaged hunk gone, is shown in the main view (the file now has only
staged changes, so no split). Routing the split-and-tabbed-to-staged case to the
secondary focused main view is left for follow-up; see focused-main-view-notes.md
§14.3.
2026-08-08 12:58:59 +02:00
Stefan Haller d3bf88c52c Restore the focused main view by patch identity on escape
Escaping a patch explorer (staging / patch building) back to the focused main
view it was entered from used to replay a numeric scroll position and selection
index captured on the way in. But the reason to escape after staging or dropping
a hunk is that the content changed, so a saved index points at the wrong line —
and the host auto-advances the explorer's selection to a still-valid line anyway,
which is the line the user actually cares about returning to.

Restore by *patch identity* instead. On escape, read the (file, type, source
line) the explorer currently has selected, then have the main view's re-render
land on the row that matches it: scan the incoming content as it loads (the
inverse of the diff-line primitive), and once the matching row plus a screenful
below it have loaded, swap the off-screen render in and scroll to / select that
row in one step. FocusPoint with scrollIntoView centres the row only if it's
off-screen, so the common unchanged-content escape — where the row is already
where it was — doesn't move at all. If the line is gone (the content really
changed), nothing is forced.

This generalizes the scroll restore from a fixed origin to a predicate
(RenderRestore: FirstPaintReady decides when the saved position is reachable,
Apply re-establishes it), folding the separate selection restore into the same
first paint — so it no longer rides a post-load callback that could fire early.

The restore also now survives task replacement, which the numeric version did
not: a periodic refresh can stop the escape's re-render before it first-paints.
The pending restore is held on the buffer manager and is *not* cleared when a
task starts, so the replacement task picks it up. It is not gated on the command
key — staging the last unstaged hunk re-renders `git diff` as `git diff --cached`,
a different command, yet the line to land on is still in the new content — but
validates itself: the scan finds the target line only when the content still
contains it, so applying it to a different item is a harmless no-op. A task
clears it once it has applied it (found or not), so it lives for exactly one
re-render. Because the restore is anchored on content identity and is idempotent,
"survive replacement" and "restore by identity" are one mechanism, not two.

With the identity in hand the snapshot no longer needs the captured scroll/index;
they're derived from the explorer's live selection.
2026-08-08 12:58:59 +02:00
Stefan Haller a24196077d Add gocui accessors for scanning a loading off-screen render
The escape restore (next commit) finds the row in a re-rendering focused main
view that matches a target patch identity, and it has to do so while the content
is still loading — i.e. against the off-screen buffer, before it is swapped in,
since the displayed buffer still shows the previous render.

Add the two primitives that scan needs:

- OffscreenDiffLineContents exposes the per-line diff material (text, metadata,
  hyperlink) of the rows read so far into the off-screen render, so the resolver
  built last commit can run against the incoming content.
- ViewLineForBufferLine maps a matched (unwrapped) buffer line back to the first
  view line that renders it — the inverse of BufferLineForViewLine — so the
  restore can scroll to and select that line once the render is swapped in.
2026-08-08 12:58:59 +02:00
Stefan Haller cfe356b08d Resolve a diff line's identity from a content snapshot, not the live view
The diff-line primitive (recover a rendered row's patch-space identity) is about
to gain a second, inverse consumer: the escape restore scans a focused main
view's rows as it re-renders, looking for the row that matches a target patch
identity. That scan runs over the *loading* off-screen buffer, not the displayed
view, so the resolver can't be tied to the displayed view's per-view-line readers.

Pull the three backends (OSC metadata, buffer parse, lazygit-edit hyperlink) onto
a single buffer-agnostic resolver that takes a snapshot of a diff's per-line
content — text, metadata and hyperlink per unwrapped buffer line — and the line
to resolve. The forward consumers (click/enter/edit/PR) feed it a snapshot of the
displayed buffer (gocui.DiffLineContents) after mapping the wrapped view line to
its buffer line; the upcoming scan will feed it the off-screen buffer's loaded
rows. Behavior is unchanged.
2026-08-08 12:58:59 +02:00
Stefan Haller 239426d46b Design notes: record the stopped-task EOF-finalize bug and its fix 2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0e3d3d3182 Don't run end-of-input handling for a render that was stopped
When a task is stopped to make way for a newer one, stopping closes
opts.Stop, and the scanner goroutine then closes lineChan. The read loop's
select between those two channels is therefore non-deterministic: it can
land on the closed lineChan (ok == false) instead of the opts.Stop case,
sending a stopped task into the end-of-input branch.

There it runs the full finalize — swapping its half-read off-screen buffer
in, applying the saved scroll, clamping the origin to the truncated
content, and clearing the loading flag — all of which corrupt what the
incoming task is about to render. The most visible symptom is a brief frame
of truncated content with the scroll yanked to the top, seen when re-renders
overlap rapidly (e.g. the periodic background refresh re-rendering a main
view faster than it can load, very easy to hit under LAZYGIT_SLOW_RENDER).

The underlying bug predates the off-screen render (the EOF branch always
clamped the origin via onEndOfInput), but that change made it far worse by
also swapping a truncated buffer into the display. Fix it at the source: in
the EOF branch, check whether we were stopped and, if so, bail out like the
explicit stop case, leaving the view entirely to the task that replaces us.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller 63dac0d77d Design notes: escalate the escape/autoRefresh race and record flicker-avoidance limits 2026-08-08 12:58:59 +02:00
Stefan Haller 6dead7cdb4 Design notes: record the scrollbar regression fix (hold the height during load) 2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 600b3eeae4 Design notes: record the scrollbar regression and the 10s-refresh vs restore race
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 80c82d4bb4 Design notes: record the off-screen render replacing the two flicker patches
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 b4fb49acce Render async content into an off-screen buffer and swap it in
A cmd/pty re-render used to overwrite the displayed buffer from the top
down as lines arrived, relying on keeping the previous render's view-line
tail to avoid a blank frame. That left the view showing a mixture of old
and new content while loading, and any reader (draw, the diff-line
mapping, clicks) could observe a half-written buffer at the wrong scroll —
the §11 Race A flicker and the §8 stale-tail mapping both came from this.

Instead, build the new content in a second, off-screen viewBuffer: until
the task has read enough to paint, writes go there and the displayed
buffer — and so everything every reader sees — is left untouched. Once the
task reaches its first-paint point (InitialRefreshAfter, or EOF for short
content) it swaps the off-screen buffer in atomically and applies the
saved scroll in the same step, so the view jumps straight from the
previous render to the new one with no intermediate frame. Subsequent
lines append to the now-displayed buffer.

Swapping at the first-paint point means the displayed buffer is only a
viewport tall when it appears and then grows as the rest streams in toward
the count needed for an accurate scrollbar. The scrollbar is sized from the
displayed buffer's height, so left to itself the thumb would shrink and
snap back during that growth (most visibly: the files panel's periodic
refresh making the thumb jump while scrolled down). The total height the
scrollbar needs is a strictly later quantity than the viewport-fill paint,
so no single early swap can have both right. FreezeScrollbarHeight therefore
records the view's height when a load begins and the scrollbar is held there
— growing only if the new content turns out taller — until the load ends; a
synchronous render superseding the load releases it. This mirrors the layout
clamp, which already ignores the partial content height while a view loads.

With the swap doing a wholesale replace, refreshViewLinesIfNeeded can
truncate the view lines to the current buffer: there is no longer a
half-loaded shorter buffer whose tail we must keep showing, so the stale
tail (§8) never forms. clear()/Reset() abandon any in-progress off-screen
render so a synchronous SetContent after a stopped task writes to the
display.

This replaces the holdViewLines and freshViewLineCount patches reverted in
the previous two commits with one mechanism. The swap holds writeMutex for
now; it could later move to the main thread. Flicker behaviour still needs
interactive verification (LAZYGIT_SLOW_RENDER + a real pager).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 34566357f4 Revert "Stop mapping stale-tail view lines to the wrong buffer line"
This reverts the freshViewLineCount stale-tail guard (§8), restoring the
demonstrate-the-bug test state. The upcoming off-screen render rebuilds
the displayed buffer wholesale on swap and lets refreshViewLinesIfNeeded
truncate, so the stale tail never forms — a cleaner fix than tracking a
fresh-count. Removing the guard on its own keeps that change focused; the
bug it guarded against is re-fixed by the truncation in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 45af0aac30 Revert "Hold the placeholder until first paint when restoring a scroll position"
This reverts the holdViewLines flicker patch (Race A). It is about to be
superseded by rendering a re-render into an off-screen buffer and swapping
it in atomically, which keeps the displayed buffer (and so every reader)
untouched until the new content is ready — a cleaner mechanism than
suppressing the view-line rebuild. Removing it on its own keeps that
upcoming change focused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0d7d16df19 Make the buffer-writing methods operate on a viewBuffer
write, writeCells, makeWriteable, parseInput and
autoRenderHyperlinksInCurrentLine produced cells into v.buf; move them onto
viewBuffer so they can write into any buffer, not just the displayed one.
The display-side effects that don't belong to content production —
tainting, clearing hover, updating search positions — stay behind in the
View.write wrapper, which delegates the actual writing to v.buf.write(v).
Render config the writer needs (Editable, colors, width, tab width,
hyperlink auto-render) is read from the passed View. Behaviour-preserving:
the wrapper still always targets v.buf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 64e3d3e049 Bundle a view's cell buffer and write state into a viewBuffer
The fields that make up a view's content and the act of writing to it —
the cell buffer (lines), the write cursor (wx/wy), the escape-sequence
decoder (ei) and the held-newline flag (pendingNewline) — were loose
fields on View. Bundle them into a viewBuffer struct that View holds by
pointer. This is a behaviour-preserving prep refactor: every access just
goes through v.buf now. It sets up rendering into a second, off-screen
viewBuffer that can be swapped in atomically, so an async re-render never
exposes a half-written buffer to readers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 d8c58ef007 Design notes: record the §11 race fixes as implemented (pending interactive verification)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 91585c534f Hold the placeholder until first paint when restoring a scroll position
When escaping to a focused main view scrolled down, the re-render keeps
the previous content as a placeholder and the task scrolls to the saved
position at its first paint. But the task's writes mark the view tainted,
so any layout pass landing between the first write and the first paint
rebuilds the view lines from the half-loaded buffer and draws them at the
placeholder's scroll — a brief frame of the wrong content at the wrong
scroll before it snaps into place. Intermittent, and only visible when the
load is slow enough for a layout pass to fall in that window.

Give the view a hold: while set, refreshViewLinesIfNeeded keeps the
current view lines instead of rebuilding from the buffer, so the view goes
on drawing the coherent placeholder. The re-render task sets it only when
restoring a scroll position (so the normal load-from-top case is
untouched) and releases it at its first paint, which applies the saved
scroll in the same step — so the loaded content appears at the restored
scroll with no intermediate frame. While held, the displayed view lines
need not match the loading buffer, so the view-line→buffer-line mapping
reports no result.

This needs interactive verification (LAZYGIT_SLOW_RENDER + a real pager);
see focused-main-view-notes.md §13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 23474716ad Restore the focused main view's selection via the re-render task, not a post-hoc ReadToEnd
Escaping to a focused main view restored the selection by scheduling, on
the next UI tick, a ReadToEnd whose callback re-selected the saved line.
But ReadToEnd fires its callback synchronously when the manager has no
live read channel, and the re-render task triggered by the push creates
that channel later, inside its own goroutine (after stopping the previous
task). If the UI tick won that race, the restore ran before any content
was loaded, FocusPoint no-oped against the unloaded line, and the
selection was silently dropped — intermittently, and more often under
load.

Thread the restore through the task instead: a thenForNextTask hook on
the buffer manager, folded into the next cmd/pty task's initial-read Then,
mirroring scrollToOriginYForNextTask. It runs once the task has read
enough to place the selection, and can't fire before the task exists. The
scroll restore already applies at the task's first paint, which precedes
the initial read's end, so the origin is in place when the selection is
restored.

This needs interactive verification (LAZYGIT_SLOW_RENDER + a real pager);
see focused-main-view-notes.md §13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3af439b17f Design notes: characterize the §11 timing races (bounded vs fundamental)
Pin the two escape-restore races to concrete mechanisms: Race A (layout
draws reveal partial content at the placeholder scroll before the task's
first paint) and Race B (the selection restore can fire before the
re-render task's readLines channel is live). Classify both as bounded
interleavings, sitting over the one fundamental constraint (no flicker-free
first paint of the target scroll without buffering that screenful), and
record the prescribed fixes and their implications for part 3. Documents
why faithful repro is the interactive app, not the headless harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 fb4f7aef06 Design notes: record the §8 staleness fix and the remaining #1 atomicity constraint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 e3214a34dd Stop mapping stale-tail view lines to the wrong buffer line
Track how many leading viewLines entries the most recent refresh built
from the current buffer (freshViewLineCount) and bound the view-line→
buffer-line mapping on it. The entries past that count are the stale tail
refreshViewLinesIfNeeded leaves in place for flicker-avoidance; they
belong to a previous, longer render and must not be mapped. The old
in-range guard was insufficient: with wrapping a stale entry's buffer
index can still be in range of the shrunk buffer. Within the fresh range
every entry was just built from the current buffer, so its index is
guaranteed in range and the guard is no longer needed.

This is the §8 correctness fix the identity-based escape restore depends
on, since that read scans the view buffer while it is still loading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 93286c6a93 Demonstrate that a stale-tail view line maps to the wrong buffer line
refreshViewLinesIfNeeded keeps the previous render's view lines in the
tail when the new render is shorter, so the view can go on showing old
content without flicker until the new content catches up. The mapping
readers guard only against a buffer index that has gone out of range of
the shrunk buffer — but with wrapping, a stale tail entry's index can
still be in range, so the guard passes and a view line that no longer
exists is mapped onto the wrong buffer line. See diff-line-metadata-notes.md §8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 60debda326 Unify the view-line→buffer-line readers onto one helper
HyperLinkInLine, DiffLineMetadataInLine and BufferLineForViewLine each
repeated the same preamble: take the lock, refresh the view lines, range-
check the view line, and guard against a stale viewLines entry pointing
past a shrunk buffer. They all need that mapping to stay consistent with
the buffer they then read, so the logic belongs in one place. Extract it
into bufferLineForViewLine and have all three call it. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ae3fe52cd1 Design notes: identity-based escape restore + plan to solve the three entangled problems
Capture a design discussion (no code yet; implementation is a future session):

- The escape-from-staging restore should anchor on the explorer view's current
  patch identity at escape time, not a saved numeric scroll/index, since staging
  or dropping hunks changes the content. This is the inverse direction of the
  diff-line-metadata primitive (identity -> rendered row) and the same operation
  the -U scroll-preservation consumer needs; record it as consumer #6 and split
  the consumer list into forward (1-4) and inverse (5-6) directions.
- Record the escape-routing cases the current prototype gets wrong (staging the
  last hunk should land in the staged half; <tab> between staged/unstaged; the
  empty-view and custom-patch-builder cases).
- Decide to solve the new restore mechanism, the §11 timing races, and the
  BufferLineForViewLine staleness trap together in the prototype rather than
  defer them to productionization (you can't plan around unsolved entangled
  mechanisms), with a dependency-first attack order (§8 fix, characterize the
  races, then the predicate-scroll restore).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 36aefdc454 Design notes: record the #2 prototype as built & verified end-to-end
The emitter (delta), carrier (gocui per-cell attachment), and consumer (the
GetDiffLineInfo metadata backend + env-var handshake) are now built for the
normal unified case and verified end-to-end — including in the running app with
delta's default mode, where clicking/enter/e/G resolve via #2 and deletions get
the correct side. Update §9 from "in progress" to a "built & verified" record
(what was built, how it was verified) mirroring §8 for #1, and mark the build
order (§7) accordingly. The remaining step-5 deliverables — finalizing/publishing
the spec and the production plan — and side-by-side/difftastic are still open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 e6bfb2c720 Use pager-emitted OSC diff metadata as the highest-fidelity line-info backend
Add mechanism #2 to GetDiffLineInfo: when a patched pager annotated each diff
line with OSC 456 metadata, read it back as the first backend, ahead of the
buffer parser (#1) and the lazygit-edit hyperlink. It is strictly higher
fidelity -- it carries the side explicitly, so it serves the renderings #1
cannot parse (delta's default mode, --line-numbers, diff-so-fancy) and conveys
deletions, which the hyperlink can't.

The host advertises the protocol versions it understands by setting
EMIT_OSC456_METADATA on the pager subprocess; a pager that doesn't understand
it ignores the variable, so this is safe to set unconditionally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 00df5177e5 gocui: parse OSC 456 per-line diff metadata and attach it per cell
A patched pager (delta) prefixes each diff line with an OSC 456 sequence
carrying that line's patch-space identity (see diff-line-metadata-notes.md),
so the host can map a rendered row back to (file, type, new-line, old-line)
without re-parsing -- the only way to recover the side for renderings that
drop the +/- markers (delta's default mode).

Recognize it in the escape interpreter and stamp the payload onto each cell,
mirroring how OSC-8 hyperlinks are handled, exposing it via
DiffLineMetadataInLine. To do so, generalize the OSC dispatch to accumulate
the (possibly multi-digit) OSC number before branching, rather than matching
the single character '8'; the OSC-8 path is unchanged for well-formed input.

Unlike a hyperlink, the metadata sequence is never closed -- the pager
re-emits one per line -- so clear it at each line boundary to keep it from
bleeding onto a following line that has none (e.g. a hunk header).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0e35ebbb55 Design notes: pin the #2 v1 wire format and record the delta de-risk
Mechanism #2's emitter side is now prototyped on this branch (delta, normal
unified mode only) and the bytes are verified. Capture what that settled, since
the spec is meant to be published for pager-developer feedback:

- The single per-line emit point in delta and which fields are reachable there
  (the gotcha: delta only maintains its line-number counters with --line-numbers
  on, so the patch tracks its own), and why a dedicated additive emitter beats
  reusing LineNumbersData.
- The pinned v1 wire format (positional, file last so it may contain ';',
  empty old-line unless deleted) and the EMIT_OSC456_METADATA env-var handshake.
- Deferred items: the OSC-number terminal audit, wrapped continuation rows, and
  header-row attachments.

Also resolves the §6 wire-format open question accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 6a14634040 Design notes: resolve #1's open questions and correct the coverage from verification
Record what the #1 prototype settled. The two open questions #1 touches are now
answered (the deleted-line new-line convention is patch.LineNumberOfLine's, and a
deletion carries both line numbers; multi-file diffs split on "diff --git" and
the section parses 1:1 with patch.Parse). Add a §8 capturing what landed, how it
was verified, and the implications for #2.

Two coverage corrections came out of verifying against real pager output, both
worth pinning before the spec is written: delta --color-only qualifies for #1
only *without* line numbers (the gutter pushes the +/- marker off column 0, so a
naive parse is confidently wrong — handled by an integrity check that falls back,
not by teaching the host delta's gutter), and diff-so-fancy strips the +/- markers
entirely, so it's a #2 case, not #1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 cf8e5fd27e Recover diff-line identity by parsing the buffer, behind a swappable seam
The focused main view's click/enter/e/G handlers all need the same thing: given
a rendered diff row, the patch-space line it corresponds to. Until now that came
solely from delta's lazygit-edit:// hyperlinks, which only carry a path and a
single line number — no side. That's lossy: for a deletion the number is the old
line, but the consumers fed it into new-file lookups, and two consecutive
deletions (which share a new-file line number) couldn't be told apart at all.

Replace GetFileAndLineForClickedDiffLine with GetDiffLineInfo, returning the
fuller (file, type, new-line, old-line) record from diff-line-metadata-notes.md.
This is mechanism #1: parse the decolorized view buffer — walk up to the file's
"diff --git" section, reuse patch.Parse on it (splitting multi-file commit diffs
on the "diff --git" boundaries), and read the type and line numbers off the patch
arithmetic. It serves the structure-preserving renderings — no pager, git diff
--color, and delta --color-only without line numbers — with no external
dependency.

To avoid trusting a mis-parse, the parser bails when a hunk's body no longer
matches its header (Patch.IsWellFormed). That's what happens when a pager keeps
the diff/hunk headers but restructures the body: delta's line-number gutters push
the +/- marker off the start of each line, so every body line reads as context.
Such renderings fall through to the next backend rather than yielding a confident
wrong answer. (diff-so-fancy goes further and rewrites the headers too, so it
fails even earlier, on the missing "diff --git".)

GetDiffLineInfo is a seam with swappable backends: the buffer parser first, then
the old hyperlink reader as a fallback for renderings the parser can't handle
(delta's default mode, or delta with line-number gutters). The future #2 OSC
per-cell metadata reader plugs in ahead of both, behind the same record shape.

Wire the consumers to the record per that doc's field mapping:
- dive into staging/patch building lands on the exact patch line, looking a
  deletion up by its old-file line number (PatchLineForOldLineNumber) so the
  two-deletions case resolves correctly;
- `e` edits at the new-file line;
- `G` anchors the PR link on the left (old) side for a deletion, the right (new)
  side otherwise.

The hyperlink fallback can't convey the side, so it reports DiffLineOther, which
the consumers treat as a non-deletion — i.e. exactly today's behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 165afd4652 Add View.BufferLineForViewLine to map a view line to its buffer line
The diff-line parser needs to walk the unwrapped diff buffer upward from a
clicked/selected row, but the row index it's handed is a view line index (which
counts wrapped lines). Expose the existing internal mapping (viewLines[y].linesY)
so callers can translate, with the same lock and stale-tail guard that
HyperLinkInLine uses against a concurrent shorter re-render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 c9c1fb6a0b Add old-file-space patch arithmetic for landing on deletions
The patch package can already map a patch line index to its new-file line
number and back (LineNumberOfLine / PatchLineForLineNumber). The diff-line
parser being built on top of this needs the old-file equivalents: a deletion
has no distinct new-file position (two consecutive deletions share one), so to
land on the exact deletion when diving into staging we have to look it up by
old-file line number.

Add OldLineNumberOfLine and PatchLineForOldLineNumber as direct mirrors of the
new-file functions, counting DELETION+CONTEXT lines instead of ADDITION+CONTEXT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 b026fb2b93 Design notes: mapping rendered diff rows to patch coordinates
Captures a design discussion about the remaining big problem behind the
focused-main-view feature: recovering a diff row's patch-space identity
(file, type, source line) when the rendering came from a pager. Records
the two complementary mechanisms (a host-side parser for
structure-preserving renderings, and a pager-emitted OSC protocol for
ones that restructure), the per-cell carrier with its keyboard/mouse
access rules, the type + old?/new-line payload and why each field is
load-bearing, and the version-negotiation handshake. Design only; no
implementation yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0b11400a9d Use the default select mode when diving into a patch explorer from a focused main view
Diving into staging or patch building from a focused main view (by
double-clicking a line, or pressing enter on the selected line) always
landed on a single-line selection. Entering the same views through the
side panel honours the UseHunkModeInStagingView config and selects the
whole hunk by default. The two ways in should agree, so that diving in
from the main view feels like the established flow.

A non-negative line index in NewState was overloaded for two intents:
clicking directly on the patch explorer view (where a single-line range
is the start of a drag) and diving in from the main view (where we want
the default select mode). Distinguish them with SelectLineInDefaultMode
on OnFocusOpts: the main-view entry points set it; the click-to-drag
path does not.

In hunk mode the selection covers the block of changes around the
clicked line. A context line has no surrounding changes, so we snap to
the next change line (as toggling hunk mode does); the clicked context
line itself is then not part of the selection.

This is a separate commit only because the branch is a throwaway
prototype; in a real history it would be folded into the commit that
introduces the focused-main-view enter behavior rather than landing on
top of it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 ea4a1716d1 Session notes: escape flicker fix implemented; timing races remain
Records session 3: the cmd/pty scroll-restore mechanism, the refreshMainViews
reset reorder, the corrected onNewKey understanding, and the remaining
timing-race investigation to do before productionizing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 13115140db Restore scroll and selection seamlessly when escaping to a focused main view
EscapeFromPatchExplorer re-renders the side panel's content back into the main
view and wants to land at the scroll position and selection the user had before
diving into staging/patch building. The previous version set the origin on the
next UI tick, after the placeholder had already been painted at the wrong
position, so the restore was visible as a jump.

Instead, ask the re-render itself to restore the scroll (via
ScrollToOriginYForNextTask), so the saved position is applied in the first
paint that shows the real content. The selection still needs the diff loaded
down to the selected line, so restore it via ReadToEnd once the content is
fully read; the scroll is no longer touched there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 3f542e7c35 Let a cmd/pty task restore a saved scroll position at its first paint
When re-rendering content the user was already scrolled into, we want the
saved scroll position applied exactly when the real content first paints — not
before. Setting the origin up front instead paints it onto whatever placeholder
is currently in the view (e.g. the shorter buffer CopyContent left there),
which flickers: either a blank frame past the placeholder's end, or a jump to
the top when the task resets the origin at startup.

Add ViewBufferManager.ScrollToOriginYForNextTask: the next cmd/pty task then
(a) does not reset the view to the top at startup even though the command key
changed, so the placeholder stays put, (b) sizes its initial read to the saved
position so enough content is loaded to fill the view there, and (c) scrolls to
it as part of the first refresh, in the same paint that shows the real content.
This is the cmd/pty analogue of RenderStringWithScrollTask.

No caller sets it yet, so this is behaviour-preserving on its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 53a25a34f1 Reset other main views' scroll after copying content, not before
refreshMainViews reset the scroll position of every other main view at the
very top, before moveMainContextPairToTop runs its CopyContent. CopyContent
copies the previously-shown view's content into the now-visible one to avoid a
blank frame during the async re-render — but because the reset ran first, it
had already zeroed the origin of that soon-to-be-copied source view. The
placeholder therefore always appeared scrolled to the top, jumping away from
wherever the screen actually was, on every cross-pair transition.

Move the reset to after the copy. The end state is unchanged (each other main
view still ends at origin 0, and the destination always re-renders), but the
brief placeholder now stays at the source view's real scroll position until
the real content paints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 d26b1ebfa6 Route all view origin writes through SetOriginX and SetOriginY
Several methods assigned v.ox and v.oy directly: SetOrigin, CopyContent,
the wrap/autoscroll branches in draw, FocusPoint, and
Scroll{Up,Down,Left,Right}. Funnelling them all through SetOriginX and
SetOriginY gives a single place to observe (or set a breakpoint on)
every change to a view's scroll position, which makes debugging scroll
behaviour much easier.

This means those call sites now also get the setters' `< 0` clamps, but
that is behaviour-preserving in every case: each assigned value is
already >= 0. calculateNewOrigin never returns a negative number;
CopyContent copies origins that are themselves always >= 0; and the draw
and scroll writes are all guarded (or fed only non-negative amounts) so
the result can't go below zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 23f8d634cc Session notes: corrected flicker diagnosis and the 3 bug fixes
Records session 2's findings: the escape flicker was caused by the layout's
scroll-up-to-fill clamp running against partially-loaded async content (not the
"only a screenful loaded" mechanism session 1 guessed); the full origin-reset
chain (onNewKey / CopyContent / layout clamp) and how each is handled; the three
standalone bug fixes that landed; and the precise remaining task (apply the
saved origin at the pty task's first repaint, i.e. a cmd-task RenderWithScroll).
Also captures the reusable debug tooling that was stripped from the tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 c8175c053f Fire queued ReadToEnd callbacks when the initial read reaches EOF
A task's read loop processes one LinesToRead request at a time. The initial
request has a large line count and no Then callback; if the content is shorter
than that, the loop hits EOF on the initial request and breaks out, abandoning
any further requests still sitting in the readLines channel. So a ReadToEnd
call that races a still-loading-but-shorter-than-its-initial-read view has its
Then silently dropped: it isn't fired immediately (the channel was non-nil at
call time) and it's never dequeued.

On EOF, drain the queued requests and fire their Then callbacks before
breaking out, since reaching EOF trivially satisfies any "read more" request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 0483b12864 Don't scroll a view up to fill blank space while its content is loading
The layout scrolls a view up if its origin is past the bottom of its
content, to avoid showing blank space (e.g. after a resize). But it measures
content height by the lines loaded so far, and command/pty tasks load
asynchronously. So when a view is re-rendered while scrolled down, the layout
would yank it to the top because only a fraction of the content has been read
yet, then leave it there once loading finished.

Track whether a command task is actively reading (set synchronously when the
task is created, so a layout pass in between sees it; cleared at EOF, but not
when stopped, since that means a newer task is taking over) and skip the
scroll-up clamp for such views. onEndOfInput already re-clamps once loading
completes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan HallerandClaude Opus 4.8 70b80d1375 Lock the view and guard the line index when reading a hyperlink
HyperLinkInLine read v.lines/v.viewLines without holding writeMutex, so it
could race a concurrent re-render rebuilding the buffer. It also indexed
v.lines by viewLines[y].linesY after only checking y against len(viewLines);
since refreshViewLinesIfNeeded overwrites viewLines in place without
truncating, the tail can hold stale entries pointing past a shrunk v.lines,
giving an out-of-range panic while a shorter diff is still loading.

Take writeMutex (as the sibling view methods do) and bounds-check linesY
against len(v.lines), returning "no link" rather than panicking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00
Stefan Haller 1b70ac6fa6 Session notes 2026-08-08 12:58:59 +02:00
Stefan Haller e8388a998d WIP FocusedMainViewSnapshot approach 2026-08-08 12:58:59 +02:00
Stefan Haller e520517388 WIP New click behavior 2026-08-08 12:58:59 +02:00
Stefan Haller bdf067e172 Open a browser at the selected line in the diff of the current branch's PR 2026-08-08 12:58:59 +02:00
Stefan Haller 37ca2a2395 Press e in focused main view (when selection is showing) to edit that line 2026-08-08 12:58:59 +02:00
Stefan Haller 4ced2c0c24 Replace gui.showSelectionInFocusedMainView config with on-demand selection
Instead of a user config that always shows a selection on focus, the
focused main view starts without a selection (matching master). Pressing
<space> shows a selection in the middle of the view (no scrolling);
pressing <esc> hides it again before falling through to exiting the view.
2026-08-08 12:58:59 +02:00
Stefan Haller 89b67cf938 WIP After going straight to patch building from main view, esc goes all the way back out
I *think* I like it better this way, but it needs more testing.
2026-08-08 12:58:59 +02:00
Stefan Haller 63ffb829c0 Press enter in main view of commits panel to enter patch building for clicked line
This involves first switching to the commit files view, and then entering the
clicked file from there.
2026-08-08 12:58:59 +02:00
Stefan Haller 2210a00af9 Extract some functions from CommitFilesController to a new CommitFilesHelper 2026-08-08 12:58:59 +02:00
Stefan Haller a32ed507d3 Press enter in main view of files/commitFiles to enter staging/patch-building
This was already possible, but only when a file was selected, and it woudln't
always land on the right line when a pager was used. Now it's also possible to
do this for directories, and it jumps to the right line.

At the moment this is a hack that relies on delta's hyperlinks, so it only works
on lines that have hyperlinks (added and context).

The implementation is very hacky for other reasons too (e.g. the addition of the
weirdly named ClickedViewRealLineIdx to OnFocusOpts).
2026-08-08 12:58:58 +02:00
Stefan Haller 37855b59e9 Select line that is in the middle of the screen 2026-08-08 12:58:58 +02:00
Stefan Haller 79f3053f41 Press enter in focused main view when user config is on 2026-08-08 12:58:58 +02:00
Stefan Haller 64cc4ccd32 Add user config gui.showSelectionInFocusedMainView 2026-08-08 12:58:58 +02:00
Stefan HallerandClaude Opus 4.8 ba9373575f Add LAZYGIT_SLOW_RENDER debug knob for watching async render frames
Re-rendering a diff into a main view is asynchronous and lazy: the read
loop fills the view a screenful at a time and refreshes as it goes. When
debugging scroll-restore and flicker behaviour, the individual frames go by
too fast to see. Setting LAZYGIT_SLOW_RENDER=<milliseconds> sleeps that long
after each line is written, stretching the load out so the frames become
visible. It has no effect when unset, so it's safe to leave in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:58 +02:00
Stefan Haller f162dc5aec Honor the conflict-marker-size gitattribute (#5902)
If the `conflict-marker-size` git attribute is used to set the marker
size to a non-default value (!= 7), lazygit's handling of conflicted
files was totally broken. Stopping at a commit with conflicts in a
rebase would show the `UU` files for a moment, and then, a few seconds
later, would stage all conflicted files and offer to continue the rebase
(with the conflicts baked into the resulting commits if you confirmed).
Even if you cancelled the continue prompt, it wasn't possible to use
git's conflict panel to resolve the conflicts; it would only show the
regular diff for those files, not its conflicts editor.

Fix this by querying the `conflict-marker-size` git attribute for all
conflicting files and use that to match the conflict markers.

Fixes #4367.
2026-08-08 12:58:14 +02:00
Stefan Haller d0078bf05c Recognize conflict markers that have no label
Git only writes the space after a marker when there is a label to write
after it, and the label can be empty: `git checkout -m` with the diff3
conflict style, for instance, has no name for the common ancestor, so it
writes a bare "|||||||" line.
2026-08-08 12:43:42 +02:00
Stefan Haller 5481436d8c Honor the conflict-marker-size gitattribute
Ask git for the attribute of every conflicted file whenever we load the
file status, so that we recognize the markers it actually wrote. Files
that are set up this way are precisely the ones whose regular content
tends to contain marker-looking lines, so matching a run of at least
seven characters instead is not an option: we'd take the file's own
content for markers and then never consider its conflicts resolved.

One `git check-attr` call covers all conflicted files at once; asking per
file would take seconds when hundreds of files are conflicted, and it
would hurt worst on Windows, where spawning a process is expensive.
Because the lookup rides along with the file status, it costs nothing
when there are no conflicts, and editing .gitattributes during a merge
takes effect on the next refresh.
2026-08-08 12:43:42 +02:00
Stefan Haller c3450f9406 Add tests demonstrating that we ignore the conflict-marker-size gitattribute
When a file's conflict markers aren't seven characters long we don't
recognize them at all. Two things go wrong: we consider the file's
conflicts resolved, so we stage it and offer to continue the merge a
moment after stopping at it; and pressing enter on it shows its diff
instead of the merge conflicts view, leaving no way to resolve it in
lazygit.
2026-08-08 12:43:42 +02:00
Stefan Haller bc9fafff02 Make the conflict marker size a parameter of our marker matching
Git doesn't always write conflict markers of seven characters: the
conflict-marker-size gitattribute overrides that per file, and it is set
for good reasons — for file types whose regular content tends to contain
marker-looking lines, such as documentation about merging, or test
scripts. We hard-code seven characters everywhere we look for markers,
so none of that works.

Prepare for honoring the attribute by threading the marker size through
everything that recognizes a marker, carried on the file model. Nothing
fills it in yet, so we still use git's default size of seven everywhere,
and matching is unchanged: a marker consists of exactly that many marker
characters, and all but the "=======" one are followed by a space and a
label.
2026-08-08 12:43:42 +02:00
Stefan Haller 5dec89abfe Update the UI after stash operations in a single frame (#5905)
This fixes a regression in 0.64.0: before that version, creating or
popping a stash would happen synchronously on the UI thread (including
the refresh), blocking the UI until everything changed, including the
panel focus. Blocking the UI was not nice of course, but at least the UI
update was clean. With 0.64.0 this changed to a background refresh, so
that the update to the two panels and the focus change all happened out
of sync, which looks rather ugly. Fix this by using Refresh's mechanism
to batch UI updates, and switch the panel focus in the Refresh's Then so
that it updates at the same time.

While we're at it, use a waiting status spinner for these operations;
they are usually fast when only few files are involved, but when
stashing a large number of files in a larger repo it can be noticeable,
and it looks ugly if the confirmation prompt stays on the screen while
it is running.
2026-08-08 12:43:29 +02:00
Stefan HallerandClaude Opus 5 4c39b0b903 Run the stash operations with a waiting status
Creating and applying a stash both touch every changed file, so in a
large repo they can take long enough to be noticeable — and running them
on the UI thread meant the confirmation popup stayed on screen, frozen,
for the whole operation. Run them on a worker instead, with a spinner,
and keep blocking input for their duration so that the type-ahead
guarantee the refresh used to provide still holds.

Dropping stays on the UI thread: it only rewrites the stash reflog, so
it's fast no matter how big the stashes are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan HallerandClaude Opus 5 ed22322ec8 Collapse the stash range selection from the refresh's Then
Collapsing the range before kicking off the refresh paints the new
selection against the list as it was before the drop, so for a frame the
entries that were just dropped are still on screen (and, with
gui.shrinkSidePanelsToContent, the panel is still at its old size).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan HallerandClaude Opus 5 6c567d1eb6 Switch to the files panel from the post-stash refresh's Then
Pushing the files context right after kicking off the refresh moves the
focus (and, with gui.shrinkSidePanelsToContent, resizes the panels) a
frame before the refreshed stash and files lists arrive. Doing it from
Then puts it in the same frame as the data it belongs to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan HallerandClaude Opus 5 544f3b834b Apply the panel updates after stash operations in a single frame
Stashing and popping change both the stash list and the files list.
With each scope updating the UI as soon as its own refresh is done, the
two panels visibly change at different times; with
gui.shrinkSidePanelsToContent that also means their sizes change at
different times than their contents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:26:18 +02:00
Stefan Haller da7ca77c9d AGENTS.md additions 2026-08-08 12:26:18 +02:00
Stefan Haller bf5829af3f Fix several problems with repos whose git dir lives outside the working tree (#5910)
Lazygit assumes a repo can be found again from its working directory: it
chdirs there and lets git rediscover the git dir from `<worktree>/.git`.
That holds for an ordinary repo and breaks for every setup where the git
dir lives somewhere else, which is where these bugs come from. Opening
such a repo worked at all only when `--git-dir` happened to leave
`GIT_DIR` in the environment for every command to inherit — which is
also why entering a submodule, which has to clear it, broke the way back
out.

Three reported problems:

- **`core.worktree` (#5895).** A repo whose work tree is elsewhere
panicked on startup with `fatal: not a git repository`: we chdir'd into
a work tree with no `.git` in it and every command after that was lost.
We now work out at startup whether git can find the repo from its work
tree, and when it can't we put `GIT_DIR`/`GIT_WORK_TREE` on every
command the repo's command builder produces — as well as in the process
environment, for subprocesses that don't come through the builder.
Nothing is set for the repos git can find on its own, which is nearly
all of them.

- **Escaping a submodule of a dotfile repo (#1118).** The repo-path
stack we push the superproject onto only held its path, and for a repo
opened with `--git-dir`/`--work-tree` the path leads nowhere. Escaping
failed with `not a git repository`, or, if some unrelated repo happened
to lie above the work tree, quietly switched to that one instead. The
stack now carries the environment as well, taken from the repo paths
rather than from the process env, so it also covers a repo whose
location lazygit worked out itself.

- **Opening a directory that holds a bare repo (#5469, #5681).** `git
rev-parse --show-toplevel` is fatal when there's no work tree, so we
never got an answer at all for a bare repo: `IsBareRepo()` could never
come out true, and lazygit either died with a stack trace or decided we
weren't in a repository. We now ask again without `--show-toplevel` when
the first query fails, and the existing "open most recent repo?" prompt
does its job.

Some related things that turned up on the way:

- **A submodule no longer looks like a linked worktree.** `git worktree
list` reports the main worktree as the common git dir with a trailing
`/.git` removed, which is not the working tree when the git dir doesn't
live inside it. Comparing that against the working tree path matched
nothing, so inside a submodule the status bar claimed we were in a
linked worktree named after the submodule, the worktrees panel listed it
as not current, and its branch got a "checked out elsewhere" marker.
Worktrees are now identified by their git dir, which names them
unambiguously.

- **Commands aimed at another repo no longer resolve against ours.**
With `GIT_DIR` set, `git -C mysub log -1` reports the *superproject's*
commit, silently. So opening lazygit with `--git-dir`/`--work-tree`
quietly broke resolving submodule conflicts, stashing and resetting a
submodule, and detaching another worktree.

- **Starting lazygit in a repo's `.git` dir opens the repo.** It used to
tell you that you were in a bare repo, which you weren't — the work tree
was one directory up. git's own convention is that a git dir called
`.git` belongs to the directory holding it, so we look there. (A linked
worktree's or a submodule's git dir isn't called `.git`, and nothing we
look at says where their work tree is, so those still get the prompt.)

- **`RepoPath()`** is documented to be the work tree when we're in the
main worktree, but was derived from the git dir's location, which is
only the same thing when the git dir is inside the work tree. This fixes
the repo name shown in the status panel for split setups.

Fixes #1118
Fixes #5469
Fixes #5681
Fixes #5736
Fixes #5895
2026-08-08 12:20:44 +02:00
Stefan HallerandClaude Opus 5 f141fcc570 Open the repo when lazygit is started in its .git dir
Running lazygit in a .git dir got you told you were in a bare repo,
which you weren't: the worktree was sitting right there, one directory
up. git's own convention is that a git dir called .git belongs to the
directory holding it — that's how `git worktree list` names the main
worktree — so ask that directory, and if it is a worktree, open the repo
we were really being asked about.

The git dirs that aren't called .git keep the answer they had. A linked
worktree's and a submodule's do have a worktree, but nothing we look at
says where, so we would be guessing; a bare repo's has none to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 06b421ad0c Remember how to get back to a repo we entered a submodule from
Entering a submodule clears GIT_DIR and GIT_WORK_TREE, as it must: they
say where the superproject is. But the stack we push the superproject
onto so that escape brings us back only held its path, and for a repo
opened with --git-dir/--work-tree the path leads nowhere — git can't
find a repo there. Escaping out of a submodule of a dotfile repo failed
with "not a git repository", or, if some unrelated repo happened to lie
above the work tree, quietly switched to that one instead.

Push the environment onto the stack along with the path, taken from the
repo paths rather than from the process env, so that it also covers a
repo we worked the location out for ourselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 9b1078a2ca Make StringStack generic
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 d19af37ee7 Tell git where the repo is when it can't find it itself
git finds a repo by looking for a .git in the directory a command runs
in. Lazygit runs its commands in the work tree, so that normally works —
but not when the git dir lives somewhere else entirely, which is what
core.worktree and --work-tree are for. Lazygit chdir'd into such a work
tree and then ran commands that couldn't see any repo from there, so
opening a repo with core.worktree set panicked on startup. It only
worked with --git-dir because that leaves GIT_DIR in the environment for
every command to inherit.

Work out at startup whether git can find the repo from its work tree,
and when it can't, put GIT_DIR and GIT_WORK_TREE on every command the
repo's builder produces. As with the working directory the builder pins
(527124d0e0), these also go into the process env — subprocesses don't
come through the builder — but the commands don't read them from there,
because the process env belongs to whichever repo we have switched to
since.

Working out whether git can find the repo means asking git, rather than
reading the .git file, whose contents can spell the same directory
differently than git does. The extra query is skipped for a repo whose
git dir is simply its .git directory, which is nearly all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 34d41b5d51 Don't let our repo answer for a different one
GIT_DIR and GIT_WORK_TREE tell git where our repo is, and every command
we run inherits them — including the ones we point at a submodule or
another worktree. git resolves those against our repo instead, and says
nothing about it: with GIT_DIR set, `git -C mysub log -1` reports the
superproject's commit. So opening lazygit with --git-dir/--work-tree
quietly broke resolving submodule conflicts, stashing and resetting a
submodule, and detaching another worktree; the worktree list came back
claiming every worktree shared our git dir.

Drop the two variables from the commands that address another repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 616d75a1fa Run Reset in the parent module the way the other commands do
Reset told git to change directory with -C while runInParentModule does
it by setting the command's working directory, but they were computing
the same directory for the same reason. Use the helper, so that there is
one place that knows what running in a nested submodule's parent means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 3d80e466ce Say why runInParentModule can name a relative directory
Its working directory resolves against the process rather than against
the repo the command builder pins commands to, which is only safe
because nothing but foreground commands come through here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 0ce248d1bf Recognize a repo that has no work tree instead of bailing out
git makes `rev-parse --show-toplevel` fatal when there's no work tree,
so asking for it together with everything else meant we never got an
answer at all for a bare repo: GetRepoPaths returned an error, nobody
ever saw IsBareRepo() == true, and lazygit either died with a stack
trace or decided we weren't in a repository. That's what you got for
opening it in a directory holding a bare repo and a .git file pointing
at it, which is a normal way to keep a repo and its worktrees together.

Ask again without --show-toplevel when the first query fails: the other
queries work fine without a work tree, so if they now succeed we know
we're in a bare repo, and the existing prompt offering to open a recent
repo does its job. If they fail too we're not in a repo at all, and the
first error already says so.

--is-bare-repository is gone from the query: a work tree implies
core.bare is false, so it could only ever come back false there, and
what matters to us is whether there is a work tree to show, which is
what we now go by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 e10a2f6a27 Stop promising bare repo support
"does not yet support" reads as a promise that it will, but it's quite
likely that it never will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 ca6c0500e6 Don't clear gui.git when we fail to open a repo
onNewRepo also runs when switching repos, and a failure there leaves us
in the repo we came from — with a nil GitCommand, which nothing else is
prepared for. Only assign once we have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 e17ed2484c Use the work tree as the repo path when it is the main worktree
RepoPath() is meant to be the same as WorktreePath() when we're in the
main worktree, but we derived it from the git dir's location instead.
That is only the same thing when the git dir lives inside the work tree.
With core.worktree, --work-tree, or a .git file pointing at a repo dir
that isn't called .git, it lands on a directory that isn't a worktree at
all, and the repo name we show follows it there.

A worktree that has the repo's common git dir to itself is the main
worktree, so use its path. That subsumes the submodule case, whose git
dir lives under the superproject's .git/modules but is still the
submodule's own common dir; --show-superproject-working-tree is now only
needed for a linked worktree of a submodule.

The existing bare repo test asserted a git output that can't occur (a
work tree and --is-bare-repository=true at once), but the rest of it is
the shape of a repo opened with --git-dir/--work-tree, where the new
repo path is the correct one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 180039e78c Add a repo paths test for a repo with a separate work tree
When the work tree lives somewhere else entirely — set up with
core.worktree or --work-tree — we're still in the main worktree, so
RepoPath() should be the work tree, as its own doc comment says. Instead
we derive it from the git dir's location, which lands somewhere that
isn't a worktree at all, and the repo name follows it.

The ACTUAL lines are indented as they will be once the EXPECTED ones
replace them, rather than as gofumpt wants them while the comment
markers are still splitting the struct's alignment. That leaves this one
file not gofumpt-clean until the next commit, in exchange for a diff
there that shows only the lines that actually change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 d2d5bdc2bc Identify the current and main worktree by git dir, not by path
`git worktree list` reports the main worktree as the common git dir with
a trailing "/.git" removed, which equals the working tree only when the
git dir sits inside it. In a submodule, a bare repo, or a repo using
core.worktree it doesn't, so comparing the reported path against the
working tree path matches nothing: no worktree is recognized as current
or as main. Most visibly, inside a submodule lazygit claimed we were in
a linked worktree named after the submodule, and offered to remove that
"worktree".

Comparing git dirs identifies a worktree unambiguously, so use that.
A worktree whose directory is gone has no git dir to compare, and there
we still have nothing better than its path.

The submodule tests were asserting the linked-worktree suffix in the
status view; it is gone now, and the repo name still says which
submodule we're in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 e1b8ef048a Add a worktree loader test for being in a submodule
A submodule's git dir doesn't live inside its working tree, and `git
worktree list` reports it by its git dir. Lazygit compares that against
the working tree path, so it recognizes neither the current nor the main
worktree, and the UI ends up claiming we're in a linked worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan HallerandClaude Opus 5 7cbd93f945 Give the worktree loader tests their repos' git dirs
The scenarios describe their repo by its paths but leave the git dirs
empty, which no repo has. Unused for now; the loader is about to want
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:15:01 +02:00
Stefan Haller d1014aecf3 Simplify the run_integration_tests.sh script (#5908)
Our minimum required git version is 2.32.0, which is the first version
that does support the GIT_CONFIG_GLOBAL env var, so we don't need the
~/.gitconfig dance any more.
2026-08-08 09:52:20 +02:00
Stefan Haller e1391298aa Simplify the run_integration_tests.sh script
Our minimum required git version is 2.32.0, which is the first version
that does support the GIT_CONFIG_GLOBAL env var, so we don't need the
~/.gitconfig dance any more.
2026-08-08 09:31:58 +02:00
Stefan Haller 813de837ef update Nix flake dependencies (#5894)
### PR Description

Refresh the Nix flake inputs - see #5882.

Direct input revisions:

- `flake-parts`: `758cf7296b` → `427bf4bd94`
- `nixpkgs`: `c9b6fb7985` → `e72e4f2994`
- `treefmt-nix`: `5eda4ee812` → `d1187f8bc7`
- `systems`: unchanged at `da67096a3b`
- `flake-compat`: unchanged at `ff81ac966b`; its immutable URL metadata
  was normalized by the lock refresh

The refresh also updates the transitive `nixpkgs-lib` and
`treefmt-nix/nixpkgs` inputs.

The newer nixpkgs revision deprecates `pkgs.nixfmt-rfc-style` because it
now aliases `pkgs.nixfmt`. Uses the current attribute directly,
preserving
formatter behavior and removing the two evaluation warnings.

### Testing

- `nix flake check`
- `nix develop --ignore-environment -c just format` with a writable
  `GOCACHE`
- `nix develop --ignore-environment -c just build` with a writable
  `GOCACHE`
- `nix develop --ignore-environment --keep HOME -c just unit-test` with
  a writable `GOCACHE`
- `nix develop --ignore-environment --keep HOME -c just lint` with
  managed proxy variables and task-local caches
- `nix-shell --run 'just --version'`
- `git diff --check`

### Please check if the PR fulfills these requirements

- [x] Cheatsheets are up-to-date — no integration-test or keybinding
changes
- [x] Code has been formatted
- [x] Tests have been added/updated — no test changes needed; unit suite
passes
- [x] Text is internationalised — no user-facing strings changed
- [x] UserConfig hot reload is unaffected — no UserConfig changes
- [x] Docs have been updated if necessary — no documentation changes
needed
- [x] You've read through your own file changes for silly mistakes etc

<!--
Be sure to name your PR with an imperative e.g. 'Add worktrees view',
and make sure the title
is suitable to be included as a bullet point in release notes (i.e.
phrased from a user's point
of view).
see https://github.com/jesseduffield/lazygit/releases/tag/v0.40.0 for
examples
-->
2026-08-06 16:51:55 +02:00
Stefan Haller 71ff5fd827 Update the versions pinned for the Nix build
flake.lock pins the exact revision of everything the Nix build pulls in
(the package set it takes git, Go and the formatters from, plus a few
helper libraries), much like go.sum does for Go modules. Nothing
refreshes it automatically and no CI job exercises it, so the pins had
quietly drifted the better part of a year behind.

The flake-compat entry also grows a "?rev=...&revCount=..." suffix on
its download URL. That is not a version change: the server hosting the
archive advertises those two values as part of the canonical URL now,
and they only restate the rev and revCount that the same entry already
lists as separate fields. The archive fetched is byte-for-byte identical
either way.
2026-08-06 16:49:23 +02:00
Stefan Haller a9bb960e5c Use nixfmt's current package name in the flake
The nixpkgs package set renamed the Nix code formatter: what used to be
called nixfmt-rfc-style is now simply nixfmt, and the old name lives on
only as an alias. Both names resolve to the identical program, so this
doesn't change how anything gets formatted.

The rename comes first because the dependency update in the next commit
makes that alias start printing a deprecation warning, so doing it in
this order means no commit in the history evaluates with one.
2026-08-06 16:49:23 +02:00
Stefan Haller 900c3e3c45 Fix race in "Stash staged changes" on git versions before 2.32.0 (#5903)
PipeCommands ran every command in its own goroutine, each doing
Start/read-stderr/Wait, with nothing ordering one goroutine's Start
against another's Wait. That ordering matters: StdoutPipe registers the
parent's read end in cmd.parentIOPipes, and Cmd.Wait closes those
descriptors when it returns. The next command's Stdin is that very
*os.File, and exec passes a user-supplied *os.File through untouched, so
Start hands the child whatever the fd happens to be at that moment. If
the producer finished and got reaped before the consumer's goroutine
reached Start, that fd was already closed, File.Fd() returned -1, and
the child was started with fd 0 closed -- reading nothing at all.

The only caller is the pre-2.35 fallback in SaveStagedChanges, which
pipes `git stash show -p` into `git apply -R`. Losing that race left git
apply with an empty patch, so it failed with "unrecognized input", the
following `git stash drop` never ran, and the user was left with a stray
stash entry. This turned up as a flaky stash/stash_staged on the git
2.32.0 CI job; the newer-git jobs take the `git stash push --staged`
path and never reach this code.

Starting every command up front removes the race, and collecting stderr
into buffers lets exec's own copying goroutines do the work. That also
fixes two lesser problems in the same function: finalErrors was appended
to from several goroutines without synchronization, and a failed Start
was only logged, so a pipeline that never ran reported success.
2026-08-06 16:14:08 +02:00
Stefan HallerandClaude Opus 5 d2a1a4f2a2 Start all commands of a pipeline before waiting for any of them
PipeCommands ran every command in its own goroutine, each doing
Start/read-stderr/Wait, with nothing ordering one goroutine's Start
against another's Wait. That ordering matters: StdoutPipe registers the
parent's read end in cmd.parentIOPipes, and Cmd.Wait closes those
descriptors when it returns. The next command's Stdin is that very
*os.File, and exec passes a user-supplied *os.File through untouched, so
Start hands the child whatever the fd happens to be at that moment. If
the producer finished and got reaped before the consumer's goroutine
reached Start, that fd was already closed, File.Fd() returned -1, and
the child was started with fd 0 closed -- reading nothing at all.

The only caller is the pre-2.35 fallback in SaveStagedChanges, which
pipes `git stash show -p` into `git apply -R`. Losing that race left
git apply with an empty patch, so it failed with "unrecognized input",
the following `git stash drop` never ran, and the user was left with a
stray stash entry. This turned up as a flaky stash/stash_staged on the
git 2.32.0 CI job; the newer-git jobs take the `git stash push --staged`
path and never reach this code.

Starting every command up front removes the race, and collecting stderr
into buffers lets exec's own copying goroutines do the work. That also
fixes two lesser problems in the same function: finalErrors was appended
to from several goroutines without synchronization, and a failed Start
was only logged, so a pipeline that never ran reported success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:34:47 +02:00
Stefan Haller bd76666d97 Fix transitions of entering and exiting filtering mode (e.g. by path or author) (#5897)
Entering or leaving filtering mode switched the screen mode and the
focused panel immediately, then reloaded the commit list in the
background. The result was an unfiltered list presented in the layout
that says "you are filtering", with nothing to say that anything was
still happening — and in a big repo that state can last seconds.

This is a regression in 0.64.0 (more specifically, from #5790): before
that we stopped blocking the UI thread on refreshes, the reload happened
before any of it, so the two always agreed; the price was a frozen UI
for the duration.

Do neither: reload on a worker, so the UI stays live, and hold back
everything the user can see of the change until the new lists are ready,
so they still land together in one frame. A waiting status says what is
going on in the meantime, and blocking input means the keys pressed
while it runs arrive after the change rather than acting on a list that
is about to be replaced.
2026-08-05 17:49:00 +02:00
Stefan Haller 1b901c7187 Scroll the selection into view after a filtering mode change
The commit list a filtering mode change leaves behind has nothing to do
with the one that was showing, so the scroll position it inherits says
nothing about where the selection ended up, and the selection can land
anywhere off screen. PostRefreshUpdate only moves the cursor within the
existing scroll position, so ask for the scroll separately, the way the
commits refresh does when it moves the selection itself.

Exiting filtering mode looked like it worked, but only by accident: the
commits refresh recognizes the commit that was selected before it ran,
selects it again at its new index, and scrolls because the index moved.
That does nothing for the case where the commit is gone from the list, or
for entering filtering mode, where we select the first commit ourselves.
2026-08-05 17:29:20 +02:00
Stefan Haller 7e6d5ff7c1 Don't show an unfiltered list as if it were filtered
Entering or leaving filtering mode switched the screen mode and the
focused panel immediately, then reloaded the commit list in the
background. The result was an unfiltered list presented in the layout
that says "you are filtering", with nothing to say that anything was
still happening — and in a big repo that state can last seconds.

Before we stopped blocking the UI thread on refreshes, the reload
happened before any of it, so the two always agreed; the price was a
frozen UI for the duration.

Do neither: reload on a worker, so the UI stays live, and hold back
everything the user can see of the change until the new lists are ready,
so they still land together in one frame. A waiting status says what is
going on in the meantime, and blocking input means the keys pressed while
it runs arrive after the change rather than acting on a list that is
about to be replaced.
2026-08-05 17:29:20 +02:00
Stefan Haller b30c734513 Handle entering and leaving filtering mode in one place
Setting a filter and clearing it are the same transition in opposite
directions: mutate the mode, bring the screen mode in line with it,
reload the views that depend on the filter, and put the selection
somewhere sensible in the reloaded commit list. They were implemented
twice, once in the filtering menu and once in ModeHelper, which is how
the two came to repaint the commit list in different ways.

Derive the screen mode and the panel switch from whether a filter is
active after the change, so both directions fall out of the same code,
and give ModeHelper the entry points for both. The filtering menu is
left with nothing but the menu.
2026-08-05 17:29:20 +02:00
Stefan Haller e54cb4bf42 Decouple hiding the working tree state from blocking input
Blocking keyboard input and hiding the working tree state mode are two
separate concerns; they were fused into one helper because every caller
so far wanted both. A caller that blocks input for something other than a
rebase would then hide the "Rebasing" indicator for the duration of its
operation, which has nothing to do with it.

Make it an explicit option instead, so blocking input on its own doesn't
imply anything about the modes on display.
2026-08-05 17:29:20 +02:00
Stefan Haller f4968f6839 Rename suppressRebasingMode to suppressWorkingTreeStateMode
The mode it suppresses is active for any working tree state, not just a
rebase: merging, cherry-picking and reverting show through the same
indicator. Name it after what it hides.
2026-08-05 17:29:20 +02:00
Stefan Haller f8b7bab1ab Decide the commit graph from the loaded list, not the filtering mode
Whether a graph can be drawn was read from the filtering mode, while the
graph itself is drawn over the commit list in the model. Those two only
agree once the list has been reloaded for the new mode, and a filtering
mode change reloads the list in the background, so in between we can be
asked to draw a graph over a list the graph makes no sense for.

That is not just cosmetic. Commits in a filtered list are almost never
each other's parents, so no pipe ever terminates: the pipe set grows by
one per row and every continuing pipe rescans it, which is cubic in the
length of the list. Escaping out of filtering mode with a filtered list
of 13000 commits — as you get once the 300 commit limit has been lifted,
which happens for good as soon as the selection passes COMMIT_THRESHOLD
— wedges the UI thread for around twenty minutes.

Record whether the list was loaded with a filter, right where the list
itself is stored, and decide from that. The graph now also stays up while
the pre-change list is still on display, rather than vanishing a moment
before the list it belongs to.
2026-08-05 17:29:20 +02:00
Stefan Haller 8996bd68b9 Don't let integration tests race a background git repack (#5898)
This fixes spurious test failures caused by git's auto maintenance
running concurrently with a test's fixture setup; see the first commit's
message for details.

In addition, we change the test harness so that such a failure in a
setup method doesn't take down the whole test binary.
2026-08-05 17:28:56 +02:00
Stefan Haller 34da956f5d Don't let a broken fixture take down the whole test binary
A failing setup step called Shell.fail, which panicked. Tests run as
parallel subtests, so that panic aborted the entire test binary: one bad
fixture cost us the results of all ~500 tests, and the failure was
reported as a stack trace rather than against the test that caused it.

Keep panicking to skip the remaining setup steps -- they would only
produce follow-on failures -- but recover in createFixture and return the
message as that test's error. All three clients already propagate an
error from a test, so they report it the way they report any other
failure.
2026-08-05 17:02:06 +02:00
Stefan Haller 4ec91a0bf5 Don't let integration tests race a background git repack
Every `git commit` forks `git maintenance run --auto --quiet --detach`,
and git 2.54 changed what that runs from the `gc` task to the
"geometric" strategy. The geometric repack's auto condition passes its
threshold of 100 to too_many_loose_objects(), which estimates the loose
object count from the objects/17 fanout directory times 256, so the real
trigger is two objects in that one directory -- where the old gc task
needed 27. Fixture repos reach two easily: every CreateNCommits(n>=6)
repo already stores the blob for file06.txt there, so a single commit
object hashing into 17 (about 4% of fixtures with 10 commits, 15% with
40) tips it over, and from then on every commit in that repo forks a
detached `git repack -d`, which prunes loose objects while the next
fixture command -- or lazygit under test -- is still working in the same
repo.

That is where the CI panics during fixture setup come from:

    panic: error running command: [git commit -m commit-10]
        error: invalid object 100644 50d5612... for 'file09.txt'
        error: Error building trees

The reported hashes are exactly the fixture blobs, so `git add` staged
them correctly; they were unlinked underneath the commit. Only the
"git latest" jobs saw this, since the pinned 2.32/2.38/2.44 jobs predate
the strategy change.

git's own test suite guards against the same thing by exporting
GIT_TEST_MAINT_AUTO_DETACH=false ("Ensure that tests cannot race with
background maintenance by default"). Turning maintenance off outright is
stronger: no test repo needs it, and it also spares us a forked git
process per commit. maintenance.auto has been honored since git 2.29, so
it covers every version in the CI matrix.

Measured on a 40-commit fixture: a background repack fired in 4 of 25
runs before, 0 of 25 after.
2026-08-05 16:58:37 +02:00
Stefan Haller aee0e40ec1 Update docs and schema for release (#5892) 2026-08-04 09:22:45 +02:00
Stefan Haller a1a542479c Update docs and schema for release 2026-08-04 09:19:40 +02:00
Stefan Haller fc0a3a4e2a Update translations from Crowdin (#5891) 2026-08-04 09:18:49 +02:00
Stefan Haller 1f231c9262 Update translations from Crowdin 2026-08-04 09:03:58 +02:00
Stefan Haller cca82cfe48 Make justfile commands available in the Nix development shell (#5890)
### Description

Add `just` to the default Nix development shell so contributors can use
the repository's `justfile` without installing `just` separately. Keep
GNU Make available as the existing fallback.

no `flake.lock` update required. `just` comes from the already-pinned
`nixpkgs` input, and no flake input changes.

Closes #5881.

### Testing

- `nix-instantiate --parse flake.nix`
- `nix flake check`
- `nix develop --ignore-environment -c just --version`
- `nix develop --ignore-environment -c go version`
- `nix develop --ignore-environment -c just format`
- `nix develop --ignore-environment -c just build`
- `nix develop --ignore-environment -c just lint`
- `nix develop --ignore-environment -c just unit-test`
- `nix-shell --run 'just --version'`

### Please check if the PR fulfills these requirements

* [x] Cheatsheets are up-to-date (run `go generate ./...`)
* [x] Code has been formatted (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [x] Tests have been added/updated (see
[here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
for the integration test guide) — no test-code change needed; validated
with the commands above
* [x] Text is internationalised (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
— no user-facing strings changed
* [x] If a new UserConfig entry was added, make sure it can be
hot-reloaded (see
[here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
— no UserConfig entry added
* [x] Docs have been updated if necessary — no documentation change
needed
* [x] You've read through your own file changes for silly mistakes etc
2026-08-04 07:50:12 +02:00
Tyce Herrman 1edd432d2f Make justfile commands available in the Nix development shell 2026-08-04 07:10:33 +02:00
Stefan Haller ccdfa8f4e3 Fix orphaned processes on Windows when quickly navigating between commits (#5885)
Fixes #5879: with an external diff command configured, quickly
navigating between commits on Windows accumulates orphaned
`git.exe`/`difft.exe`/`conhost.exe` processes that keep computing their
diffs in the background and persist after lazygit exits.

Stopping a pty task on Windows relied on `ClosePseudoConsole`, whose
`CTRL_CLOSE_EVENT` only reaches clients *attached to the pseudoconsole
at that moment*. Attachment happens asynchronously during child startup,
so a task stopped within the first few milliseconds of its life — which
is exactly what rapid navigation produces — misses the event entirely
and survives, together with its whole process tree (git for Windows
spawns through a two-level git.exe wrapper, so a single task has several
attach windows).

The fix puts the child into a job object before it executes its first
instruction (created suspended → assigned → resumed), so every
descendant is in the job from the start. The pty teardown still closes
the pseudoconsole first and gives clients that received the close event
a moment (500ms) to exit through their own handlers — git's cleans up
lock files — and then terminates whatever is left in the job.
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` doubles as a safety net: if lazygit
exits without running the teardown, the OS closes the job handle and
reaps the tree.

Validated with a harness that mimics lazygit's stop path with randomized
0–120ms stop delays: before the fix, 3 of 30 process trees survived as
permanent orphans per run; after it, three runs of 30 all came back with
zero orphans, with the job kill catching exactly the children that
missed the close event (6 of 90) while the rest still exited gracefully
through the event as before.
2026-08-04 07:06:26 +02:00
Stefan HallerandClaude Fable 5 d251faddec Reap pty process trees synchronously when quitting on Windows (#5879)
The pty teardown in Close runs on a background goroutine that doesn't
get to finish when lazygit is quitting: the process exits milliseconds
after the view buffer managers are closed. The job objects still cover
the clients -- KILL_ON_JOB_CLOSE reaps them when the process's handles
are rundown at exit -- but nothing reaps the conhost, so on Windows
builds whose conhost fails to run down on its own, quitting leaks one
conhost per live pty.

This is not a rare timing window: a diff longer than what has been
read keeps its git process (and thus its pty and conhost) running for
the entire time it is displayed, so that scrolling can read more.
Quitting while looking at a long diff is therefore the common case,
and with an external differ configured it leaks a conhost on affected
builds on almost every quit.

Fix this by having the gui's shutdown path wait synchronously for the
in-flight teardowns after closing the view buffer managers. A quit
signal makes the teardowns skip the conhost rundown wait -- the
conhost serves nothing once its clients are dead, and the exit must
not stall for its sake -- so the wait normally completes in
milliseconds, keeping quit as fast as before; a 2-second cap protects
the exit path even if a teardown wedges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 07:03:27 +02:00
Stefan HallerandClaude Fable 5 b4dee2af0e Reap the pty's conhost on Windows builds where it fails to run down (#5879)
A pseudoconsole's conhost.exe is spawned by CreatePseudoConsole as a
child of lazygit, so it is not part of the job object that the pty
teardown kills. That is normally fine: a healthy conhost runs itself
down once the reference handle is closed and its clients are gone. But
conhost builds before the ConPTY overhaul that shipped with Windows 11
24H2 (confirmed on 23H2, build 22631) fail to complete the rundown
when a client attached after the close event was delivered and was
then killed -- the fate of exactly the clients the job kill exists for
-- and such a conhost lingers forever with no clients, at a rate of
about one per five fast commit navigations. These builds remain
widespread: all of Windows 10 (whose ESU tail runs into 2028, and
whose hardware often cannot run Windows 11 at all) plus pre-24H2
Windows 11 fleets.

Since Windows offers no way to obtain the conhost's pid or handle from
the HPCON, identify it by diffing lazygit's direct conhost children
around the CreatePseudoConsole call, serialized by a mutex so that two
concurrently starting ptys can't confuse each other's diff, and open a
handle immediately so that pid reuse is harmless. The teardown then
gives conhost a second to exit on its own before terminating it; on
healthy builds the wait succeeds and the reap never fires. If the
conhost can't be identified unambiguously, we simply don't reap, which
is no worse than before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 07:03:27 +02:00
Stefan HallerandClaude Fable 5 6048fee49d Kill the pty child's process tree on Windows to avoid orphans (#5879)
Stopping a pty task on Windows relies on ClosePseudoConsole, which
delivers CTRL_CLOSE_EVENT to the console's attached clients. But only
to those attached at that moment: when the user flicks quickly through
commits, a task is often stopped within the first few milliseconds of
its life, before the child has attached to the pseudoconsole. Such a
child misses the event and survives, running the entire diff to
completion in the background (spawning one external differ per changed
file) and keeping its conhost.exe alive; rapid navigation accumulates
these git/difft/conhost trees, and they outlive lazygit. Grandchildren
are affected too: git for Windows runs commands through a two-level
git.exe wrapper, so a single task has several attach windows, and a
grandchild spawned while the console is going down is orphaned even
when its parent got the event and exited.

Fix this by putting the child into a job object before it runs its
first instruction (created suspended, assigned, then resumed), so that
every descendant is in the job from the start; the teardown in Close
terminates the job right after initiating the pseudoconsole close.
There is no point in a grace period between the two: the close event
is not a graceful signal -- git and the common diff tools leave it to
the default handler, which calls ExitProcess at an arbitrary point --
so clients that received it are already dying, and the kill exists for
those that missed it. Killing at an arbitrary point cannot leak a
stale index.lock, because pty-rendered commands no longer take that
lock (see withPtyGitConfig in pkg/gui/pty.go).

The pseudoconsole close runs on its own goroutine because the kill
must not wait for it: on builds where ClosePseudoConsole blocks until
the console host exits (pre-24H2), the host keeps running as long as a
surviving client does, and that client only goes away through the job
kill; sequencing the kill after a blocking close would deadlock in
exactly the case the kill exists for.

KILL_ON_JOB_CLOSE doubles as a safety net: if lazygit exits without
running the teardown, the OS closes the job handle and reaps the tree.

In a harness that mimicked the stop path with randomized 0-120ms stop
delays, 3 of 30 process trees survived as orphans before this change;
none survive with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 07:03:27 +02:00
Stefan HallerandClaude Fable 5 d52c0a604f Give task commands a Terminate method instead of exposing their process
The task stop path terminates the still-running command by pulling its
*os.Process out of the Cmd interface and applying one global strategy
(TerminateProcessGracefully) to it. That shape can't accommodate the
upcoming fix for orphaned process trees on Windows: there, stopping a
pty task requires terminating the entire process tree via a job object
whose handle lives with the pty, not with the process. And the two Cmd
implementations genuinely need different strategies anyway: a
process-group kill (the likely future fix for #5675 on Unix) is only
safe for pty children, which run as session leaders, while plain
commands share lazygit's own process group.

So let each Cmd implementation decide how to terminate itself, and drop
GetProcess, which had no other callers. No change in behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 07:03:27 +02:00
Stefan Haller 9b22c7dc77 Prevent stale index.lock files from diffs rendered through a pty on Windows (#5888)
At the end of a diff against the worktree, git re-reads and refreshes
the index and writes it back if it found stale stat information
(diff.autoRefreshIndex, on by default). It holds index.lock for the
whole refresh; GIT_OPTIONAL_LOCKS does not cover this lock, and the
window scales with the size of the repository (~150ms for a 6k-file
repository with a warm stat cache).

On Windows, a pty task that is stopped because the user moved on
terminates its git process at an arbitrary point: tearing down the
pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the default
handler, which simply calls ExitProcess. If that lands inside the
refresh, a stale index.lock is left behind and the next git command
chokes on it. This is the same problem that 98801da106 fixed by no
longer killing git processes; the ConPTY support added in 0.63
reintroduced it through the close event.

Disable the automatic refresh for pty-rendered commands. They can afford
it: the refresh only persists refreshed stat information, and lazygit's
foreground git status refreshes -- which never run in a pty and are
never killed -- already write that back on every user action and on
terminal focus-in. The cost is that while the on-disk stat cache is
stale, an external differ is invoked even for files whose stat
information changed but whose content didn't, showing them as empty
diffs; this heals with the next foreground refresh, which also
re-renders the view.

Unix keeps the refresh: a stopped pty child gets SIGTERM there, and
git's signal handlers remove its lock files, so the lock window is
harmless. The rawGit renderer keeps it too: its tasks don't run in a pty
and are never killed on Windows -- they either run to completion or die
on a broken pipe mid-output, before the refresh begins.
2026-08-04 07:03:24 +02:00
Stefan HallerandClaude Fable 5 0a92529b14 Prevent stale index.lock files from diffs rendered through a pty on Windows
At the end of a diff against the worktree, git re-reads and refreshes
the index and writes it back if it found stale stat information
(diff.autoRefreshIndex, on by default). It holds index.lock for the
whole refresh; GIT_OPTIONAL_LOCKS does not cover this lock, and the
window scales with the size of the repository (~150ms for a 6k-file
repository with a warm stat cache).

On Windows, a pty task that is stopped because the user moved on
terminates its git process at an arbitrary point: tearing down the
pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the
default handler, which simply calls ExitProcess. If that lands inside
the refresh, a stale index.lock is left behind and the next git
command chokes on it. This is the same problem that 98801da106 fixed
by no longer killing git processes; the ConPTY support added in 0.63
reintroduced it through the close event.

Disable the automatic refresh for pty-rendered commands. They can
afford it: the refresh only persists refreshed stat information, and
lazygit's foreground git status refreshes -- which never run in a pty
and are never killed -- already write that back on every user action
and on terminal focus-in. The cost is that while the on-disk stat
cache is stale, an external differ is invoked even for files whose
stat information changed but whose content didn't, showing them as
empty diffs; this heals with the next foreground refresh, which also
re-renders the view.

Unix keeps the refresh: a stopped pty child gets SIGTERM there, and
git's signal handlers remove its lock files, so the lock window is
harmless. The rawGit renderer keeps it too: its tasks don't run in a
pty and are never killed on Windows -- they either run to completion
or die on a broken pipe mid-output, before the refresh begins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:53:53 +02:00
Stefan Haller 6871959caa Exclude more commit trailers from auto-wrapping (#5871)
We already excluded the most commonly used commit trailers from being
auto-wrapped when typing or rewording a commit message, but this was
limited to two hard-coded ones ("Signed-off-by:" and "Co-authored-by:").
Extend this mechanism to use a heuristic to prevent more trailers from
wrapping; the heuristic kicks in for any "Key: Value" line if Key
contains a dash, or the value looks like a URL (so that it also catches
things like "Bug: https://my-bug-tracker/345").

Fixes https://github.com/jesseduffield/lazygit/discussions/5869.
2026-08-02 19:25:14 +02:00
Stefan Haller b6deefacd2 Exclude more commit trailers from auto-wrapping
We already excluded the most commonly used commit trailers from being
auto-wrapped when typing or rewording a commit message, but this was
limited to two hard-coded ones ("Signed-off-by:" and "Co-authored-by:").
Extend this mechanism to use a heuristic to prevent more trailers from
wrapping; the heuristic kicks in for any "Key: Value" line if Key
contains a dash, or the value looks like a URL (so that it also catches
things like "Bug: https://my-bug-tracker/345").

To avoid mistaking a "Key: Value"-looking line in the message body for a
trailer, only apply the heuristic in the last paragraph of the message,
i.e. the block of lines at the end that is separated from the body by a
blank line. Each line there is judged on its own, so a line that isn't
recognized as a trailer still wraps without affecting the real trailers
next to it.
2026-08-02 19:22:34 +02:00
Stefan Haller 9811bc7ea8 Move Github PR cache out of state.yml into a separate file (#5884)
Having the cache in state.yml causes this file to be rewritten every
60s, which is annoying if you have a lazygit running in the background
somewhere without even realizing it, and it keeps overwriting the
foreground lazygit's newer command shell history and recent repos list
with its stale data. State.yml should only contain things that change in
response to user actions, not periodically.
2026-08-02 19:12:03 +02:00
Stefan Haller 47471c501b Move Github PR cache out of state.yml into a separate file
Having the cache in state.yml causes this file to be rewritten every
60s, which is annoying if you have a lazygit running in the background
somewhere without even realizing it, and it keeps overwriting the
foreground lazygit's newer command shell history and recent repos list
with its stale data. State.yml should only contain things that change in
response to user actions, not periodically.
2026-08-02 19:09:22 +02:00
Stefan Haller 669c445eb6 Show a Github PR's combined checks state in branches list (and main view for selected branch) (#5874)
For a while now we have been showing Github icons in the branches panel
to indicate which branches have an associated pull request. This PR
changes this so that open PRs (which used to show a green Github icon)
now show a colored symbol indicating the combined state of the Github
checks (if any are configured for this repo):

<img width="1046" height="418" alt="image"
src="https://github.com/user-attachments/assets/3e9074a6-0ae4-423a-90d9-306863daa46c"
/>

This lets you see at a glance which of your PRs are ready to merge.
(Note however that this feature knows nothing about which of the checks
are _required_ for merging, so a branch showing a failure icon may still
be ready to merge if the failing checks are not required ones.)

It may be a little confusing for new users to see two green checkmarks
for a branch, one to the left of the name and one to the right; they
mean very different things. Still, seeing the Github checks state in the
branches list is so useful that I think it's a good compromise;
hopefully users will learn quickly enough what all the icons mean.

When selecting a branch that has a pull request, the main view already
showed some information about the PR (its title, pr number, and the
state (open/closed/merged). This PR adds an aggregated pull request
checks field to this, indicating the overall checks state
(passed/failed/pending/...). It has a hyperlink taking you directly to
the checks tab on Github.

<img width="1538" height="246" alt="CleanShot 2026-07-30 at 12 32 25@2x"
src="https://github.com/user-attachments/assets/68aa24da-d78a-45d7-8812-db8b509bbbc8"
/>

The update frequency of the checks badge is tied to the auto-fetch
cadence for now (once every minute by default); you can fetch manually
to force an update, and some user actions (including the focus-in
refresh) update the PR states too, but anything more fancy (e.g.
fetching just the checks state of the selected branch) would have been
more work, and I want to see if this is good enough.
2026-08-02 19:08:25 +02:00
Stefan Haller 2669842445 Show a Github PR's combined checks state in branches list and main view
In the branches list we show the checks icon (✓, ✗ etc) instead of the
gihub icon for branches that are open and have a state. It is a little
confusing, because the ✓ in front of the name means something very
different than the ✓ after it, but the checks status is just too useful
to see in the list.

In the main view we show it as a compact status before the PR title,
with a hyperlink that takes you directly to the checks tab in Github.
2026-08-02 13:06:21 +02:00
Stefan Haller 320d33a8ef Centralize pull-request header presentation
The branch controller should decide which pull request to show, not how its
header is styled and linked. Move the existing formatter and state badge next
to the branch presentation helpers so subsequent header changes stay in one
layer.
2026-07-31 09:01:44 +02:00
Stefan Haller ff26f61ffd Carry aggregate check state with GitHub pull requests
GitHub exposes a combined status for the head commit without requiring
individual check contexts. Include that rollup in the existing request
and startup cache so every consumer sees the same state without making a
second network request.
2026-07-31 09:01:44 +02:00
Stefan Haller 67ec23e08a Isolate GitHub pull-request response parsing
The fetch currently combines transport, JSON decoding, and model
conversion, which makes response changes difficult to verify without
exercising the network. Put the deterministic work behind a small parser
so later payload changes can be covered with raw GraphQL fixtures.
2026-07-31 09:01:44 +02:00
Stefan Haller d8d09e1f94 Rework the custom pager config (rename to diff renderer) (#5870)
For a long time lazygit has used the term "custom pager" to refer to
what's really a "diff renderer". A pager is a program that allows you to
view output page by page (hence the name), e.g. less; lazygit's custom
diff renderers are not pagers. It used the term only because the feature
is implemented using git's `GIT_PAGER` env var, but that's an
implementation detail.

Rename the 'git.pagers' config to 'git.diffRenderers', and restructure
its elements while we're at it to make things clearer:

- Add a 'type' field to explicitly specify which type of diff renderer
it is (the two fundamentally different ones are 'stdinFilter' and
'extDiff').
- Add a third type, 'rawGit', which has an 'args' field that makes it
easy to use 'git diff --color-words' as a custom renderer
- Unify the old 'pager' and 'externalDiffCommand' fields to a single
'command' field for both types

Existing config files are migrated automatically.
2026-07-31 08:58:48 +02:00
Stefan Haller e296845aae Adapt links 2026-07-31 08:42:51 +02:00
Stefan Haller 4598c7b548 Adapt documentation 2026-07-31 08:42:51 +02:00
Stefan Haller 8731d8a51b Rework the custom pager config (rename to diff renderer)
For a long time lazygit has used the term "custom pager" to refer to
what's really a "diff renderer". A pager is a program that allows you to
view output page by page (hence the name), e.g. less; lazygit's custom
diff renderers are not pagers. It used the term only because the feature
is implemented using git's GIT_PAGER env var, but that's an
implementation detail.

Rename the 'git.pagers' config to 'git.diffRenderers', and restructure
its elements while we're at it to make things clearer:

- Add a 'type' field to explicitly specify which type of diff renderer
  it is (the two fundamentally different ones are 'stdinFilter' and
  'extDiff').
- Add a third type, 'rawGit', which has an 'args' field that makes it
  easy to use 'git --color-words' as a custom renderer
- Unify the old 'pager' and 'externalDiffCommand' fields to a single
  'command' field for both types

Existing config files are migrated automatically.
2026-07-31 08:42:51 +02:00
Stefan Haller ac4a1f575c Fix tests to use the right field name
It doesn't matter for what this test used to test (that nothing changes
if the 'pagers' array exists), but it will be relevant once we further
migrate the 'pagers' array from there.
2026-07-31 08:42:51 +02:00
Stefan Haller 1cf632002e Make CurrentPagerName always return a name
We don't want callers to need any additional logic, so pass in the
translation set so that the function can decide what static text to
return. This allows us to get rid of the CurrentPagerUsesGitConfigDiff
method which is in the way for the refactoring we're about to do.
2026-07-31 08:42:51 +02:00
Stefan Haller 2694aa774a Extract GitCommandBuilder method AddCommonDiffArgs
We were setting the same arguments in five places; extract this to one.
2026-07-31 08:42:51 +02:00
Stefan Haller 1ebb65b775 Cleanup: add blank lines for canonical markdown
My markdown formatter in VS Code adds these when I press Command-S.
2026-07-31 08:42:51 +02:00
Stefan Haller 4479d34e37 Remove BenchmarkMigrationOnLargeConfiguration
It has served its purpose when config migration was initially
implemented, but nobody runs this benchmark nowadays, and the example
config has run out of date with reality. Some PRs have still updated it
when they made changes to the config, but others didn't, and it's
unclear what the rules are; so let's just remove it.
2026-07-31 08:42:51 +02:00
Stefan Haller 91dcd4cbb3 Reorder commits (or rebase todos) by dragging with the mouse (#5857)
Pressing the left button on the current selection now starts a drag that
moves the selected commits, both in the normal commits view and for
todos during an interactive rebase. A press on an unselected row still
creates a range selection.

While dragging, a "drop here" indicator shows where the commits will be
inserted.

Moving commits with the mouse is useful for the case that you want to
move them a longer distance, because that's slow when doing it one by
one with the keyboard, and also you don't want to resolve conflicts at
every step. The standard workaround for that is to enter an interactive
rebase first and then continue it afterwards, but that's a bit
cumbersome; dragging solves that nicely.

Closes #5842.
2026-07-31 08:41:18 +02:00
Stefan Haller 104fdf34a9 Keep the destination visible while commits move
Moving commits runs a rebase, which can take a while. Instead of
letting the drop indicator vanish the moment the button is released,
keep it in place and turn it into a "moving commits here" spinner once
the move takes longer than a short grace period, so that quick moves
stay free of flicker. The indicator is cleared when the post-move
refresh lands.
2026-07-31 08:37:28 +02:00
Stefan Haller cefec1c5c9 Cancel commit drags with escape
While a commit drag is in progress, escape now aborts it: the drag
state and the drop indicator are discarded and the mouse capture is
released, so nothing happens when the button is eventually released.
Otherwise escape keeps its normal meaning.
2026-07-31 08:37:28 +02:00
Stefan Haller 0738d55551 Keep scrolling while commits are dragged at an edge
Reuse the drag autoscroller for commit drags. Scrolling stops once the
insertion point reaches the end of the allowed range in the scroll
direction, so during a rebase the view doesn't keep scrolling once the
last insertion position among the todos has been reached.
2026-07-31 08:37:28 +02:00
Stefan Haller eda4ad1192 Drag selected commits to a new position
Pressing the left button on the current selection now starts a drag
that moves the selected commits, both in the normal commits view and
for todos during an interactive rebase. A press anywhere else falls
through to the usual click handling, so dragging from an unselected
line still creates a range selection, and releasing without having
moved collapses the selection to the pressed commit like a plain click
would.

While dragging, the insertion point follows the pointer: rows below
the dragged block insert after the pointed-at commit, rows above it
insert before it, and during a rebase the destination is limited to
the contiguous block of movable todos around the selection. gocui
moves the view cursor along with the pointer, so each drag event moves
it back to keep the original selection highlighted.

The move happens on release. The model may have been refreshed during
the drag, so the dragged commits are located again by their identity
(hash, subject, todo action); if they no longer form a unique
contiguous block, the drop is ignored rather than guessing.
2026-07-31 08:37:28 +02:00
Stefan Haller b055d28fb1 Show commit insertion points during a drag
Render the insertion point of a commit drag as a non-model item in the
commits list. It must be inserted at the right position relative to
the section headers, because the list renderer assumes non-model items
are ordered by their model index.

Not used yet, we'll hook it up to the drag gesture in the next commit.
2026-07-31 08:37:28 +02:00
Stefan Haller e756511042 Move dragged commits in one rebase
Let the todo-move primitives take a distance instead of hardcoding a
single row, by iterating the one-row move in memory. Dropping a commit
several rows away thus rewrites the todo file once and, outside of an
interactive rebase, runs a single rebase rather than one per row.
2026-07-31 08:37:28 +02:00
Stefan Haller b85483ecc0 Unify commit movement before adding drag destinations
Merge the up/down variants of the move commands into one
direction-parameterized implementation. Dragging commits is about to
need moves over arbitrary distances, which we don't want to build twice.
2026-07-31 08:37:28 +02:00
Stefan Haller 4085b6462d Create a range selection in list views by dragging with the mouse (#5856)
Like in the staging view, drag with the mouse in a list view to create a
non-sticky range selection (including auto-scrolling as the mouse
reaches the view edge).
2026-07-31 08:37:13 +02:00
Stefan Haller 14434015c7 Keep range drags moving at panel edges
Give the list views the same edge autoscroll during drag selection
that the staging view already has; the new mouse-release binding stops
the autoscroll when the drag ends.
2026-07-31 08:32:43 +02:00
Stefan Haller e7998356be Create a range selection by dragging with the mouse in list views
Dragging with the left button held now extends the selection from the
pressed line, exactly like moving with shift+up/down does. We use the
non-sticky flavor so that the range collapses on the next plain cursor
movement, again matching the keyboard behavior.

The binding is only registered for contexts that support range selection
in the first place; dragging in other lists continues to do nothing.
2026-07-31 08:32:43 +02:00
Stefan Haller 10bac9dbf6 Auto-scroll when dragging to create range selection in staging view (#5855)
The staging view (and custom patch building view) already has the
capability to create a range selection by dragging with the mouse;
however, a longer range couldn't be selected this way because the
dragging stopped at the view edge. Add auto-scrolling so that the view
scrolls as the mouse reaches the view edge; slowly on the innermost edge
row, faster on the outermost row and beyond. Scrolling starts after a
short delay so that a drag merely passing near the edge doesn't scroll.
2026-07-31 08:32:25 +02:00
Stefan Haller b682fb7635 Auto-scroll when dragging to create range selection in staging view
When the pointer reaches the edge of the view during a drag (or leaves
the view entirely, which mouse capture makes possible), keep scrolling
and extending the selection: slowly on the innermost edge row, faster
on the outermost row, and very fast beyond. Scrolling starts after a
short delay so that a drag merely passing near the edge doesn't scroll.

When the view loses focus mid-drag (e.g. because a popup appeared),
cancel the autoscroll and the mouse capture.
2026-07-31 08:26:35 +02:00
Stefan Haller f5a069342b Add DragAutoScroller helper
It can be used to auto-scroll a view during drag gestures when the
cursor reaches the edge of the view.
2026-07-31 08:26:35 +02:00
Stefan Haller ba024ed6c9 Add ViewDriver methods for mouse drag gestures in integration tests
Add press/move/release primitives next to the existing Click. The test
driver remembers the last reported position so a release doesn't have
to repeat the coordinates, and RepeatMouseMove lets a test verify that
a held-button motion event within the same cell has no effect.
2026-07-31 08:26:35 +02:00
Stefan Haller 98613957ae Refactor: extract replayMouseEvent helper method 2026-07-31 08:26:35 +02:00
Stefan Haller a199a0a680 Gocui mouse event fixes (#5854)
Some fixes to gocui mouse event handling; these don't fix current bugs
in lazygit, but they are needed for upcoming work involving drag
gestures.
2026-07-31 08:26:16 +02:00
Stefan Haller 4609985029 Route mouse events to their originating view during a drag gesture
Route all mouse events to the view that was under the pointer when the
left button was pressed, until the button is released. Previously each
event went to whatever view was under the pointer at the time, so a
drag that left the view's bounds started acting on neighboring views.

Since events can now carry positions outside the view, clamp the view
cursor to the view's bounds in that case (handlers still receive the
unclamped position), and require an actual click for tab activation so
that a captured drag crossing the tab row doesn't switch tabs.
2026-07-31 08:22:35 +02:00
Stefan Haller 44a2bbeb7c Deliver mouse release after a drag
Releasing a mouse button was delivered as a plain mouse-move (hover)
event: the release processing resets dragState to NOT_DRAGGING, after
which the event fell into the NOT_DRAGGING branch. Views therefore had
no way of telling that a drag gesture ended, which the upcoming
drag-based features (range selection, commit reordering) need.

Deliver the release as a real mouse event with the MouseRelease key
and normalize its modifiers to ModNone, so release bindings also match
modified drags. Make recordClickInfo ignore it: a release is the end of
a click, not a click of its own, and must not break double-click
detection.
2026-07-31 08:22:35 +02:00
Stefan Haller 38d2293a10 Add test for double-click detection
Add a test pinning down that a press/release/press sequence at the
same position is detected as a double click. An upcoming commit starts
delivering the release as a real mouse event to the click-recording
code, which must not mistake it for a click of its own.
2026-07-31 08:22:35 +02:00
Stefan Haller a965db2a7d Demonstrate that drag release becomes hover 2026-07-31 08:22:35 +02:00
Stefan Haller ff53a3ed8c Preserve the first mouse movement of a drag
When the left button is pressed and the pointer then moves, the event
that made the MAYBE_DRAGGING -> DRAGGING transition fell through the
switch without being assigned a key or modifier, so the first cell of
every drag arrived at handlers as a MouseRelease event without the
motion modifier and was effectively lost. Give it the same
MouseLeft/ModMotion identity as all subsequent drag events.

Held-button motion events that stay within the pressed cell carry no
information at all; swallow them instead of letting them through as
further release-shaped events (which used to clobber the double-click
state when the pointer jittered within a cell between two clicks).
2026-07-31 08:22:35 +02:00
Stefan Haller c5fe27dfa5 Additions to AGENTS.md 2026-07-31 08:22:35 +02:00
Stefan Haller 5e0a2e97ef Some small UI polish (#5853)
Two small UI changes:
- Change list section headers to a slightly more elegant design
- Change default spinner frames and rate
2026-07-31 08:22:22 +02:00
Stefan Haller 31cbabe48f Change list section headers to a slightly more elegant design
I'm not a skilled UI designer, so I suspect there may be even better
options, but it's definitely already better than the raw ASCII "---" we
had before.

Put the line only at the beginning because it looks bad if the line
after the label is misaligned when labels don't have the same width
(e.g. "Remote" vs. "Local" in the divergence view).
2026-07-31 08:03:40 +02:00
Stefan Haller 22db808a01 Add a helper function for formatting list section headers
We want to change their design, and we want to do it only in one place.
2026-07-31 08:03:40 +02:00
Stefan Haller 67c81c5b14 Change default spinner frames and rate
I find that this looks a little nicer, and it reduces CPU load by not
spinning so fast.
2026-07-31 08:03:40 +02:00
Stefan Haller 940d7cc80f Support absolute paths when detecting edit preset from EDITOR env var (#5876)
Fixes #5875.
2026-07-31 08:01:57 +02:00
Stefan Haller f15f713c68 Support absolute paths when detecting edit preset from EDITOR env var 2026-07-30 17:31:56 +02:00
Stefan Haller df0943ad33 Fix multi-selection of files with common prefix not working in commit files panel (#5868)
When selecting sibling directories sharing a common prefix, trying to
add them to a custom patch or discarding them from the commit would only
add one, not both. We had the same bug in the files panel and fixed it
in #3599, but forgot to make the equivalent change in the commit files
panel.

Fixes #5866.
2026-07-28 18:39:49 +02:00
Stefan Haller 1d107721f2 Fix multi-selection of files with common prefix not working in commit files panel
Equivalent to the change that was made to isDescendentOfSelectedNodes in
files_controller.go in a5eec48b4b.
2026-07-28 18:14:58 +02:00
Stefan Haller c7acf38399 Make isDescendentOfSelectedCommitFileNodes work for the root item
The root item's path is ".", and the path of a file at top level is
"./file". When using GetPath, this gives us "." and "file",
respectively, and isDescendentOfSelectedCommitFileNodes would return
false for these.

Working with the internal paths (i.e. without stripping the leading
"./") fixes this.

There is no known breakage that is caused by this, that's why I'm not
adding an integration test that demonstrates a bug.

Equivalent to the change that was made to isDescendentOfSelectedNodes in
files_controller.go in 302b621b68.
2026-07-28 18:14:58 +02:00
Stefan Haller 8fefe2b933 Cleanup: move variable assignment out of the loop
It never changes inside this function, so there's no need to recompute
it with every loop iteration.

Equivalent to the change that was made to isDescendentOfSelectedNodes in
files_controller.go in d0c6e27fee.
2026-07-28 18:04:35 +02:00
Stefan Haller 6a022241d2 Add test to demonstrate a problem with custom patches and directories sharing a prefix
We had the same bug in the files panel, and fixed it in a5eec48b4b, but
forgot to make the equivalent change to the commit files panel.
2026-07-28 18:04:35 +02:00
Stefan Haller 292035709f Small addition to CONTRIBUTING.md (#5861) 2026-07-25 18:39:03 +02:00
Stefan Haller 9d7ca51ee7 Small addition to CONTRIBUTING.md 2026-07-25 18:36:28 +02:00
Stefan Haller 338d017191 Fix clicking in commits panel of unfocused VS Code window (#5859)
When lazygit is running in a VS Code window that doesn't have the focus
(e.g. a split tab) and you click in the commits panel to focus it and
select a commit at the same time, it would briefly select the commit you
clicked but then flash back to the commit that was previously selected.
This PR fixes that so that the clicked commit stays selected.

This was only a problem with VS Code; in Zed's builtin terminal it
worked, apparently because it first dispatches the click and then the
focus-in event. Ghostty and iTerm2 were not affected because they don't
dispatch clicks in inactive windows or tabs at all.

Labelling as ignore-for-release because it fixes a regression that was
introduced since the last release.
2026-07-25 18:09:39 +02:00
Stefan Haller 3d9318e2a7 Preserve commit clicks during focus refreshes
This fixes the problem described in the previous commit; we no longer
capture the selection at the start of the refresh. There's no reason to
do that (we don't do it for branches either). It is enough to capture
the selection in the final bounce, before we assign the new model slice.
2026-07-24 15:32:19 +02:00
Stefan Haller 5aa003612c Demonstrate stale focus refresh overwriting a click
When clicking in the commits view of lazygit running in an unfocused VS
Code window, VS Code first sends us the focus-in event and then the
mouse-click. The focus-in refresh captures the selection when it starts,
then we handle the mouse click and you briefly see the clicked row
getting selected, but then the selection flashes back to the original
row as the refresh restores it when done.
2026-07-24 15:30:29 +02:00
Stefan Haller a5b4477a20 Fix Windows crash when switching to fullscreen mode with a custom pager (#5838)
Fix a crash when typing `+` twice to go to full screen mode, when a
custom pager such as delta or difftastic is configured.

Fixes #5837.
2026-07-23 18:07:56 +02:00
Stefan HallerandClaude Fable 5 02c8ba3073 Clamp ConPTY sizes to the 1x1 minimum that Windows accepts
CreatePseudoConsole and ResizePseudoConsole reject zero dimensions with
E_INVALIDARG, but we legitimately request them: the pty is sized after
the main view, and that view is zero-sized while hidden, e.g. in
full-screen mode with a side panel focused. Entering that mode while a
custom pager is configured therefore made StartPty fail (degrading to
unpaged output now that the fallback works), and resizing a live pty
from onResize would fail layout. The Unix pty accepts zero sizes, so
the clamp lives in the Windows implementation only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:31:26 +02:00
Stefan HallerandClaude Fable 5 c217084c90 Add test showing StartPty fails on Windows when given a zero size
CreatePseudoConsole rejects zero dimensions with E_INVALIDARG, so
starting a pty sized after a hidden (and thus zero-sized) view fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:31:26 +02:00
Stefan HallerandClaude Fable 5 f000ce9f1c Never hand NewCmdTask a nil reader when a command fails to start
NewCmdTask feeds the reader returned by its start func into a
bufio.Scanner, and Scanner.Scan panics with a nil pointer dereference
when that reader is nil. Two start funcs could produce one:

- newPtyTask's fallback for a failed StartPty returned a literal nil
  reader, alongside an ExecCmd that was never started, so the intended
  "fall back to a plain cmd task" never worked. This crashed lazygit on
  Windows when using a custom pager with the main view zero-sized, e.g.
  after pressing + twice to enter full-screen mode with a side panel
  focused: ConPTY rejects zero dimensions, making StartPty fail.

- startCmdWithPipe returned nil when the pipe couldn't be created,
  which the Unix pty fallback path can trigger, since a failed pty
  start can leave the tty assigned to the command's stdout.

Make startCmdWithPipe never return a nil reader: when the pipe can't be
created, don't start the command at all and return an empty reader so
the task shuts down cleanly with the error in the log. Then route
newPtyTask's fallback through it, so a StartPty failure degrades to
running the command without a pty: the pager is lost, but the command's
output still renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:31:26 +02:00
Stefan HallerandClaude Fable 5 400faea60c Add test showing startCmdWithPipe returns a nil reader on pipe failure
NewCmdTask feeds the reader returned by its start func straight into a
bufio.Scanner, whose Scan panics on a nil reader with a nil pointer
dereference. startCmdWithPipe returns exactly that when the pipe cannot
be created.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:31:26 +02:00
Stefan HallerandClaude Fable 5 5209294a56 Extract helper for starting a command with piped output
The fallback path in newPtyTask (taken when StartPty fails) needs the
same start-the-command-with-a-pipe logic that newCmdTask uses, so pull
it out into a helper that both can share. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:31:26 +02:00
Stefan Haller 1dc7d4f589 Support a {{diffContext}} template variable in external diff command (#5841)
Useful for passing the context size to external diff commands like
difftastic, like so:

```yml
git:
  pagers:
    - externalDiffCommand: difft --color=always --context={{diffContext}}
```

See
https://github.com/jesseduffield/lazygit/issues/5527#issuecomment-5026444795.
2026-07-23 17:29:22 +02:00
Stefan Haller 87d9537de9 Support a {{diffContext}} template variable in external diff command
Useful for passing the context size to external diff commands like
difftastic.
2026-07-23 17:13:02 +02:00
Stefan Haller d8b07ee4f5 Add RefreshBlockingInput to buffer keypresses until a refresh has landed (#5844)
A refresh from the UI thread returns immediately and applies its model
and view updates as queued UI-thread callbacks. A key pressed before
those have run is handled against the stale, pre-refresh state. For most
keys that's harmless, but some handlers turn that state into git
commands: pressing space twice in quick succession in the staging panel
builds the second patch from the already-applied diff and fails with
'patch does not apply', because the refresh after the first press is
what moves the selection to the next stageable hunk.

Notably, this is not just a regression of the recent change that made
UI-thread refreshes non-blocking; the window was merely much narrower
before. A blocking refresh parked the UI thread while the scopes'
bounces were queued, and the event loop drains pending keyboard input
with priority over queued user events, so a key pressed during the
blocked window still beat the queued state updates. The guarantee that
the next keypress sees post-refresh state had already ended when the
scopes' state updates moved from worker-side mutex-guarded writes to
UI-thread bounces.

Fix it with the input-blocking mechanism we already use for commit
surgery, exposed as a new RefreshBlockingInput entry point: it begins
blocking events synchronously in the calling handler, and ends the block
from a callback that the finishing step queues behind the refresh's own
updates. Keys pressed while the refresh is in flight are buffered and
replayed, in order, against the fully refreshed state; since a replayed
key's handler re-enters this same path, a burst of keypresses applies
sequentially, each one seeing the previous one's refresh. Unlike the old
blocking refreshes, this doesn't freeze the UI thread: rendering,
spinners, resizing, and mouse scrolling keep working while input is
withheld.

Blocking input is opt-in per call site rather than the default for all
UI-thread refreshes, because most refreshes (the focus-in and startup
refreshes, say) don't produce state that the next keypress depends on,
and blocking on them would delay typing for no reason. It should also be
limited to quick, narrow-scoped refreshes: a full refresh, or any scope
that pulls in COMMITS, can take very long in large repos and should
usually not hold up input.

Use the new function for the staging panel's stage/discard/edit-hunk
refreshes, for moving rebase todos, and for stash operations.

Labelling as ignore-for-release because it fixes regressions introduced
after the last release.
2026-07-22 09:05:38 +02:00
Stefan Haller 975da9b8a9 Block input and batch UI updates when switching repos
On startup we don't want to block input during the initial refresh (it
should be possible to press, say, `4` to jump to the commits panel right
after startup without a delay), and we also want panels to show their
contents as soon as possible; it doesn't matter so much that it's not in
sync, we go from empty to populated here. However, when switching repos
it can be confusing that some panels that are slow to update still show
the old repo's data while others already show the new one's data, so
update the UI only when everything is ready, and also block input to
prevent accidentally trying to act on the old, stale data.
2026-07-22 08:31:11 +02:00
Stefan HallerandClaude Fable 5 fc975f32a8 Block input while the refresh after a stash operation is in flight
Popping or dropping a stash shifts the indices of the entries below it,
and renaming re-creates the stash at the top, shifting all the others.
The stash model is only rebuilt by the refresh, which finishes in the
background, so acting on the next entry in quick succession — pressing
the key, confirming the popup, and pressing again right away — reads the
stale pre-operation indices and targets the wrong stash. Note that the
confirmation popup is no protection here: the race starts when the
confirm handler runs, and the next keypress can easily beat the refresh.

Use RefreshBlockingInput so a quick follow-up keypress is buffered and
replayed once the refreshed stash list is in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 08:31:11 +02:00
Stefan HallerandClaude Fable 5 055184f997 Block input while the refresh after moving a rebase todo is in flight
Moving a todo rewrites the todo file and advances the selection
synchronously, but the commits model is only rebuilt by the refresh. A
second press arriving before that grabs the swapped-with todo from the
stale model at the advanced index and moves it back, so holding the key
to move a todo several slots misbehaved. Use RefreshBlockingInput so the
second press is buffered and replayed once the moved todo list is in
place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 08:31:11 +02:00
Stefan HallerandClaude Fable 5 200042a57c Add test showing that rapidly moving a rebase todo twice moves the wrong todo
Moving a todo up or down rewrites the todo file and advances the
selection synchronously, but the commits model is only rebuilt by the
refresh, which finishes in the background. A second keypress arriving
before that reads the pre-move model at the advanced selection index —
that's the todo the first move swapped with, so the second press moves
that one back instead of moving the selected todo further. Two rapid
presses (e.g. from holding the key down) thus amount to a net no-op.

The two presses also spawn two racing refreshes whose model updates can
land in either order, so the todo list can even end up disagreeing with
the todo file. That's why the test continues the rebase and asserts the
resulting commit order instead of the displayed list: the rebase replays
the file, which is deterministic.

The test documents this currently broken behavior; the fix comes next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 08:31:11 +02:00
Stefan Haller b96b8a9753 Add RefreshBlockingInput to buffer keypresses until a refresh has landed
A refresh from the UI thread returns immediately and applies its model
and view updates as queued UI-thread callbacks. A key pressed before
those have run is handled against the stale, pre-refresh state. For most
keys that's harmless, but some handlers turn that state into git
commands: pressing space twice in quick succession in the staging panel
builds the second patch from the already-applied diff and fails with
'patch does not apply', because the refresh after the first press is
what moves the selection to the next stageable hunk.

Notably, this is not just a regression of the recent change that made
UI-thread refreshes non-blocking; the window was merely much narrower
before. A blocking refresh parked the UI thread while the scopes'
bounces were queued, and the event loop drains pending keyboard input
with priority over queued user events, so a key pressed during the
blocked window still beat the queued state updates. The guarantee that
the next keypress sees post-refresh state had already ended when the
scopes' state updates moved from worker-side mutex-guarded writes to
UI-thread bounces.

Fix it with the input-blocking mechanism we already use for commit
surgery, exposed as a new RefreshBlockingInput entry point: it begins
blocking events synchronously in the calling handler, and ends the
block from a callback that the finishing step queues behind the
refresh's own updates. Keys pressed while the refresh is in flight are
buffered and replayed, in order, against the fully refreshed state;
since a replayed key's handler re-enters this same path, a burst of
keypresses applies sequentially, each one seeing the previous one's
refresh. Unlike the old blocking refreshes, this doesn't freeze the UI
thread: rendering, spinners, resizing, and mouse scrolling keep working
while input is withheld.

Blocking input is opt-in per call site rather than the default for all
UI-thread refreshes, because most refreshes (the focus-in and startup
refreshes, say) don't produce state that the next keypress depends on,
and blocking on them would delay typing for no reason. It should also be
limited to quick, narrow-scoped refreshes: a full refresh, or any scope
that pulls in COMMITS, can take very long in large repos and should
usually not hold up input.

The staging panel's stage/discard/edit-hunk refreshes use it now.
2026-07-22 08:31:11 +02:00
Stefan HallerandClaude Fable 5 963db76ab6 Add test showing that a rapid second keypress acts on a stale staging panel
Pressing space twice in quick succession in the staging panel is supposed
to stage two hunks: the refresh triggered by the first press rebuilds the
panel's diff and moves the selection to the next stageable hunk, and the
second press stages that.

Since we made UI-thread refreshes non-blocking, the second press is
handled as soon as it arrives, while that refresh is still in flight. It
then reads the stale pre-refresh diff, builds the first hunk's patch
again, and git apply fails with 'patch does not apply' because those
lines are already in the index.

The test documents this currently broken behavior; the fix comes next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:23:58 +02:00
Stefan HallerandClaude Fable 5 7a902b56cc Add a way for integration tests to press keys in rapid succession
The test driver waits for lazygit to become idle after every keypress, so
tests could never exercise what happens when a key arrives while the
previous key's processing is still in flight — for example while the
refresh triggered by the previous key hasn't updated the model yet. Real
users type faster than that all the time.

PressRapidly injects all its keys back to back and waits for idle only
once at the end, so the second and later keys are queued before the first
one's processing has finished. The next commit uses this to demonstrate a
bug in exactly that scenario.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:23:58 +02:00
Stefan HallerandClaude Fable 5 2e653ceeba Update comments that still describe the removed blocking refresh mode
A few comments still reasoned in terms of SYNC vs ASYNC refreshes, a
distinction that no longer exists: sync vs async is now derived from the
calling thread. Restate them in terms of the current mechanisms
(RefreshFromWorker blocking its worker, model updates being enqueued on
the UI thread) without changing any behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:23:58 +02:00
Stefan HallerandClaude Fable 5 00cef799ce Show the inline status again when checking out a newly created remote branch
Checking out a remote branch that has no local counterpart creates the
local branch, refreshes, and then checks it out. The refresh exists so
that CheckoutRef finds the new branch in the model and attaches an inline
status to the branch item instead of showing a global waiting status. But
since UI-thread refreshes stopped blocking, the checkout started before
the refreshed branches had landed in the model, so the lookup failed and
we always got the waiting status. Run the checkout from the refresh's
Then, which is queued behind the model update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:23:57 +02:00
Stefan Haller 4b3e5f123f Fix more problems related to concurrent repo switch and background refresh (#5839)
Make the background fetch and the refresh that runs after it more safe
against racing with a concurrent foreground repo switch (i.e. switching
worktrees, repos, or submodules). This fixes a bunch of different
problems; see the individual commit messages for details.
2026-07-21 18:40:28 +02:00
Stefan HallerandClaude Fable 5 5fc678dde6 Read the gui's per-repo pointers on the UI thread in background routines
gui.git, gui.helpers and gui.State are all replaced on a repo switch,
which runs on the UI thread. The background fetch and the external-
change poller read them from their own goroutines, racing the
reassignment. This race can't show up in the integration suite, which
doesn't enable the background routines, so no -race run will ever flag
it; it can only bite real users who switch repos while a background
fetch or poll is in flight.

Capture the objects a routine iteration needs in a single blocking
UI-thread hop before using them, the same pattern the refresh's input
capture uses. For the fetch this has two welcome side effects: the
fetch, the post-fetch refresh's generation baseline, and the recorded
fetch time now all refer to the same repo (the old comment documented
the timestamp's mismatch as a known, unguarded race), and the git
instance the fetch runs through is pinned to that repo's directory, so
a switch mid-fetch can no longer direct in-flight work at the new repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan HallerandClaude Fable 5 efed9d0407 Don't auto-forward branches when the repo was switched during the fetch
PostFetchRefresh's refresh is the only background refresh carrying a
Then callback, and Then callbacks are not generation-guarded: when the
background fetch's refresh crossed a repo switch, the callback still
ran — in the new repo — and auto-forwarded the new repo's branches
because the old repo's fetch had completed. That was harmless in
practice (the update-ref call compares against the expected old value,
and it only does what the next fetch's auto-forward would do anyway),
but mutating refs in a repo whose fetch never happened is not an action
the user took. Skip the auto-forward when the repo generation changed
since the fetch started.

The generation is captured by the fetch's callers before the fetch
runs, not by PostFetchRefresh itself: the background fetch doesn't
block repo switching and is a network call, so by the time
PostFetchRefresh runs a switch may already have happened — a capture
there (or the one the refresh itself takes) would compare against the
new repo's generation and let the auto-forward through. For the manual
fetch the capture point makes no difference, since a foreground
operation blocks repo switching for its entire duration.

This deliberately guards only this call site rather than making Then
callbacks generation-guarded in general: a Then is an arbitrary
callback, and whether it is safe to skip on a repo switch is a decision
for the author of the call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan HallerandClaude Fable 5 568a4276d7 Pin the cached git config's commands to the repo directory
The cached git config runs its `git config` reads through raw
exec.Command calls, outside the pinned git command builder, so they
followed the process working directory. A cache miss on a stale
instance — one still in use by a refresh that crossed a repo switch —
would therefore read the new repo's local config while computing data
for the old one. Give the cache a directory, set once by NewGitCommand
right after it determines the repo paths (the object is created fresh
for every repo switch, so no cross-repo cache invalidation is needed),
and run every config command there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan HallerandClaude Fable 5 e0b8dbf48c Resolve the refresh's file reads against its repo root
The refresh workers read a few files at paths relative to the process
working directory: the submodule config read of .gitmodules, the files
refresh's check for conflict markers, and the submodule stash's
existence check. Git commands are pinned to the repo their instance was
created for, but these Go file reads still followed the cwd, so a
background refresh crossing a repo switch would read the new repo's
files while computing data for the old one. Join them with the worktree
root of the instance they belong to. (Most git-state file reads —
working tree state, rebase todos, bisect info — already resolve
against RepoPaths and need no change.)

This also fixes the submodule stash's existence check for nested
submodules: it stat'ed submodule.Path, which is relative to the parent
module, against the repo root — now it uses the submodule's full path,
matching the stash command right below it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan HallerandClaude Fable 5 ae095f276b Don't refuse a repo switch during a pure refresh
The refreshes on focus-in, right after a repo switch, and after
returning from a subprocess are full foreground refreshes, so their
tasks kept Busy() true for as long as the slowest scope took — and any
switch attempt in that window was refused with the "can't switch"
toast. The focus-in one is particularly annoying: focusing lazygit is
often precisely what the user does in order to switch repos, and right
after regaining focus is when a refresh takes longest.

Blocking the switch bought nothing there. The refusal exists for user
operations, whose follow-up work (e.g. a Then callback reading the
model) isn't covered by the switch-safety guards; but these refreshes
merely reload state, and a refresh by itself is now switch-safe: its
git commands run against the repo it was started for, and the
generation guard drops its updates when the repo changed.

We can't just mark them Background, because that flag also decides
whether the files refresh lets git take optional locks to persist its
refreshed stat cache — worth doing for an attended refresh, and the
focus-in refresh (typically running right after external changes) is
the case that profits most. So split the two meanings: a new
DontBlockRepoSwitch option dispatches the refresh's tasks as background
tasks (excluded from Busy()) while keeping the attended optional-locks
behavior. Combining it with Then panics, since Then is not
generation-guarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan HallerandClaude Fable 5 8e045653be Don't pop up errors from a refresh worker once the repo was switched
An error returned from a gocui worker is shown to the user in an error
popup. For the branch loader's behind-counts worker that used to be the
"no such ref" popup when a background refresh crossed a repo switch:
the old repo's main branch didn't exist in the new repo. The previous
commits fix that scenario properly — the command now runs against the
repo the refresh was started for — but a stale worker can still fail
legitimately, most plausibly because that repo was deleted after
switching away from it (e.g. removing a worktree). Its results are
dropped anyway, so log the error instead of alarming the user about a
repo they already left.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan HallerandClaude Fable 5 7c0fa9fe33 Run a refresh's git commands through the instance captured at its start
A background refresh's model writes are dropped by the generation guard
when the repo is switched mid-flight, but its git commands kept running
— and because the refresh read the live git instance at each step, any
command issued after the switch ran against the new repo. Now that git
commands are pinned to the directory of the instance they were built
from, capture the instance once when the refresh starts and run every
scope's git work through it, so a switch-crossing refresh keeps
addressing the repo it was started for.

The instance is captured together with the repo generation, on the UI
thread (where repo switches run), so the pair can't straddle a switch:
an old instance paired with the new generation would compute data from
the old repo and write it into the new repo's model unguarded.

This also removes the refresh workers' unsynchronized reads of the live
instance pointer, which raced its reassignment on the UI thread when a
background refresh crossed a repo switch (foreground refreshes can't
cross one: they keep Busy() true, which refuses the switch).

Two reads keyed app-state by the live instance's repo path on a worker
and now use the captured instance, fixing which repo they file under
when crossing a switch: the pull-request cache, and the "user dismissed
the base-remote prompt" flag. The base-remote menu's handlers keep
reading the live instance: a switch dismisses any open popup, so they
can't run against the wrong repo (and the OnPress body runs under a
foreground task, which blocks switching anyway).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan HallerandClaude Fable 5 527124d0e0 Pin git commands to the repo they were created for
Lazygit changes the process working directory when switching repos, but
work that is still in flight for the previous repo can keep spawning
git commands after the switch — most notably a background refresh. Its
model writes are already dropped by the repo generation guard, but its
git commands would now run against the new repo. That is wasted work at
best; at worst it surfaces spurious error popups (the behind-base-
branch computation failing with "no such ref" when the old repo's main
branch doesn't exist in the new one) and pollutes caches belonging to
the old repo's reusable state (e.g. MainBranches' existing-branches
cache), which the user sees when switching back.

Give the git command builder the directory of the repo it was created
for, and pin every command it produces to that directory. The pinned
directory and the process cwd are identical until a switch happens
(NewGitCommand chdirs to the worktree path right before creating the
builder), so nothing changes in the steady state; the pin only takes
effect for commands built through a previous repo's GitCommand instance
after a switch, which now keep addressing the repo they were built for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan HallerandClaude Fable 5 096a710761 Refresh pull requests through the regular refresh after picking a base remote
When the user picks a base remote in the "select remote repository"
prompt, we called setGithubPullRequests directly, bypassing the refresh
machinery — which meant hand-rolling the refresh env that call needs
(with a comment explaining why), and fetching against the branches
captured when the prompt was created. Issue a PULL_REQUESTS-scoped
refresh instead: it re-reads branches and remotes (both fast even in
large repos), fetches against those fresh values, and gets the refresh
machinery's guarantees without any special-casing. The config write is
re-read by the refresh from git config, so it is guaranteed to be
picked up.

The waiting status now covers the config write and the branches/remotes
reload, while the GitHub request itself continues as a background task
— which is how every other pull-request fetch behaves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan HallerandClaude Fable 5 132f656480 Run nested-submodule commands in the parent module's directory explicitly
Deleting a nested submodule (and updating its URL) chdir'd the whole
process into the parent module, ran its git commands there, and chdir'd
back. Only those commands need to run there, and a process-wide chdir
leaks the parent module's directory into any command another goroutine
spawns during that window (e.g. a background refresh's). Set the
directory on the commands themselves instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:07:39 +02:00
Stefan Haller 4b788d5eb0 Run tests with race detection on CI (#5792)
Now that #5791 has made integration tests race-free, let's add a CI job
that runs them with race detection turned on.
2026-07-20 14:43:11 +02:00
Stefan HallerandClaude Opus 4.8 b6b5436d57 Log per-test durations during integration tests
To spot slow or anomalous tests across CI runs, record each test's run
duration when LAZYGIT_TEST_TIMING is set (to a file path);
run_integration_tests.sh prints them at the end, sorted by slowest
first. CI sets it for all integration jobs.

The harness appends to a file rather than writing to stdout/stderr
because `go test` captures those and only surfaces them with -v, which
would drown the signal in every test's verbose logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:26:26 +02:00
Stefan HallerandClaude Opus 4.8 03a914c04c Dump goroutine stacks when the test watchdog fires
The watchdog only log.Fatal'd with a message, so a hung test told us
that it timed out but not where it was stuck -- useless for diagnosing
an intermittent deadlock under the race detector. Dump all goroutine
stacks to stderr first (the harness surfaces this process's stderr on
failure), turning a bare timeout into an actionable stack trace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:26:25 +02:00
Stefan Haller 840a66e733 Run the integration tests under the race detector on CI
Add one extra integration-tests job that runs the whole suite under the
race detector. A `race` matrix dimension (default false) plus an include
entry adds a single git-latest job with LAZYGIT_RACE_DETECTOR set; races
live in lazygit's own Go code rather than in git, so one git version is
enough, and using latest skips the git-build steps.

The race job skips coverage collection: it's redundant with the non-race
latest job and would only slow the -race build down further.
2026-07-20 14:26:25 +02:00
Stefan Haller 334daccfab Increase integration test timeout to 30 minutes
Go's default 10-minute timeout was enough for running integration tests
normally (both locally and on CI), but with race detection turned on
they can take much longer to run. Increase the timeout unconditionally
to 30 minutes; we don't bother making a distinction between race vs.
normal, because a longer timeout doesn't hurt (I can't recall having hit
the global timeout ever; and we still have the per-test watchdog that
kills an individual test after 40s).
2026-07-20 14:26:25 +02:00
Stefan HallerandClaude Opus 4.8 7fce58b09b Scale the integration test watchdog up under the race detector
The integration test watchdog fails a test if its recording takes longer
than 40 seconds. Under the race detector everything runs several times
slower, so legitimately slow tests (e.g. a conflicting interactive
rebase) blow that budget and fail even though nothing is actually stuck.

Key the timeout off a build-tag constant: the `race` tag is set
automatically when the binary is built with -race, so a race build gets
a 5x-longer budget while a normal build is unchanged, and the two can't
drift apart the way a runtime flag would. The base 40s stays in one
place; only the multiplier varies by build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:26:25 +02:00
Stefan HallerandClaude Opus 4.8 46c1fa7db2 Don't let a leaked subprocess wedge the headless test runner
When lazygit exits but leaves behind a subprocess that inherited its
stderr pipe and has detached from the pty, cmd.Wait() blocks in
awaitGoroutines waiting for that pipe to reach EOF -- which never
happens while the straggler is alive. With no WaitDelay set, that wait
is unbounded, so a single leaked process hangs the whole test binary
until the 10-minute global timeout fires and panics. Worse, the timeout
discards whatever lazygit wrote to stderr before exiting (a panic, a
-race report), which is exactly the output needed to diagnose the
failure.

This surfaces under -race, where lazygit runs slow enough to widen the
window for a spawned command to still be alive when lazygit quits, and
it's a blocker for enabling the race detector on CI.

Bound the wait with cmd.WaitDelay so Wait force-closes the pipe and
returns ErrWaitDelay instead of hanging, surface the captured stderr as
the error (falling back to the wait error when nothing was printed), and
kill the child's process group on failure so a straggler can't linger
into a later test or pile up across a run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:26:25 +02:00
Stefan Haller 4390e1fb08 Fix stall with ctrl+z and fg (#5830)
When pressing ctrl+z to suspend lazygit, and then `fg` to bring it to
the foreground again, sometimes it wouldn't come to the foreground,
stalling in the background with one core using 100% CPU.

Fixes #5309.
2026-07-20 14:25:47 +02:00
Stefan HallerandClaude Fable 5 94e5f570f6 Bump tcell to v3.4.1 to fix drawing on a suspended screen
The previous two commits stop gocui from flushing while suspended, but
that guard cannot be fully airtight from gocui's side: it is a
check-then-act on the suspended flag, so a flush racing the suspend
itself (e.g. from a spinner goroutine) could still reach the screen
just as it disengages, and tcell's disengageFinish mutates the cell
buffer without holding the screen lock. Upstream now closes this at
the source (gdamore/tcell#1139): draw() returns immediately on a
disengaged screen, and the draw scan loop can no longer stall on the
width-0 cells that a released cell buffer reports (#5309).

The delta over the previously pinned snapshot is these two fixes, a
CSI R input decode fix, a wasm packaging chore, and dependency bumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:23:09 +02:00
Stefan Haller 319f43e166 Schedule a redraw when resuming from suspension
Until now the repaint after fg was accidental: it only happened because
the suspend keybinding handler still had a flush pending on the UI
thread, and only if that flush happened to run after the SIGCONT
handler had re-engaged the screen. Now that flushes are skipped while
suspended, losing that race would leave the screen blank until the next
input event arrives, so schedule a redraw explicitly (#5309).
2026-07-20 14:23:09 +02:00
Stefan Haller d887a41ad2 Don't flush the screen while suspended
When suspending with ctrl+z, the suspend keybinding handler disengages
the screen and then sends SIGSTOP to the process group, so the UI
thread freezes at the return from kill(2) with the handler's follow-up
flush still pending. When fg continues the process, that pending flush
races the SIGCONT handler's Resume. If the flush wins, Show() draws
against the disengaged screen, whose cell buffer tcell has released to
0x0 while its width/height still hold the old size; drawCell() then
reports width 0 for the out-of-range cell, the draw loop's
'x += width - 1' never advances, and the UI thread spins forever while
holding the tcell screen lock. Resume in turn blocks forever on that
lock, so the screen never re-engages and no input is ever read again:
the hard stall of #5309, only recoverable by killing the process.

Guard both flush paths with the suspended flag. For the flag to
guarantee that the screen is engaged whenever it is false, Resume must
clear it only after re-engaging (it used to clear it before); Suspend
already sets it before disengaging. This also covers the pre-existing
unsynchronized suspended check in draw(), which is subsumed by the
guards and can go.

The regression test cannot use the demonstrate-then-fix pattern: on
unfixed code the flush goroutine spins holding the screen lock, which
deadlocks any subsequent screen call including the test cleanup's
Close().
2026-07-20 14:23:09 +02:00
Stefan Haller 3ed6ce8f67 Add test showing that resuming after a suspend schedules no redraw
When lazygit is suspended with ctrl+z and brought back with fg, nothing
deliberately triggers a redraw. The screen only repaints because the UI
thread happens to have a flush pending from the suspend keybinding, and
that flush races the SIGCONT handler's resume; when it loses in the
right way, the terminal shows a blank screen until the next input event
arrives (#5309).
2026-07-20 14:23:09 +02:00
Stefan Haller b371411567 Recommend the gopls MCP tools for symbol navigation in AGENTS.md
Grep-based navigation needs manual filtering for the many colliding
method names in this codebase, while gopls answers reference and
implementation questions type-aware and exactly. Scope the guidance to
the symbol tools and keep grep for textual searches: gopls' own MCP
instructions prescribe running vulncheck at session start and
go_file_context after every file read, which costs more than it helps
here. The server is registered per user and machine, so sessions
without it must just fall back to grep rather than try to set it up.
2026-07-20 14:23:09 +02:00
Stefan Haller e91dcb7056 Suppress output from a few git commands that pollute the command log (#5834) 2026-07-19 19:29:08 +02:00
Stefan Haller 197916aafb Suppress command logs for git calls related to the ctrl+f command 2026-07-19 19:22:48 +02:00
Stefan Haller 33a2dc302e Suppress command logs for tag commands
The command log is supposed to show only commands initiated by the user;
these are commands that we run to get information for rendering, so they
pollute the log and are confusing.
2026-07-19 19:19:47 +02:00
373 changed files with 25541 additions and 3738 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"additionalDirectories": [
"/Users/stk/Stk/Dev/Builds/delta",
"/Users/stk/Stk/Dev/Builds/difftastic"
]
}
}
+22 -3
View File
@@ -53,8 +53,16 @@ jobs:
- 2.38.2 # first version that supports the rebase.updateRefs config
- 2.44.0
- latest # We rely on github to have the latest version installed on their VMs
race:
- false
# Additionally run the whole suite once under the race detector. Data
# races live in lazygit's own Go code rather than in git, so a single
# git version is enough; use the latest to skip the git-build steps.
include:
- git-version: latest
race: true
runs-on: ubuntu-latest
name: "Integration Tests - git ${{matrix.git-version}}"
name: "Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }}"
env:
GOFLAGS: -mod=vendor
steps:
@@ -92,12 +100,23 @@ jobs:
run: git --version
- name: Test code
env:
# See https://go.dev/blog/integration-test-coverage
LAZYGIT_GOCOVERDIR: /tmp/code_coverage
# See https://go.dev/blog/integration-test-coverage. The race variant
# skips coverage: it's redundant with the non-race latest job and
# would only slow the -race build down further. Leaving the dir unset
# makes run_integration_tests.sh take its non-coverage path.
LAZYGIT_GOCOVERDIR: ${{ !matrix.race && '/tmp/code_coverage' || '' }}
# Only set for the race variant. The race detector needs cgo; it's on
# by default on the Linux runner, but we set it explicitly to be safe.
LAZYGIT_RACE_DETECTOR: ${{ matrix.race && '1' || '' }}
CGO_ENABLED: ${{ matrix.race && '1' || '' }}
# Append each test's duration to this file; run_integration_tests.sh
# prints the slowest at the end, to spot slow/anomalous tests.
LAZYGIT_TEST_TIMING: /tmp/test_timings.txt
run: |
mkdir -p /tmp/code_coverage
./scripts/run_integration_tests.sh
- name: Upload code coverage artifacts
if: ${{ !matrix.race }}
uses: actions/upload-artifact@v7
with:
name: coverage-integration-${{ matrix.git-version }}-${{ github.run_id }}
+53 -1
View File
@@ -26,6 +26,24 @@ Windows box has only `just`).
(most useful with `--sandbox` or `--slow`).
- `just lint` — run golangci-lint.
## Prefer gopls MCP tools for Go symbol questions
When the gopls MCP tools are available in the session, prefer them over grep
for type-aware questions about Go code: who calls a function or method
(`go_symbol_references`), finding a symbol by fuzzy name (`go_search`), or
inspecting a package's API (`go_package_api`). Method names in this codebase
collide a lot (`draw`, `Show`, `Refresh` exist on several types), and grep
needs manual filtering that gopls doesn't. This includes code under
`vendor/`, which gopls resolves as part of the module build.
Grep remains the right tool for strings, comments, config keys, non-Go
files, and anything textual. Don't adopt the full workflow from
`gopls mcp -instructions` (vulncheck on session start, `go_file_context`
after every file read); that overhead isn't worth it here.
If the tools aren't available in a session, fall back to grep silently —
don't try to install, register, or start the server.
## When to commit
Do not leave completed work uncommitted. Once a logical unit of work is done
@@ -195,6 +213,16 @@ that changes the relevant test(s) or adds new ones to demonstrate the bug, then
fix the bug in a follow-up commit. This gives reviewers (and `git bisect`) a
clear before/after and proves the test actually exercises the broken code path.
This applies only to defects that existed before the entire branch or branch
stack. Never use the bug-demonstration pattern for a regression introduced by
an earlier commit in the current stack. Fix or rewrite the commit that
introduced the regression so that no commit in the final history contains it.
Put the regression test in a preparatory commit before the introducing commit,
so it guards that commit in the final history. If the test cannot pass before
the feature exists, restructure the implementation or test seam until it can;
if that would require a design tradeoff, stop and discuss it rather than adding
a later demonstration/fix pair.
Use the `EXPECTED` / `ACTUAL` pattern in the bug-demonstrating commit. The test
asserts the current (wrong) behavior so it passes on the broken code, with the
correct expectation preserved inline as a comment. The fix commit then swaps
@@ -237,7 +265,11 @@ If you find yourself reaching for a local variable so that both forms can be
expressed against the same receiver, the structure isn't right yet — go back
and fix it instead of papering over it with a binding.
Use this pattern only where it makes sense; don't apply it by default.
Use this pattern only where it makes sense; don't apply it by default. Only
ever use it for bugs, never for added features or behavior changes that aren't
bugfixes; it is useful to demonstrate how a bug existed before fixing it, but
it is never useful to demonstrate how a feature didn't exist before implementing
it.
## Unify duplicated logic before you change it
@@ -358,12 +390,32 @@ Avoid phrasings like:
- "cleaner than the previous approach"
- "we used to ... but ..."
- "after trying X, we found Y"
- "X rather than Y", where Y is what the code did before the change
The iteration story is sometimes worth preserving — but it belongs in the
commit message, which is the durable record of *why this change was made*. The
code comment should make sense to someone who has never seen any prior version
and is just trying to understand the file as it currently exists.
The tell is subtler than an explicit "we used to". A comment that justifies the
code against an alternative — "run it on a worker rather than blocking the UI",
"switch panels in `Then` rather than a moment earlier" — is history in disguise
whenever that alternative is what the code did before the change. It reads as
ordinary rationale, but the reader has no way to know the contrast is with a
version that no longer exists.
So the check to apply is: would you have written this comment if you were
writing the file from scratch, with no diff in mind? If not, the sentence
belongs in the commit message.
## Don't justify routine call sites
If the codebase calls a helper in twenty places without explanation, your
twenty-first call site doesn't need one either. A comment there says "something
here is unusual"; when nothing is, it's noise — and it invites exactly the kind
of before/after justification the section above warns about. Look at the
neighboring call sites before writing one: if they're bare, match them.
## Don't present "live with the bug" as an option
When you're investigating a defect and laying out fix options for the user,
+1
View File
@@ -30,5 +30,6 @@ There are other forms of contributions to a project besides source code that are
- File feature requests for new functionality that you want to see in lazygit. I have a lot of ideas for future improvement myself, but I have also implemented a lot of feature ideas that weren't mine, and I'm grateful for those ideas. (Of course, there are also lots of feature requests that I don't implement, so don't be disappointed if I don't jump on yours.)
- Help make other people's bug reports reproducible. Sometimes people report bugs that they have only seen once, and in such a case it can be helpful to come up with reproducible scenarios.
- Help complete or improve the translation into other languages; join https://crowdin.com/project/lazygit for that.
- Run a master build! This is probably the most valuable way to help me. Test the latest master not just by occasionally trying it, but by actually using it for your daily work; report any issues that you find. This will help prevent having to release hotfix updates for regressions that are only noticed by users updating to a new release.
Importantly, if you file issues (whether bug reports or feature requests), stay around to answer questions and discuss your issue. There are few things that I find more annoying than spending time on responding to someone's issue (sometimes even making a PR that addresses it), and to then never hear from the OP again. So please set up your Github notifications so that you see when there's activity on your issue, and continue to participate.
+9 -3
View File
@@ -118,7 +118,7 @@ If you're a mere mortal like me and you're tired of hearing how powerful git is
- [Changing Directory On Exit](#changing-directory-on-exit)
- [Undo/Redo](#undoredo)
- [Configuration](#configuration)
- [Custom Pagers](#custom-pagers)
- [Custom Diff Renderers](#custom-diff-renderers)
- [Custom Commands](#custom-commands)
- [Git flow support](#git-flow-support)
- [Contributing](#contributing)
@@ -423,6 +423,7 @@ nix-shell -p lazygit
# or with flakes enabled
nix run nixpkgs#lazygit
```
Or you can add lazygit to your `configuration.nix` using the `environment.systemPackages` option.
More details can be found via NixOS search [page](https://search.nixos.org/).
@@ -431,6 +432,7 @@ More details can be found via NixOS search [page](https://search.nixos.org/).
This repository includes a nix flake that provides the latest development version and additional development tools:
**Run lazygit directly from the repository:**
```sh
nix run github:jesseduffield/lazygit
# or from a local clone
@@ -438,6 +440,7 @@ nix run .
```
**Build lazygit from source:**
```sh
nix build github:jesseduffield/lazygit
# or from a local clone
@@ -446,6 +449,7 @@ nix build .
**Development environment:**
For contributors, the flake provides a development shell with Go toolchain, development tools, and dependencies:
```sh
nix develop github:jesseduffield/lazygit
# or from a local clone
@@ -453,12 +457,14 @@ nix develop
```
The development shell includes:
- Go toolchain
- git and make
- Proper environment variables for development
**Using in other flakes:**
The flake also provides an overlay for easy integration into other flake-based projects:
```nix
{
inputs.lazygit.url = "github:jesseduffield/lazygit";
@@ -584,9 +590,9 @@ See the [docs](/docs/Undoing.md)
Check out the [configuration docs](docs/Config.md).
### Custom Pagers
### Custom Diff Renderers
See the [docs](docs/Custom_Pagers.md)
See the [docs](docs/Custom_DiffRenderers.md)
### Custom Commands
File diff suppressed because it is too large Load Diff
+37 -30
View File
@@ -66,8 +66,8 @@ gui:
# The number of spaces per tab; used for everything that's shown in the main
# view, but probably mostly relevant for diffs.
# Note that when using a pager, the pager has its own tab width setting, so you
# need to pass it separately in the pager command.
# Note that when using a diff renderer, the renderer has its own tab width
# setting, so you need to pass it separately in the renderer command.
tabWidth: 4
# If true, capture mouse events.
@@ -336,13 +336,13 @@ gui:
spinner:
# The frames of the spinner animation.
frames:
- '|'
- /
- '-'
- \
- ●∙∙
- ∙●∙
- ∙∙●
- ∙●∙
# The "speed" of the spinner in milliseconds.
rate: 50
rate: 180
# Status panel view.
# One of 'dashboard' (default) | 'allBranchesLog'
@@ -360,38 +360,45 @@ gui:
# Config relating to git
git:
# Array of pagers. Each entry has the following format:
# Array of diff renderers. Each entry has the following format:
#
# # A name for the pager, shown in the notification when cycling pagers.
# # If not set, the name is derived from the first word of the pager
# # command (or of the external diff command).
# # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'
# # | 'rawGit'
# type: "stdinFilter"
#
# # A name for the diff renderer, shown in the notification when cycling
# # renderers. If not set, the name is derived from the first word of the
# # renderer command.
# name: ""
#
# # Value of the --color arg in the git diff command. Some pagers want
# # this to be set to 'always' and some want it set to 'never'
# # If true, selected diff lines are highlighted only with a narrow bar
# # at the left edge of the view, rather than across the whole width.
# # Useful for pagers whose own line background colors should remain
# # visible.
# narrowSelectionHighlight: false
#
# # Value of the --color arg in the git diff command. Only used for type
# # 'stdinFilter'. Some renderers want this to be set to 'always' and some
# # want it set to 'never'.
# colorArg: "always"
#
# # The command to use for rendering diffs. This is either a stdinFilter or
# # an external diff command, depending on the type field; not applicable if
# # the type is 'rawGit'.
# # e.g.
# # diff-so-fancy
# # delta --dark --paging=never
# # ydiff -p cat -s --wrap --width={{columnWidth}}
# pager: ""
# # ydiff -p cat
# # difft --color=always
# command: ""
#
# # e.g. 'difft --color=always'
# externalDiffCommand: ""
# # Extra arguments (array of strings) passed to the git command. Only
# # applicable if the type is 'rawGit'.
# args: []
#
# # If true, Lazygit will use git's `diff.external` config for paging.
# # The advantage over `externalDiffCommand` is that this can be
# # configured per file type in .gitattributes; see
# # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
# useExternalDiffGitConfig: false
#
# 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually
# exclusive; set at most one per entry.
#
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md
# for more information.
pagers: []
diffRenderers: []
# Config relating to committing
commit:
@@ -714,8 +721,8 @@ keybinding:
prevTab: '['
nextScreenMode: +
prevScreenMode: _
cyclePagers: '|'
cyclePagersReverse: \
cycleDiffRenderers: '|'
cycleDiffRenderersReverse: \
undo: z
redo: Z
filteringMenu: <ctrl+s>
+84
View File
@@ -0,0 +1,84 @@
# Custom Diff Renderers
Custom diff renderers are useful for showing a better rendering of a diff than git's builtin raw diff, and using one is strongly recommended (I personally prefer delta myself, but that's a matter of personal preference). There are three types of diff renderers that lazygit supports:
- **stdin filters**, e.g. [delta](#delta) and [diff-so-fancy](#diff-so-fancy). They take git's raw output as stdin and produce something nicer on stdout, and they are hooked up using git's GIT_PAGER mechanism. (These used to be called "custom pagers" in earlier lazygit versions.)
- **external diff programs**, e.g. difftastic; these are called using git's `--ext-diff` flag, and they take over diff generation from git completely rather than post-processing git's output.
- **git's raw output using custom arguments**; mainly useful for `--color-words` (or `--word-diff` if you are color blind).
Diff renderers are configured with the `diffRenderers` array in the `git` section of lazygit's config file; it is an array because you can have multiple entries that you can cycle through with the `|` key. This can be useful if you usually prefer a particular diff renderer, but want to use a different one for certain kinds of diffs.
Fields that are shared by all renderer types:
- **type** The type of diff renderer; choices are `stdinFilter`, `extDiff`, or `rawGit`. `stdinFilter` is the default, because it's the most common one; so you can omit this if you use delta.
- **name** A name that is shown in the status bar toast when cycling renderers; defaults to the first word of the renderer command, but can be useful e.g. to distinguish "delta" from "delta side-by-side" if you have entries for both.
Fields only for `stdinFilter`:
- **command** The command line to use for `GIT_PAGER`.
- **colorArg** whether you want the `--color=always` arg in your `git diff` command. Some diff renderers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most renderers need.
Fields only for `extDiff`:
- **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool.
Fields only for `rawGit`:
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
Here's an example for a multi-renderer setup:
```yaml
git:
diffRenderers:
- command: delta --dark --paging=never
- command: ydiff -p cat
colorArg: never
- type: extDiff
command: difft --color=always --context={{diffContext}}
- type: rawGit
args: --color-words
name: color-words
- type: rawGit # git's default diff
name: default
```
## Delta:
```yaml
git:
diffRenderers:
- command: delta --dark --paging=never
```
![](https://i.imgur.com/QJpQkF3.png)
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
## Diff-so-fancy
```yaml
git:
diffRenderers:
- command: diff-so-fancy
```
![](https://i.imgur.com/rjH1TpT.png)
## ydiff
```yaml
gui:
sidePanelWidth: 0.2 # gives you more space to show things side-by-side
git:
diffRenderers:
- colorArg: never
command: ydiff -p cat
```
![](https://i.imgur.com/vaa8z0H.png)
-108
View File
@@ -1,108 +0,0 @@
# Custom Pagers
Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel).
Multiple pagers are supported; you can cycle through them with the `|` key. This can be useful if you usually prefer a particular pager, but want to use a different one for certain kinds of diffs.
Pagers are configured with the `pagers` array in the git section; here's an example for a multi-pager setup (use an empty object `{}` for the default builtin diff display that doesn't use a pager):
```yaml
git:
pagers:
- pager: delta --dark --paging=never
- pager: ydiff -p cat -s --wrap --width={{columnWidth}}
colorArg: never
- externalDiffCommand: difft --color=always
- {} # default, no pager used
```
The `colorArg` key is for whether you want the `--color=always` arg in your `git diff` command. Some pagers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most pagers need.
## Delta:
```yaml
git:
pagers:
- pager: delta --dark --paging=never
```
![](https://i.imgur.com/QJpQkF3.png)
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `pager:` config to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
## Diff-so-fancy
```yaml
git:
pagers:
- pager: diff-so-fancy
```
![](https://i.imgur.com/rjH1TpT.png)
## ydiff
```yaml
gui:
sidePanelWidth: 0.2 # gives you more space to show things side-by-side
git:
pagers:
- colorArg: never
pager: ydiff -p cat -s --wrap --width={{columnWidth}}
```
![](https://i.imgur.com/vaa8z0H.png)
Be careful with this one, I think the homebrew and pip versions are behind master. I needed to directly download the ydiff script to get the no-pager functionality working.
## Using external diff commands
Some diff tools can't work as a simple pager like the ones above do, because they need access to the entire diff, so just post-processing git's diff is not enough for them. The most notable example is probably [difftastic](https://difftastic.wilfred.me.uk).
These can be used in lazygit by using the `externalDiffCommand` config; in the case of difftastic, that could be
```yaml
git:
pagers:
- externalDiffCommand: difft --color=always
```
The `colorArg` option is not used in this case.
You can add whatever extra arguments you prefer for your difftool; for instance
```yaml
git:
pagers:
- externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off
```
This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`:
```sh
#!/bin/sh
git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5"
```
And then use it in your git config like so:
```yaml
git:
pagers:
- externalDiffCommand: ~/bin/color-words.sh
```
Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using
```yaml
git:
pagers:
- useExternalDiffGitConfig: true
```
This can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
`pager`, `externalDiffCommand`, and `useExternalDiffGitConfig` are alternative ways of producing the diff, so a pager entry may use at most one of them.
+1 -1
View File
@@ -2,7 +2,7 @@
* [Configuration](./Config.md).
* [Custom Commands](./Custom_Command_Keybindings.md)
* [Custom Pagers](./Custom_Pagers.md)
* [Custom Diff Renderers](./Custom_DiffRenderers.md)
* [Dev docs](./dev)
* [Keybindings](./keybindings)
* [Undo/Redo](./Undoing.md)
+20 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | Refresh | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | Next screen mode (normal/half/fullscreen) | |
| `` _ `` | Prev screen mode | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Cancel | |
| `` ? `` | Open keybindings menu | |
| `` <ctrl+s> `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -224,6 +224,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Search the current view by text | |
## Main panel (patch building)
@@ -329,6 +338,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Search the current view by text | |
## Stash
+20 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 更新 | Gitの状態を更新します(`git status`、`git branch`などをバックグラウンドで実行してパネルの内容を更新します)。これは`git fetch`を実行しません。 |
| `` + `` | 次の画面モード(通常/半分/全画面) | |
| `` _ `` | 前の画面モード | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | キャンセル | |
| `` ? `` | キーバインディングメニューを開く | |
| `` <ctrl+s> `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 |
@@ -193,6 +193,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 現在のビューをテキストで検索 | |
## タグ
@@ -306,6 +315,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 現在のビューをテキストで検索 | |
## メニュー
+20 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 새로고침 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | 다음 스크린 모드 (normal/half/fullscreen) | |
| `` _ `` | 이전 스크린 모드 | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | 취소 | |
| `` ? `` | 매뉴 열기 | |
| `` <ctrl+s> `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -85,6 +85,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 검색 시작 | |
## Stash
@@ -163,6 +172,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Patch Building)
+38 -20
View File
@@ -9,31 +9,31 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+r> `` | Wissel naar een recente repo | |
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Scroll naar beneden vanaf hoofdpaneel | |
| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` @ `` | Commandolog opties weergeven | Bekijk commandolog opties, bijv. commandolog tonen/verbergen en focus. |
| `` P `` | Push | Push de huidige branch naar de bijbehorende upstream-branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. |
| `` p `` | Pull | Pull wijzigingen van de remote voor de huidige branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. |
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
| `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
| `` : `` | Voer shellcommando uit | Bring up a prompt where you can enter a shell command to execute. |
| `` <ctrl+p> `` | Bekijk aangepaste patch opties | |
| `` m `` | Bekijk merge/rebase opties | Toon abort/continue/skip opties voor huidige merge/rebase. |
| `` R `` | Verversen | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | Volgende scherm modus (normaal/half/groot) | |
| `` _ `` | Vorige scherm modus | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Annuleren | |
| `` ? `` | Open menu | |
| `` <ctrl+s> `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. |
| `` W, <ctrl+e> `` | Open diff menu | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. |
| `` q, <ctrl+c> `` | Afsluiten | |
| `` <ctrl+z> `` | Pauzeer de applicatie | |
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <ctrl+w> `` | Witruimte weergeven in-/uitschakelen | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | Verander config bestand | Open bestand in externe editor. |
| `` z `` | Ongedaan maken (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
| `` Z `` | Redo (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
| `` Z `` | Redo (via reflog) (experimenteel) | Het reflog wordt gebruikt om te bepalen welk git commando moet worden gebruikt om het laatste git commando te herhalen. Wijzigingen aan de working tree worden niet meegenomen, alleen command's zijn kandidaten. |
## Lijstpaneel navigatie
@@ -161,25 +161,25 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Squash | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. |
| `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
| `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. |
| `` r `` | Hernoem commit | Reword the selected commit's message. |
| `` r `` | Hernoem commit | Herschrijf de commit message van de geselecteerde commit. |
| `` R `` | Hernoem commit met editor | |
| `` d `` | Verwijder commit | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. |
| `` e `` | Edit (start interactive rebase) | Wijzig commit |
| `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
| `` e `` | Bewerken (start interactieve rebase) | Wijzig commit |
| `` i `` | Start interactieve rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
| `` p `` | Pick | Kies commit (wanneer midden in rebase) |
| `` F `` | Creëer fixup commit | Creëer fixup commit |
| `` S `` | Apply fixup commits | Squash bovenstaande commits |
| `` <ctrl+j>, <alt+down> `` | Verplaats commit 1 naar beneden | |
| `` <ctrl+k>, <alt+up> `` | Verplaats commit 1 naar boven | |
| `` V `` | Plak commits (cherry-pick) | |
| `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. |
| `` B `` | Markeer als basiscommit voor rebase | Selecteer een basiscommit voor de volgende rebase. Als je rebased op een branch worden alleen commits boven de basiscommit meegenomen. Hiervoor wordt het `git rebase --onto` commando gebruikt. |
| `` A `` | Amend | Wijzig commit met staged veranderingen |
| `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. |
| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <ctrl+l> `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` t `` | Revert | Maak een revert commit voor de geselecteerde commit, die de wijzigingen in deze commit terugdraait. |
| `` T `` | Tag commit | Maak een nieuwe tag die naar de geselecteerde commit wijst. Je wordt gevraagd om een tag naam en optionele omschrijving. |
| `` <ctrl+l> `` | Log opties weergeven | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | Open pull request in browser | |
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Open commit in browser | |
| `` n `` | Creëer nieuwe branch van commit | |
@@ -232,6 +232,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle drag selecteer | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Start met zoeken | |
## Patch bouwen
@@ -241,7 +250,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
| `` e `` | Verander bestand | Open bestand in externe editor. |
@@ -255,7 +264,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Open commit in browser | |
| `` n `` | Creëer nieuwe branch van commit | |
@@ -307,6 +316,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle drag selecteer | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Start met zoeken | |
## Staging
@@ -316,7 +334,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
@@ -361,7 +379,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Open commit in browser | |
| `` n `` | Creëer nieuwe branch van commit | |
@@ -396,10 +414,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <ctrl+o> `` | Copy tag to clipboard | |
| `` <space> `` | Uitchecken | Geselecteerde tag uitchecken als detached HEAD. |
| `` n `` | Creëer tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
| `` n `` | Creëer tag | Maak een nieuwe tag die naar de huidige commit wijst. Je wordt gevraagd om een tag naam en optionele beschrijving. |
| `` w `` | New worktree | |
| `` d `` | Verwijderen | View delete options for local/remote tag. |
| `` P `` | Push tag | Push the selected tag to a remote. You'll be prompted to select a remote. |
| `` P `` | Tag pushen | Push de geselecteerde tag naar een remote. Je krijgt de optie een remote te selecteren. |
| `` g `` | Resetten | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | Open externe diff applicatie (git difftool) | |
| `` 0 `` | Focus main view | |
+20 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | Odśwież | Odśwież stan git (tj. uruchom `git status`, `git branch`, itp. w tle, aby zaktualizować zawartość paneli). To nie uruchamia `git fetch`. |
| `` + `` | Następny tryb ekranu (normalny/półpełny/pełnoekranowy) | |
| `` _ `` | Poprzedni tryb ekranu | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Anuluj | |
| `` ? `` | Otwórz menu przypisań klawiszy | |
| `` <ctrl+s> `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. |
@@ -100,6 +100,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Drzewa pracy
@@ -202,6 +211,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Panel główny (scalanie)
+20 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | Atualizar | Atualize o estado do git (ou seja, execute `git status`, `git branch`, etc em segundo plano para atualizar o conteúdo de painéis). Isso não executa `git fetch`. |
| `` + `` | Modo de tela seguinte (normal/metade/tela cheia) | |
| `` _ `` | Modo de tela anterior | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Cancelar | |
| `` ? `` | Abrir o menu de atalhos do teclado | |
| `` <ctrl+s> `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -236,6 +236,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Painel Principal (preparação)
@@ -338,6 +347,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Stash
+20 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | Обновить | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | Следующий режим экрана (нормальный/полуэкранный/полноэкранный) | |
| `` _ `` | Предыдущий режим экрана | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Отменить | |
| `` ? `` | Открыть меню | |
| `` <ctrl+s> `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -75,6 +75,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Найти | |
## Главная панель (Индексирование)
@@ -107,6 +116,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | Найти | |
## Главная панель (Слияние)
+20 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 刷新 | 刷新Git状态(即在后台运行`git status`、`git branch`等命令以更新面板内容)。此操作不会执行`git fetch`。 |
| `` + `` | 下一屏模式(正常/半屏/全屏) | |
| `` _ `` | 上一屏模式 | |
| `` \| `` | 切换分页器 | 从已配置的分页器列表中选择下一个分页器 |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | 取消 | |
| `` ? `` | 打开菜单 | |
| `` <ctrl+s> `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 |
@@ -287,6 +287,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 开始搜索 | |
## 正在合并
@@ -335,6 +344,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 开始搜索 | |
## 状态
+20 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
| `` _ `` | 上一個螢幕模式 | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | 取消 | |
| `` ? `` | 開啟選單 | |
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -83,6 +83,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 搜尋 | |
## 主面板(合併)
@@ -364,6 +373,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` / `` | 搜尋 | |
## 狀態
+31 -30
View File
@@ -66,8 +66,8 @@ gui:
# The number of spaces per tab; used for everything that's shown in the main
# view, but probably mostly relevant for diffs.
# Note that when using a pager, the pager has its own tab width setting, so you
# need to pass it separately in the pager command.
# Note that when using a diff renderer, the renderer has its own tab width
# setting, so you need to pass it separately in the renderer command.
tabWidth: 4
# If true, capture mouse events.
@@ -336,13 +336,13 @@ gui:
spinner:
# The frames of the spinner animation.
frames:
- '|'
- /
- '-'
- \
- ●∙∙
- ∙●∙
- ∙∙●
- ∙●∙
# The "speed" of the spinner in milliseconds.
rate: 50
rate: 180
# Status panel view.
# One of 'dashboard' (default) | 'allBranchesLog'
@@ -360,38 +360,39 @@ gui:
# Config relating to git
git:
# Array of pagers. Each entry has the following format:
# Array of diff renderers. Each entry has the following format:
#
# # A name for the pager, shown in the notification when cycling pagers.
# # If not set, the name is derived from the first word of the pager
# # command (or of the external diff command).
# # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'
# # | 'rawGit'
# type: "stdinFilter"
#
# # A name for the diff renderer, shown in the notification when cycling
# # renderers. If not set, the name is derived from the first word of the
# # renderer command.
# name: ""
#
# # Value of the --color arg in the git diff command. Some pagers want
# # this to be set to 'always' and some want it set to 'never'
# # Value of the --color arg in the git diff command. Only used for type
# # 'stdinFilter'. Some renderers want this to be set to 'always' and some
# # want it set to 'never'.
# colorArg: "always"
#
# # The command to use for rendering diffs. This is either a stdinFilter or
# # an external diff command, depending on the type field; not applicable if
# # the type is 'rawGit'.
# # e.g.
# # diff-so-fancy
# # delta --dark --paging=never
# # ydiff -p cat -s --wrap --width={{columnWidth}}
# pager: ""
# # ydiff -p cat
# # difft --color=always
# command: ""
#
# # e.g. 'difft --color=always'
# externalDiffCommand: ""
# # Extra arguments (array of strings) passed to the git command. Only
# # applicable if the type is 'rawGit'.
# args: []
#
# # If true, Lazygit will use git's `diff.external` config for paging.
# # The advantage over `externalDiffCommand` is that this can be
# # configured per file type in .gitattributes; see
# # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
# useExternalDiffGitConfig: false
#
# 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually
# exclusive; set at most one per entry.
#
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md
# for more information.
pagers: []
diffRenderers: []
# Config relating to committing
commit:
@@ -714,8 +715,8 @@ keybinding:
prevTab: '['
nextScreenMode: +
prevScreenMode: _
cyclePagers: '|'
cyclePagersReverse: \
cycleDiffRenderers: '|'
cycleDiffRenderersReverse: \
undo: z
redo: Z
filteringMenu: <ctrl+s>
+84
View File
@@ -0,0 +1,84 @@
# Custom Diff Renderers
Custom diff renderers are useful for showing a better rendering of a diff than git's builtin raw diff, and using one is strongly recommended (I personally prefer delta myself, but that's a matter of personal preference). There are three types of diff renderers that lazygit supports:
- **stdin filters**, e.g. [delta](#delta) and [diff-so-fancy](#diff-so-fancy). They take git's raw output as stdin and produce something nicer on stdout, and they are hooked up using git's GIT_PAGER mechanism. (These used to be called "custom pagers" in earlier lazygit versions.)
- **external diff programs**, e.g. difftastic; these are called using git's `--ext-diff` flag, and they take over diff generation from git completely rather than post-processing git's output.
- **git's raw output using custom arguments**; mainly useful for `--color-words` (or `--word-diff` if you are color blind).
Diff renderers are configured with the `diffRenderers` array in the `git` section of lazygit's config file; it is an array because you can have multiple entries that you can cycle through with the `|` key. This can be useful if you usually prefer a particular diff renderer, but want to use a different one for certain kinds of diffs.
Fields that are shared by all renderer types:
- **type** The type of diff renderer; choices are `stdinFilter`, `extDiff`, or `rawGit`. `stdinFilter` is the default, because it's the most common one; so you can omit this if you use delta.
- **name** A name that is shown in the status bar toast when cycling renderers; defaults to the first word of the renderer command, but can be useful e.g. to distinguish "delta" from "delta side-by-side" if you have entries for both.
Fields only for `stdinFilter`:
- **command** The command line to use for `GIT_PAGER`.
- **colorArg** whether you want the `--color=always` arg in your `git diff` command. Some diff renderers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most renderers need.
Fields only for `extDiff`:
- **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool.
Fields only for `rawGit`:
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
Here's an example for a multi-renderer setup:
```yaml
git:
diffRenderers:
- command: delta --dark --paging=never
- command: ydiff -p cat
colorArg: never
- type: extDiff
command: difft --color=always --context={{diffContext}}
- type: rawGit
args: --color-words
name: color-words
- type: rawGit # git's default diff
name: default
```
## Delta:
```yaml
git:
diffRenderers:
- command: delta --dark --paging=never
```
![](https://i.imgur.com/QJpQkF3.png)
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
## Diff-so-fancy
```yaml
git:
diffRenderers:
- command: diff-so-fancy
```
![](https://i.imgur.com/rjH1TpT.png)
## ydiff
```yaml
gui:
sidePanelWidth: 0.2 # gives you more space to show things side-by-side
git:
diffRenderers:
- colorArg: never
command: ydiff -p cat
```
![](https://i.imgur.com/vaa8z0H.png)
-108
View File
@@ -1,108 +0,0 @@
# Custom Pagers
Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel).
Multiple pagers are supported; you can cycle through them with the `|` key. This can be useful if you usually prefer a particular pager, but want to use a different one for certain kinds of diffs.
Pagers are configured with the `pagers` array in the git section; here's an example for a multi-pager setup (use an empty object `{}` for the default builtin diff display that doesn't use a pager):
```yaml
git:
pagers:
- pager: delta --dark --paging=never
- pager: ydiff -p cat -s --wrap --width={{columnWidth}}
colorArg: never
- externalDiffCommand: difft --color=always
- {} # default, no pager used
```
The `colorArg` key is for whether you want the `--color=always` arg in your `git diff` command. Some pagers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most pagers need.
## Delta:
```yaml
git:
pagers:
- pager: delta --dark --paging=never
```
![](https://i.imgur.com/QJpQkF3.png)
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `pager:` config to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
## Diff-so-fancy
```yaml
git:
pagers:
- pager: diff-so-fancy
```
![](https://i.imgur.com/rjH1TpT.png)
## ydiff
```yaml
gui:
sidePanelWidth: 0.2 # gives you more space to show things side-by-side
git:
pagers:
- colorArg: never
pager: ydiff -p cat -s --wrap --width={{columnWidth}}
```
![](https://i.imgur.com/vaa8z0H.png)
Be careful with this one, I think the homebrew and pip versions are behind master. I needed to directly download the ydiff script to get the no-pager functionality working.
## Using external diff commands
Some diff tools can't work as a simple pager like the ones above do, because they need access to the entire diff, so just post-processing git's diff is not enough for them. The most notable example is probably [difftastic](https://difftastic.wilfred.me.uk).
These can be used in lazygit by using the `externalDiffCommand` config; in the case of difftastic, that could be
```yaml
git:
pagers:
- externalDiffCommand: difft --color=always
```
The `colorArg` option is not used in this case.
You can add whatever extra arguments you prefer for your difftool; for instance
```yaml
git:
pagers:
- externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off
```
This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`:
```sh
#!/bin/sh
git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5"
```
And then use it in your git config like so:
```yaml
git:
pagers:
- externalDiffCommand: ~/bin/color-words.sh
```
Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using
```yaml
git:
pagers:
- useExternalDiffGitConfig: true
```
This can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
`pager`, `externalDiffCommand`, and `useExternalDiffGitConfig` are alternative ways of producing the diff, so a pager entry may use at most one of them.
+1 -1
View File
@@ -2,7 +2,7 @@
* [Configuration](./Config.md).
* [Custom Commands](./Custom_Command_Keybindings.md)
* [Custom Pagers](./Custom_Pagers.md)
* [Custom Diff Renderers](./Custom_DiffRenderers.md)
* [Dev docs](./dev)
* [Keybindings](./keybindings)
* [Undo/Redo](./Undoing.md)
+2 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | Refresh | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | Next screen mode (normal/half/fullscreen) | |
| `` _ `` | Prev screen mode | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Cancel | |
| `` ? `` | Open keybindings menu | |
| `` <ctrl+s> `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. |
+2 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 更新 | Gitの状態を更新します(`git status`、`git branch`などをバックグラウンドで実行してパネルの内容を更新します)。これは`git fetch`を実行しません。 |
| `` + `` | 次の画面モード(通常/半分/全画面) | |
| `` _ `` | 前の画面モード | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | キャンセル | |
| `` ? `` | キーバインディングメニューを開く | |
| `` <ctrl+s> `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 |
+2 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 새로고침 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | 다음 스크린 모드 (normal/half/fullscreen) | |
| `` _ `` | 이전 스크린 모드 | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | 취소 | |
| `` ? `` | 매뉴 열기 | |
| `` <ctrl+s> `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. |
+20 -20
View File
@@ -9,31 +9,31 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+r> `` | Wissel naar een recente repo | |
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Scroll naar beneden vanaf hoofdpaneel | |
| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` @ `` | Commandolog opties weergeven | Bekijk commandolog opties, bijv. commandolog tonen/verbergen en focus. |
| `` P `` | Push | Push de huidige branch naar de bijbehorende upstream-branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. |
| `` p `` | Pull | Pull wijzigingen van de remote voor de huidige branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. |
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
| `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
| `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
| `` : `` | Voer shellcommando uit | Bring up a prompt where you can enter a shell command to execute. |
| `` <ctrl+p> `` | Bekijk aangepaste patch opties | |
| `` m `` | Bekijk merge/rebase opties | Toon abort/continue/skip opties voor huidige merge/rebase. |
| `` R `` | Verversen | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | Volgende scherm modus (normaal/half/groot) | |
| `` _ `` | Vorige scherm modus | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Annuleren | |
| `` ? `` | Open menu | |
| `` <ctrl+s> `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. |
| `` W, <ctrl+e> `` | Open diff menu | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. |
| `` q, <ctrl+c> `` | Afsluiten | |
| `` <ctrl+z> `` | Pauzeer de applicatie | |
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <ctrl+w> `` | Witruimte weergeven in-/uitschakelen | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
| `` <alt+shift+c> `` | Verander config bestand | Open bestand in externe editor. |
| `` z `` | Ongedaan maken (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
| `` Z `` | Redo (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
| `` Z `` | Redo (via reflog) (experimenteel) | Het reflog wordt gebruikt om te bepalen welk git commando moet worden gebruikt om het laatste git commando te herhalen. Wijzigingen aan de working tree worden niet meegenomen, alleen command's zijn kandidaten. |
## Lijstpaneel navigatie
@@ -161,25 +161,25 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Squash | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. |
| `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
| `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. |
| `` r `` | Hernoem commit | Reword the selected commit's message. |
| `` r `` | Hernoem commit | Herschrijf de commit message van de geselecteerde commit. |
| `` R `` | Hernoem commit met editor | |
| `` d `` | Verwijder commit | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. |
| `` e `` | Edit (start interactive rebase) | Wijzig commit |
| `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
| `` e `` | Bewerken (start interactieve rebase) | Wijzig commit |
| `` i `` | Start interactieve rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
| `` p `` | Pick | Kies commit (wanneer midden in rebase) |
| `` F `` | Creëer fixup commit | Creëer fixup commit |
| `` S `` | Apply fixup commits | Squash bovenstaande commits |
| `` <ctrl+j>, <alt+down> `` | Verplaats commit 1 naar beneden | |
| `` <ctrl+k>, <alt+up> `` | Verplaats commit 1 naar boven | |
| `` V `` | Plak commits (cherry-pick) | |
| `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. |
| `` B `` | Markeer als basiscommit voor rebase | Selecteer een basiscommit voor de volgende rebase. Als je rebased op een branch worden alleen commits boven de basiscommit meegenomen. Hiervoor wordt het `git rebase --onto` commando gebruikt. |
| `` A `` | Amend | Wijzig commit met staged veranderingen |
| `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. |
| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <ctrl+l> `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` t `` | Revert | Maak een revert commit voor de geselecteerde commit, die de wijzigingen in deze commit terugdraait. |
| `` T `` | Tag commit | Maak een nieuwe tag die naar de geselecteerde commit wijst. Je wordt gevraagd om een tag naam en optionele omschrijving. |
| `` <ctrl+l> `` | Log opties weergeven | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
| `` G `` | Open pull request in browser | |
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Open commit in browser | |
| `` n `` | Creëer nieuwe branch van commit | |
@@ -241,7 +241,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
| `` e `` | Verander bestand | Open bestand in externe editor. |
@@ -255,7 +255,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Open commit in browser | |
| `` n `` | Creëer nieuwe branch van commit | |
@@ -316,7 +316,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
@@ -361,7 +361,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | Open commit in browser | |
| `` n `` | Creëer nieuwe branch van commit | |
@@ -396,10 +396,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <ctrl+o> `` | Copy tag to clipboard | |
| `` <space> `` | Uitchecken | Geselecteerde tag uitchecken als detached HEAD. |
| `` n `` | Creëer tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
| `` n `` | Creëer tag | Maak een nieuwe tag die naar de huidige commit wijst. Je wordt gevraagd om een tag naam en optionele beschrijving. |
| `` w `` | New worktree | |
| `` d `` | Verwijderen | View delete options for local/remote tag. |
| `` P `` | Push tag | Push the selected tag to a remote. You'll be prompted to select a remote. |
| `` P `` | Tag pushen | Push de geselecteerde tag naar een remote. Je krijgt de optie een remote te selecteren. |
| `` g `` | Resetten | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | Open externe diff applicatie (git difftool) | |
| `` 0 `` | Focus main view | |
+2 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | Odśwież | Odśwież stan git (tj. uruchom `git status`, `git branch`, itp. w tle, aby zaktualizować zawartość paneli). To nie uruchamia `git fetch`. |
| `` + `` | Następny tryb ekranu (normalny/półpełny/pełnoekranowy) | |
| `` _ `` | Poprzedni tryb ekranu | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Anuluj | |
| `` ? `` | Otwórz menu przypisań klawiszy | |
| `` <ctrl+s> `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. |
+2 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | Atualizar | Atualize o estado do git (ou seja, execute `git status`, `git branch`, etc em segundo plano para atualizar o conteúdo de painéis). Isso não executa `git fetch`. |
| `` + `` | Modo de tela seguinte (normal/metade/tela cheia) | |
| `` _ `` | Modo de tela anterior | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Cancelar | |
| `` ? `` | Abrir o menu de atalhos do teclado | |
| `` <ctrl+s> `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. |
+2 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | Обновить | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | Следующий режим экрана (нормальный/полуэкранный/полноэкранный) | |
| `` _ `` | Предыдущий режим экрана | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | Отменить | |
| `` ? `` | Открыть меню | |
| `` <ctrl+s> `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. |
+2 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 刷新 | 刷新Git状态(即在后台运行`git status`、`git branch`等命令以更新面板内容)。此操作不会执行`git fetch`。 |
| `` + `` | 下一屏模式(正常/半屏/全屏) | |
| `` _ `` | 上一屏模式 | |
| `` \| `` | 切换分页器 | 从已配置的分页器列表中选择下一个分页器 |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | 取消 | |
| `` ? `` | 打开菜单 | |
| `` <ctrl+s> `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 |
+2 -2
View File
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
| `` _ `` | 上一個螢幕模式 | |
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <esc> `` | 取消 | |
| `` ? `` | 開啟選單 | |
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
Generated
+16 -16
View File
@@ -7,7 +7,7 @@
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz?rev=ff81ac966bb2cae68946d5ed5fc4994f96d0ffec&revCount=69"
},
"original": {
"type": "tarball",
@@ -19,11 +19,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1759362264,
"narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=",
"lastModified": 1785627969,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "758cf7296bee11f1706a574c77d072b8a7baa881",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"type": "github"
},
"original": {
@@ -34,11 +34,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1759831965,
"narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=",
"lastModified": 1785828668,
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c9b6fb798541223bbb396d287d16f43520250518",
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
"type": "github"
},
"original": {
@@ -50,11 +50,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1754788789,
"narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=",
"lastModified": 1785031560,
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "a73b9c743612e4244d865a2fdee11865283c04e6",
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
"type": "github"
},
"original": {
@@ -65,11 +65,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1754340878,
"narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=",
"lastModified": 1770107345,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "cab778239e705082fe97bb4990e0d24c50924c04",
"rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4",
"type": "github"
},
"original": {
@@ -108,11 +108,11 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1758728421,
"narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
"lastModified": 1785360170,
"narHash": "sha256-XE1lKgQ3eIO3E7zWryqcRsax+mYXod/5RHBn4YaR9YE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
"rev": "d1187f8bc71fb8aab02395869ec3f5c1920f75c0",
"type": "github"
},
"original": {
+3 -2
View File
@@ -101,6 +101,7 @@
# Development tools
git
gnumake
just
];
# Environment variables for development
@@ -108,8 +109,8 @@
};
treefmt = {
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler;
programs.nixfmt.package = pkgs.nixfmt-rfc-style;
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt.compiler;
programs.nixfmt.package = pkgs.nixfmt;
programs.gofmt.enable = true;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -8
View File
@@ -13,7 +13,7 @@ require (
github.com/cli/go-gh/v2 v2.13.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.24
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59
github.com/gdamore/tcell/v3 v3.4.1
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.6.1
github.com/integrii/flaggy v1.8.0
@@ -38,8 +38,8 @@ require (
github.com/stretchr/testify v1.11.1
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -65,11 +65,10 @@ require (
github.com/onsi/gomega v1.34.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+16 -16
View File
@@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 h1:kUXexBZYoVdAJIOIuP6uLgK3k0G7ClDIRO27Z3epgtU=
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59/go.mod h1:Ev/2PFhL0QtVmu6XPZG9NEuITAZ6XH7i7/BF3wupBdw=
github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w=
github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
@@ -139,19 +139,19 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -163,26 +163,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+1 -1
View File
@@ -40,7 +40,7 @@ lint:
./scripts/gofumpt-check.sh
./scripts/golangci-lint-shim.sh run
e2e-test-command := "go test pkg/integration/clients/*.go"
e2e-test-command := "go test -timeout 30m pkg/integration/clients/*.go"
# Run integration tests headlessly: no args runs all tests, a test name (or path) runs just that one. Use e2e-cli for a visible UI.
e2e *args:
+12 -8
View File
@@ -263,12 +263,14 @@ func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error {
}
type MoveTodosUpInstruction struct {
Hashes []string
Hashes []string
Distance int
}
func NewMoveTodosUpInstruction(hashes []string) Instruction {
func NewMoveTodosUpInstruction(hashes []string, distance int) Instruction {
return &MoveTodosUpInstruction{
Hashes: hashes,
Hashes: hashes,
Distance: distance,
}
}
@@ -288,17 +290,19 @@ func (self *MoveTodosUpInstruction) run(common *common.Common) error {
})
return handleInteractiveRebase(common, func(path string) error {
return utils.MoveTodosUp(path, todosToMove, false, getCommentChar())
return utils.MoveTodos(path, todosToMove, false, -self.Distance, getCommentChar())
})
}
type MoveTodosDownInstruction struct {
Hashes []string
Hashes []string
Distance int
}
func NewMoveTodosDownInstruction(hashes []string) Instruction {
func NewMoveTodosDownInstruction(hashes []string, distance int) Instruction {
return &MoveTodosDownInstruction{
Hashes: hashes,
Hashes: hashes,
Distance: distance,
}
}
@@ -318,7 +322,7 @@ func (self *MoveTodosDownInstruction) run(common *common.Common) error {
})
return handleInteractiveRebase(common, func(path string) error {
return utils.MoveTodosDown(path, todosToMove, false, getCommentChar())
return utils.MoveTodos(path, todosToMove, false, self.Distance, getCommentChar())
})
}
+1 -1
View File
@@ -16,7 +16,7 @@ type errorMapping struct {
func knownError(tr *i18n.TranslationSet, err error) (string, bool) {
errorMessage := err.Error()
knownErrorMessages := []string{minGitVersionErrorMessage(tr)}
knownErrorMessages := []string{minGitVersionErrorMessage(tr), tr.BareRepoNotSupported}
if lo.Contains(knownErrorMessages, errorMessage) {
return errorMessage, true
+26 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -60,25 +61,42 @@ func NewGitCommand(
version *git_commands.GitVersion,
osCommand *oscommands.OSCommand,
gitConfig git_config.IGitConfig,
pagerConfig *config.PagerConfig,
diffRendererConfigManager *config.DiffRendererConfigManager,
) (*GitCommand, error) {
repoPaths, err := git_commands.GetRepoPaths(osCommand.Cmd, version)
if err != nil {
return nil, errors.Errorf("Error getting repo paths: %v", err)
}
// A bare repo has no worktree for us to work in. Callers that can offer the
// user something better (app.setupRepo) check for this first; getting here
// means nobody could, e.g. because --git-dir was pointed at a bare repo.
if repoPaths.IsBareRepo() {
return nil, errors.New(cmn.Tr.BareRepoNotSupported)
}
err = os.Chdir(repoPaths.WorktreePath())
if err != nil {
return nil, utils.WrapError(err)
}
// Everything we run through the command builder gets told where the repo is
// by the builder itself, but subprocesses don't go through it: user-defined
// custom commands, an editor, and the lazygit we re-enter as git's sequence
// editor during a rebase. Put it in the process env for those.
env.SetGitLocationEnvVars(repoPaths.GitLocationEnvVars())
// Pin the config reads to the repo directory like all other git commands
// (see NewGitCmdObjBuilder); the config commands run outside that builder.
gitConfig.SetDir(repoPaths.WorktreePath())
return NewGitCommandAux(
cmn,
version,
osCommand,
gitConfig,
repoPaths,
pagerConfig,
diffRendererConfigManager,
), nil
}
@@ -88,9 +106,9 @@ func NewGitCommandAux(
osCommand *oscommands.OSCommand,
gitConfig git_config.IGitConfig,
repoPaths *git_commands.RepoPaths,
pagerConfig *config.PagerConfig,
diffRendererConfigManager *config.DiffRendererConfigManager,
) *GitCommand {
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd)
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath(), repoPaths.GitLocationEnvVars())
// here we're doing a bunch of dependency injection for each of our commands structs.
// This is admittedly messy, but allows us to test each command struct in isolation,
@@ -99,7 +117,7 @@ func NewGitCommandAux(
// common ones are: cmn, osCommand, dotGitDir, configCommands
configCommands := git_commands.NewConfigCommands(cmn, gitConfig)
gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, repoPaths, configCommands, pagerConfig)
gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, repoPaths, configCommands, diffRendererConfigManager)
fileLoader := git_commands.NewFileLoader(gitCommon, cmd, configCommands)
statusCommands := git_commands.NewStatusCommands(gitCommon)
@@ -119,6 +137,9 @@ func NewGitCommandAux(
patchBuilder := patch.NewPatchBuilder(cmn.Log,
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain)
},
func() (string, error) {
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
})
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon)
+21 -3
View File
@@ -11,6 +11,22 @@ import (
type gitCmdObjBuilder struct {
innerBuilder *oscommands.CmdObjBuilder
// The directory of the repo (or worktree) this builder was created for;
// every command we produce runs there, regardless of the process's current
// working directory. The two are the same until the user switches to
// another repo: lazygit chdirs on a switch, but work still in flight for
// the previous repo (e.g. a background refresh spawning commands through
// the old builder) must keep running its commands against the repo it
// started in, not whichever one the process has since moved to.
repoDir string
// The env vars every command we produce gets: the optional-locks one below,
// plus the repo's git location if it has one (see
// RepoPaths.GitLocationEnvVars). Those are in the process env too, but for
// the same reason as repoDir we don't rely on that: the process env belongs
// to whichever repo lazygit has since switched to.
envVars []string
}
var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
@@ -21,7 +37,7 @@ var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
// only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar.
var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0"
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *gitCmdObjBuilder {
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string, gitLocationEnvVars []string) *gitCmdObjBuilder {
// the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase)
updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
return &gitCmdObjRunner{
@@ -33,15 +49,17 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild
return &gitCmdObjBuilder{
innerBuilder: updatedBuilder,
repoDir: repoDir,
envVars: append([]string{defaultEnvVar}, gitLocationEnvVars...),
}
}
func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj {
return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar)
return self.innerBuilder.New(args).AddEnvVars(self.envVars...).SetWd(self.repoDir)
}
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj {
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar)
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(self.envVars...).SetWd(self.repoDir)
}
func (self *gitCmdObjBuilder) Quote(str string) string {
+37
View File
@@ -17,8 +17,45 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) {
builder := NewGitCmdObjBuilder(
utils.NewDummyLog(),
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
"/path/to/repo",
nil,
)
assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0")
assert.Contains(t, builder.NewShell("git status", "").GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0")
}
// Every command the builder produces runs in the directory of the repo the
// builder was created for, not in the process's current directory: lazygit
// chdirs when switching repos, and commands built for the previous repo after
// that (e.g. by a background refresh still in flight) must keep addressing the
// repo they were built for.
func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) {
builder := NewGitCmdObjBuilder(
utils.NewDummyLog(),
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
"/path/to/repo",
nil,
)
assert.Equal(t, "/path/to/repo", builder.New([]string{"git", "status"}).GetCmd().Dir)
assert.Equal(t, "/path/to/repo", builder.NewShell("git status", "").GetCmd().Dir)
}
// A repo whose git dir isn't in its worktree can't be found by running a
// command there, so the builder has to tell every command where it is; see
// RepoPaths.GitLocationEnvVars. The process env says the same thing, but only
// for the repo lazygit is in right now, which isn't necessarily this one.
func TestGitCmdObjBuilderPinsCommandsToGitLocation(t *testing.T) {
builder := NewGitCmdObjBuilder(
utils.NewDummyLog(),
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
"/path/to/worktree",
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"},
)
assert.Subset(t, builder.New([]string{"git", "status"}).GetEnvVars(),
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"})
assert.Subset(t, builder.NewShell("git status", "").GetEnvVars(),
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"})
}
+1 -1
View File
@@ -29,5 +29,5 @@ func (self *BlameCommands) BlameLineRange(filename string, commit string, firstL
Arg("--").
Arg(filename)
return self.cmd.New(cmdArgs.ToArgv()).RunWithOutput()
return self.cmd.New(cmdArgs.ToArgv()).DontLog().RunWithOutput()
}
+3 -11
View File
@@ -240,24 +240,16 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
return self.cmd.New(cmdArgs)
}
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj {
contextSize := self.UserConfig().Git.DiffContextSize
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig()
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string, ignoreExternalDiff bool) *oscommands.CmdObj {
cmdArgs := NewGitCmd("show").
Config("diff.noprefix=false").
ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd).
ArgIfElse(extDiffCmd != "" || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("--submodule").
Arg("--color="+self.pagerConfig.GetColorArg()).
Arg(fmt.Sprintf("--unified=%d", contextSize)).
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
Arg("--stat").
Arg("--decorate").
Arg("-p").
Arg(hash).
ArgIf(self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
Arg("--").
Arg(filterPaths...).
Dir(self.repoPaths.worktreePath).
+18 -18
View File
@@ -255,7 +255,7 @@ func TestCommitShowCmdObj(t *testing.T) {
contextSize uint64
similarityThreshold int
ignoreWhitespace bool
pagerConfig *config.PagingConfig
diffRendererConfig *config.DiffRendererConfig
expected []string
}
@@ -266,8 +266,8 @@ func TestCommitShowCmdObj(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
ignoreWhitespace: false,
pagerConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"},
diffRendererConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
},
{
testName: "Default case with filter path",
@@ -275,8 +275,8 @@ func TestCommitShowCmdObj(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
ignoreWhitespace: false,
pagerConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--", "file.txt"},
diffRendererConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--", "file.txt"},
},
{
testName: "Show diff with custom context size",
@@ -284,8 +284,8 @@ func TestCommitShowCmdObj(t *testing.T) {
contextSize: 77,
similarityThreshold: 50,
ignoreWhitespace: false,
pagerConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=77", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"},
diffRendererConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=77", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
},
{
testName: "Show diff with custom similarity threshold",
@@ -293,8 +293,8 @@ func TestCommitShowCmdObj(t *testing.T) {
contextSize: 3,
similarityThreshold: 33,
ignoreWhitespace: false,
pagerConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=33%", "--"},
diffRendererConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=33%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
},
{
testName: "Show diff, ignoring whitespace",
@@ -302,8 +302,8 @@ func TestCommitShowCmdObj(t *testing.T) {
contextSize: 77,
similarityThreshold: 50,
ignoreWhitespace: true,
pagerConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=77", "--stat", "--decorate", "-p", "1234567890", "--ignore-all-space", "--find-renames=50%", "--"},
diffRendererConfig: nil,
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=77", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
},
{
testName: "Show diff with external diff command",
@@ -311,8 +311,8 @@ func TestCommitShowCmdObj(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
ignoreWhitespace: false,
pagerConfig: &config.PagingConfig{ExternalDiffCommand: "difft --color=always"},
expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"},
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
},
{
testName: "Show diff using git's external diff config",
@@ -320,16 +320,16 @@ func TestCommitShowCmdObj(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
ignoreWhitespace: false,
pagerConfig: &config.PagingConfig{UseExternalDiffGitConfig: true},
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"},
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff"},
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
userConfig := config.GetDefaultConfig()
if s.pagerConfig != nil {
userConfig.Git.Pagers = []config.PagingConfig{*s.pagerConfig}
if s.diffRendererConfig != nil {
userConfig.Git.DiffRenderers = []config.DiffRendererConfig{*s.diffRendererConfig}
}
userConfig.Git.IgnoreWhitespaceInDiffView = s.ignoreWhitespace
userConfig.Git.DiffContextSize = s.contextSize
@@ -341,7 +341,7 @@ func TestCommitShowCmdObj(t *testing.T) {
}
instance := buildCommitCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, runner: runner, repoPaths: &repoPaths})
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths).Run())
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths, false).Run())
runner.CheckForMissingCalls()
})
}
+14 -14
View File
@@ -8,12 +8,12 @@ import (
type GitCommon struct {
*common.Common
version *GitVersion
cmd oscommands.ICmdObjBuilder
os *oscommands.OSCommand
repoPaths *RepoPaths
config *ConfigCommands
pagerConfig *config.PagerConfig
version *GitVersion
cmd oscommands.ICmdObjBuilder
os *oscommands.OSCommand
repoPaths *RepoPaths
config *ConfigCommands
diffRendererConfigManager *config.DiffRendererConfigManager
}
func NewGitCommon(
@@ -23,15 +23,15 @@ func NewGitCommon(
osCommand *oscommands.OSCommand,
repoPaths *RepoPaths,
config *ConfigCommands,
pagerConfig *config.PagerConfig,
diffRendererConfigManager *config.DiffRendererConfigManager,
) *GitCommon {
return &GitCommon{
Common: cmn,
version: version,
cmd: cmd,
os: osCommand,
repoPaths: repoPaths,
config: config,
pagerConfig: pagerConfig,
Common: cmn,
version: version,
cmd: cmd,
os: osCommand,
repoPaths: repoPaths,
config: config,
diffRendererConfigManager: diffRendererConfigManager,
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ func buildGitCommon(deps commonDeps) *GitCommon {
gitCommon.Common.SetUserConfig(config.GetDefaultConfig())
}
gitCommon.pagerConfig = config.NewPagerConfig(func() *config.UserConfig {
gitCommon.diffRendererConfigManager = config.NewDiffRendererConfigManager(func() *config.UserConfig {
return gitCommon.Common.UserConfig()
})
+135 -14
View File
@@ -2,8 +2,11 @@ package git_commands
import (
"fmt"
"os"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/mgutz/str"
)
type DiffCommands struct {
@@ -16,32 +19,150 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
}
}
// This is for generating diffs to be shown in the UI (e.g. rendering a range
// diff to the main view). It uses a custom pager if one is configured.
func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
useExtDiff := extDiffCmd != ""
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig()
ignoreWhitespace := self.UserConfig().Git.IgnoreWhitespaceInDiffView
// metadataHandshake is the OSC sequence a metadata-aware pager emits, as its first
// output, to announce that it speaks the diff-line-metadata protocol: a version-only
// OSC 1717 record (no fields). See ProbePagerEmitsDiffMetadata and, for how it's
// swallowed on a real render, escapeInterpreter.dropMetadataIfHandshake.
const metadataHandshake = "\x1b]1717"
// ProbePagerEmitsDiffMetadata reports whether the configured diff renderer speaks the
// diff-line-metadata protocol, by running it on empty input and checking for its
// handshake. It's the focused main view's signal for whether it can act on the rendered
// diff or must fall back to the raw diff (see
// StagingHelper.DiffMainViewShouldRenderRaw). The verdict is content-independent — the
// handshake is the renderer's first output regardless of the diff — so the caller caches
// it per renderer.
//
// No PTY is needed: git needs a terminal to decide to invoke a pager, but a renderer
// emits the handshake whenever OSC1717 is set, so we can run it directly with empty
// input.
//
// A git-config external diff driver (useExternalDiffGitConfig) is chosen per file via
// .gitattributes and a single diff can mix drivers, so there's no one renderer to probe;
// we conservatively report false (the focused main view then always renders raw).
func (self *DiffCommands) ProbePagerEmitsDiffMetadata() bool {
if extDiffCmd := self.diffRendererConfigManager.GetExternalDiffCommand(3); extDiffCmd != "" {
return self.externalDiffEmitsMetadata(extDiffCmd)
}
if pagerCmd := self.diffRendererConfigManager.GetStdinFilterCommand(0); pagerCmd != "" {
return self.probeEmitsMetadata(self.cmd.NewShell(pagerCmd, ""))
}
if rawGitArgs := self.diffRendererConfigManager.GetRawGitArgs(); len(rawGitArgs) > 0 {
return self.rawGitEmitsMetadata(rawGitArgs)
}
return false
}
// rawGitEmitsMetadata probes git itself, run with the diff renderer's own arguments.
//
// The arguments are the point: git describes only the formats whose output can't be read
// back from its own text, and announces itself for exactly those, so the same git answers
// differently depending on what it is asked for — a handshake for --color-words, silence
// for a unified diff.
func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool {
oldPath, newPath, cleanup, ok := probeFiles()
if !ok {
return false
}
defer cleanup()
return self.probeEmitsMetadata(self.cmd.New(
NewGitCmd("diff").
Arg("--no-index").
Arg(rawGitArgs...).
Arg(oldPath, newPath).
ToArgv(),
))
}
// externalDiffEmitsMetadata probes an external diff command, invoking it the way git
// invokes a diff.external driver — with 7 positional args
// (path old-file old-hex old-mode new-file new-hex new-mode) — but on two empty temp
// files, so it emits its handshake without there being a real diff to render.
func (self *DiffCommands) externalDiffEmitsMetadata(extDiffCmd string) bool {
oldPath, newPath, cleanup, ok := probeFiles()
if !ok {
return false
}
defer cleanup()
args := append(str.ToArgv(extDiffCmd),
"probe", oldPath, "0000000", "100644", newPath, "0000000", "100644")
return self.probeEmitsMetadata(self.cmd.New(args))
}
// probeFiles creates the two empty temp files a probe stands a diff up from, and a
// cleanup that removes them. Empty because a probe needs the renderer to announce
// itself, not to have anything to render.
func probeFiles() (string, string, func(), bool) {
oldFile, err := os.CreateTemp("", "lazygit-probe-old-*")
if err != nil {
return "", "", nil, false
}
oldFile.Close()
newFile, err := os.CreateTemp("", "lazygit-probe-new-*")
if err != nil {
os.Remove(oldFile.Name())
return "", "", nil, false
}
newFile.Close()
return oldFile.Name(), newFile.Name(), func() {
os.Remove(oldFile.Name())
os.Remove(newFile.Name())
}, true
}
func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool {
cmdObj.AddEnvVars("OSC1717=V1")
// The pager may exit non-zero on the synthetic input; we only care about whether
// it emitted the handshake first, and the output is captured either way.
output, _ := cmdObj.RunWithOutput()
return strings.Contains(output, metadataHandshake)
}
// This is for generating diffs to be shown in the UI (e.g. rendering a range
// diff to the main view). It uses a custom diff renderer if one is configured, unless
// ignoreExternalDiff is set (the focused main view's raw-diff fallback; keeps the
// colour, unlike a plain diff).
func (self *DiffCommands) DiffCmdObj(diffArgs []string, ignoreExternalDiff bool) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
Config("diff.noprefix=false").
ConfigIf(useExtDiff, "diff.external="+extDiffCmd).
ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())).
ArgIf(ignoreWhitespace, "--ignore-all-space").
Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)).
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(diffArgs...).
Dir(self.repoPaths.worktreePath).
ToArgv(),
)
}
// CustomPatchDiffCmdObj builds the command that renders the custom patch shown in the
// secondary pane: a `git diff --no-index` of the two file trees PatchCommands materialized
// under dir (a/ = before, b/ = after; see WriteCustomPatchDiffTrees). It uses the same pager
// wiring as DiffCmdObj so the patch renders exactly like any other diff — through a stdin
// pager, an external diff tool, or (when ignoreExternalDiff is set, the focused main view's
// raw-diff fallback) git's own colour. --no-prefix is used because the a/ and b/ tree names
// already stand in for git's conventional a//b/ path prefixes, so the diff's paths come out
// as the real repo-relative paths.
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, ignoreExternalDiff bool) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("--no-index").
Arg("--no-prefix").
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg("a", "b").
Dir(dir).
ToArgv(),
)
}
// This is a basic generic diff command that can be used for any diff operation
// (e.g. copying a diff to the clipboard). It will not use a custom pager, and
// does not use user configs such as ignore whitespace.
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
// and does not use user configs such as ignore whitespace.
// If you want to diff specific refs (one or two), you need to add them yourself
// in additionalArgs; it is recommended to also pass `--` after that. If you
// want to restrict the diff to specific paths, pass them in additionalArgs
+2 -1
View File
@@ -2,6 +2,7 @@ package git_commands
import (
"os"
"path/filepath"
"strconv"
"strings"
@@ -93,7 +94,7 @@ func (self *FileCommands) guessDefaultEditor() string {
// At this point, it might be more than just the name of the editor;
// e.g. it might be "code -w" or "vim -u myvim.rc". So assume that
// everything up to the first space is the editor name.
editor = strings.Split(editor, " ")[0]
editor = filepath.Base(strings.Split(editor, " ")[0])
}
return editor
+60
View File
@@ -8,6 +8,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
)
type FileLoaderConfig interface {
@@ -88,6 +89,8 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
files = append(files, file)
}
self.setConflictMarkerSizes(files)
// Go through the files to see if any of these files are actually worktrees
// so that we can render them correctly
worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath())
@@ -111,6 +114,63 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
return files
}
// Looks up how long the conflict markers in the conflicted files are. We ask
// git for all of them at once, because spawning a process per file would be
// painfully slow when hundreds of files are conflicted (especially on Windows).
func (self *FileLoader) setConflictMarkerSizes(files []*models.File) {
conflictedFiles := lo.Filter(files, func(file *models.File, _ int) bool {
return file.HasInlineMergeConflicts
})
if len(conflictedFiles) == 0 {
return
}
paths := lo.Map(conflictedFiles, func(file *models.File, _ int) string {
return file.Path
})
markerSizes, err := self.getConflictMarkerSizes(paths)
if err != nil {
self.Log.Error(err)
return
}
for _, file := range conflictedFiles {
file.ConflictMarkerSize = markerSizes[file.Path]
}
}
func (self *FileLoader) getConflictMarkerSizes(paths []string) (map[string]int, error) {
cmdArgs := NewGitCmd("check-attr").
Arg("-z").
Arg("--stdin").
Arg("conflict-marker-size").
ToArgv()
// -z makes git both read the paths and write its output NUL-separated, so
// that paths containing newlines don't throw us off.
output, _, err := self.cmd.New(cmdArgs).
SetStdin(strings.Join(paths, "\x00")).
DontLog().
RunWithOutputs()
if err != nil {
return nil, err
}
markerSizes := map[string]int{}
fields := strings.Split(output, "\x00")
// Each path yields a path/attribute/value triple; the value is either a
// number or something like "unspecified", in which case we leave the marker
// size at 0 to say that git's default applies.
for i := 0; i+2 < len(fields); i += 3 {
if markerSize, err := strconv.Atoi(fields[i+2]); err == nil && markerSize > 0 {
markerSizes[fields[i]] = markerSize
}
}
return markerSizes, nil
}
type FileDiff struct {
LinesAdded int
LinesDeleted int
@@ -37,6 +37,10 @@ func TestFileGetStatusFiles(t *testing.T) {
ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"},
"4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt",
nil,
).
ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"},
"file5.txt\x00conflict-marker-size\x00unspecified\x00",
nil,
),
showNumstatInFilesView: true,
expectedFiles: []*models.File{
@@ -112,6 +116,58 @@ func TestFileGetStatusFiles(t *testing.T) {
},
},
},
{
testName: "Conflicted files with a conflict-marker-size attribute",
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"},
"UU file1.txt\x00UU file2.txt\x00UU file3.txt\x00 M file4.txt",
nil,
).
ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"},
"file1.txt\x00conflict-marker-size\x0032\x00"+
"file2.txt\x00conflict-marker-size\x00unspecified\x00"+
"file3.txt\x00conflict-marker-size\x00nonsense\x00",
nil,
),
expectedFiles: []*models.File{
{
Path: "file1.txt",
HasUnstagedChanges: true,
Tracked: true,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
ConflictMarkerSize: 32,
DisplayString: "UU file1.txt",
ShortStatus: "UU",
},
{
Path: "file2.txt",
HasUnstagedChanges: true,
Tracked: true,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
DisplayString: "UU file2.txt",
ShortStatus: "UU",
},
{
Path: "file3.txt",
HasUnstagedChanges: true,
Tracked: true,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
DisplayString: "UU file3.txt",
ShortStatus: "UU",
},
{
Path: "file4.txt",
HasUnstagedChanges: true,
Tracked: true,
DisplayString: " M file4.txt",
ShortStatus: " M",
},
},
},
{
testName: "File with new line char",
similarityThreshold: 50,
+11
View File
@@ -203,6 +203,17 @@ func TestGuessDefaultEditor(t *testing.T) {
},
expectedResult: "bbedit",
},
{
gitConfigMockResponses: nil,
getenv: func(env string) string {
if env == "EDITOR" {
return "/usr/bin/nvim"
}
return ""
},
expectedResult: "nvim",
},
}
for _, s := range scenarios {
@@ -1,9 +1,12 @@
package git_commands
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/env"
)
// OptionalLocksEnvVar is the name of the environment variable that tells git
@@ -16,6 +19,15 @@ import (
// that opts back in is the foreground files refresh; see FileLoader.gitStatus.
const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS"
// forOtherRepo prepares a command that operates on a repo other than the one
// we have open — a submodule, or another worktree. GIT_DIR and GIT_WORK_TREE
// say where our repo is, and every command we run inherits them, so a command
// pointed at a different repo would be resolved against ours instead: `git -C
// <submodule> log` would silently log the superproject's commits.
func forOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar)
}
// convenience struct for building git commands. Especially useful when
// including conditional args
type GitCommandBuilder struct {
@@ -111,6 +123,20 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
return self
}
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
contextSize := userConfig.Git.DiffContextSize
extDiffCmd := diffRendererConfigManager.GetExternalDiffCommand(contextSize)
useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
return self.
ConfigIf(forUI && extDiffCmd != "", "diff.external="+extDiffCmd).
ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff").
Arg(fmt.Sprintf("--unified=%d", contextSize)).
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)).
ArgIf(forUI, diffRendererConfigManager.GetRawGitArgs()...)
}
func (self *GitCommandBuilder) ToArgv() []string {
return append([]string{"git"}, self.args...)
}
+28 -2
View File
@@ -85,12 +85,25 @@ type PullRequestNode struct {
HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
State string `json:"state"`
IsDraft bool `json:"isDraft"`
HeadRef GithubRef `json:"headRef"`
}
type GithubRepositoryOwner struct {
Login string `json:"login"`
}
type GithubRef struct {
Target GithubGitObject `json:"target"`
}
type GithubGitObject struct {
StatusCheckRollup GithubStatusCheckRollup `json:"statusCheckRollup"`
}
type GithubStatusCheckRollup struct {
State string `json:"state"`
}
type graphQLRequest struct {
Query string `json:"query"`
Variables map[string]string `json:"variables"`
@@ -121,6 +134,15 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
number
url
isDraft
headRef {
target {
... on Commit {
statusCheckRollup {
state
}
}
}
}
headRepositoryOwner {
login
}
@@ -231,9 +253,12 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string,
return nil, err
}
return parsePullRequestsResponse(respBytes)
}
func parsePullRequestsResponse(respBytes []byte) ([]*models.GithubPullRequest, error) {
var result Response
err = json.Unmarshal(respBytes, &result)
if err != nil {
if err := json.Unmarshal(respBytes, &result); err != nil {
return nil, err
}
@@ -246,6 +271,7 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string,
Number: node.Number,
Title: node.Title,
State: lo.Ternary(node.IsDraft && node.State != "CLOSED", "DRAFT", node.State),
ChecksState: node.HeadRef.Target.StatusCheckRollup.State,
Url: node.Url,
HeadRepositoryOwner: models.GithubRepositoryOwner{
Login: node.HeadRepositoryOwner.Login,
+100
View File
@@ -76,6 +76,104 @@ func TestGraphQLEndpoint(t *testing.T) {
}
}
func TestFetchPullRequestsQueryFetchesOnlyAggregateCheckState(t *testing.T) {
query, variables := fetchPullRequestsQuery([]string{"feature"}, "owner", "repo")
assert.Contains(t, query, "headRef {")
assert.Contains(t, query, "... on Commit {")
assert.Contains(t, query, "statusCheckRollup {")
assert.NotContains(t, query, "contexts")
assert.Equal(t, map[string]string{
"owner": "owner",
"repo": "repo",
"branch1": "feature",
}, variables)
}
func TestParsePullRequestsResponse(t *testing.T) {
t.Run("flattens aliases and normalizes drafts", func(t *testing.T) {
response := []byte(`{
"data": {
"repository": {
"a1": {
"edges": [
{
"node": {
"title": "Add feature",
"headRefName": "feature",
"number": 42,
"url": "https://github.com/jesseduffield/lazygit/pull/42",
"headRepositoryOwner": {"login": "contributor"},
"state": "OPEN",
"isDraft": false,
"headRef": {
"target": {
"statusCheckRollup": {"state": "SUCCESS"}
}
}
}
}
]
},
"a2": {
"edges": [
{
"node": {
"title": "Draft feature",
"headRefName": "draft-feature",
"number": 43,
"url": "https://github.com/jesseduffield/lazygit/pull/43",
"headRepositoryOwner": {"login": "contributor"},
"state": "OPEN",
"isDraft": true,
"headRef": null
}
}
]
}
}
}
}`)
prs, err := parsePullRequestsResponse(response)
assert.NoError(t, err)
assert.ElementsMatch(t, []*models.GithubPullRequest{
{
HeadRefName: "feature",
Number: 42,
Title: "Add feature",
State: "OPEN",
ChecksState: "SUCCESS",
Url: "https://github.com/jesseduffield/lazygit/pull/42",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
},
{
HeadRefName: "draft-feature",
Number: 43,
Title: "Draft feature",
State: "DRAFT",
Url: "https://github.com/jesseduffield/lazygit/pull/43",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
},
}, prs)
})
t.Run("returns an empty slice for an empty result", func(t *testing.T) {
prs, err := parsePullRequestsResponse([]byte(`{"data":{"repository":{}}}`))
assert.NoError(t, err)
assert.Empty(t, prs)
})
t.Run("rejects malformed JSON", func(t *testing.T) {
prs, err := parsePullRequestsResponse([]byte(`{"data":`))
assert.Error(t, err)
assert.Nil(t, prs)
})
}
func TestGenerateGithubPullRequestMap(t *testing.T) {
cases := []struct {
name string
@@ -99,6 +197,7 @@ func TestGenerateGithubPullRequestMap(t *testing.T) {
Number: 42,
Title: "Add feature",
State: "OPEN",
ChecksState: "PENDING",
Url: "https://github.com/jesseduffield/lazygit/pull/42",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
@@ -122,6 +221,7 @@ func TestGenerateGithubPullRequestMap(t *testing.T) {
Number: 42,
Title: "Add feature",
State: "OPEN",
ChecksState: "PENDING",
Url: "https://github.com/jesseduffield/lazygit/pull/42",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
+92
View File
@@ -2,7 +2,9 @@ package git_commands
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
@@ -20,6 +22,11 @@ type PatchCommands struct {
stash *StashCommands
PatchBuilder *patch.PatchBuilder
// lastBuiltTreeGeneration is the patch builder generation the diff trees were last
// materialized for, so EnsureCustomPatchDiffTrees rebuilds them only when the patch
// actually changed (not on every render / navigation).
lastBuiltTreeGeneration int
}
func NewPatchCommands(
@@ -40,6 +47,91 @@ func NewPatchCommands(
}
}
// EnsureCustomPatchDiffTrees materializes the custom patch's diff trees if they're stale —
// i.e. the patch changed since they were last built. Called before rendering the secondary
// pane, so a mere re-render (e.g. navigating commits, which doesn't change the patch) reuses
// the existing trees, while a toggle/removal/whole-file change rebuilds them. This is what
// keeps the trees current across every path that mutates the patch.
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
if self.PatchBuilder.Generation() == self.lastBuiltTreeGeneration {
return nil
}
if err := self.WriteCustomPatchDiffTrees(); err != nil {
return err
}
self.lastBuiltTreeGeneration = self.PatchBuilder.Generation()
return nil
}
// WriteCustomPatchDiffTrees materializes the custom patch under the patch builder's temp
// dir as two file trees — a/ holds each patched file's "from"-side content, b/ holds that
// content with the patch applied — so the patch can be re-diffed with `git diff --no-index`
// and rendered through any pager (see DiffCommands.CustomPatchDiffCmdObj). This is what lets
// a partial, in-memory custom patch be shown the same way as any other diff; the in-memory
// aggregated patch on its own could only be fed to a stdin pager, never an external diff
// tool. The dirs are named a/b so that, with `--no-prefix`, the diff's paths come out as the
// real repo-relative paths (git's conventional a//b/ prefixes).
//
// Called whenever the patch's contents change. The temp dir's lifetime is owned by the
// patch builder (created on Start, removed on Reset), so there's nothing to clean up here
// beyond wiping the trees before rebuilding them.
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
dir := self.PatchBuilder.TempDir()
if dir == "" {
return nil
}
aDir := filepath.Join(dir, "a")
bDir := filepath.Join(dir, "b")
for _, d := range []string{aDir, bDir} {
if err := os.RemoveAll(d); err != nil {
return err
}
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
for _, filename := range self.PatchBuilder.ActiveFilenames() {
// The "from"-side content; the patch applied below turns b/ into the "after" side
// while a/ stays the "before".
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, filename).RunWithOutput()
added := err != nil // absent on the "from" side — a file the patch adds
// a/ always holds the "before" content — empty for an added file, so the rendered
// diff still pairs it with b/ and shows the real a//b/ paths (rather than git's
// directory-comparison "added in b" form, which mangles the header).
beforeContent := content
if added {
beforeContent = ""
}
if err := self.os.CreateFileWithContent(filepath.Join(aDir, filename), beforeContent); err != nil {
return err
}
// b/ is seeded only for existing files (which the patch modifies in place); an added
// file is left absent so the patch — rendered as a /dev/null creation — creates it.
if !added {
if err := self.os.CreateFileWithContent(filepath.Join(bDir, filename), content); err != nil {
return err
}
}
}
// Render added files as /dev/null creations (the natural form), not as diffs against an
// empty file: the latter (--- a/file) would make the atomic `git apply` expect them to
// already exist in b/, where they don't.
patchText := self.PatchBuilder.PatchToApply(false, false)
if strings.TrimSpace(patchText) == "" {
// An empty patch leaves a/ and b/ identical (or empty), so the diff is empty.
return nil
}
patchFilePath, err := self.SaveTemporaryPatch(patchText)
if err != nil {
return err
}
return self.cmd.New(NewGitCmd("apply").Arg(patchFilePath).Dir(bDir).ToArgv()).Run()
}
type ApplyPatchOpts struct {
ThreeWay bool
Cached bool
+20 -20
View File
@@ -112,29 +112,30 @@ func (self *RebaseCommands) GenericAmend(commits []*models.Commit, start, end in
}
func (self *RebaseCommands) MoveCommitsDown(commits []*models.Commit, startIdx int, endIdx int) error {
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+2)
hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string {
return commit.Hash()
})
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: baseHashOrRoot,
instruction: daemon.NewMoveTodosDownInstruction(hashes),
overrideEditor: true,
}).Run()
return self.MoveCommits(commits, startIdx, endIdx, 1)
}
func (self *RebaseCommands) MoveCommitsUp(commits []*models.Commit, startIdx int, endIdx int) error {
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+1)
return self.MoveCommits(commits, startIdx, endIdx, -1)
}
func (self *RebaseCommands) MoveCommits(commits []*models.Commit, startIdx int, endIdx int, offset int) error {
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+max(offset, 0)+1)
hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string {
return commit.Hash()
})
var instruction daemon.Instruction
if offset > 0 {
instruction = daemon.NewMoveTodosDownInstruction(hashes, offset)
} else {
instruction = daemon.NewMoveTodosUpInstruction(hashes, -offset)
}
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: baseHashOrRoot,
instruction: daemon.NewMoveTodosUpInstruction(hashes),
instruction: instruction,
overrideEditor: true,
}).Run()
}
@@ -369,21 +370,20 @@ func (self *RebaseCommands) DeleteUpdateRefTodos(commits []*models.Commit) error
}
func (self *RebaseCommands) MoveTodosDown(commits []*models.Commit) error {
fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo")
todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo {
return todoFromCommit(commit)
})
return utils.MoveTodosDown(fileName, todosToMove, true, self.config.GetCoreCommentChar())
return self.MoveTodos(commits, 1)
}
func (self *RebaseCommands) MoveTodosUp(commits []*models.Commit) error {
return self.MoveTodos(commits, -1)
}
func (self *RebaseCommands) MoveTodos(commits []*models.Commit, offset int) error {
fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo")
todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo {
return todoFromCommit(commit)
})
return utils.MoveTodosUp(fileName, todosToMove, true, self.config.GetCoreCommentChar())
return utils.MoveTodos(fileName, todosToMove, true, offset, self.config.GetCoreCommentChar())
}
// SquashAllAboveFixupCommits squashes all fixup! commits above the given one
+178 -13
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spf13/afero"
)
@@ -19,10 +20,12 @@ type RepoPaths struct {
repoGitDirPath string
repoName string
isBareRepo bool
gitLocationEnvVars []string
}
// Path to the current worktree. If we're in the main worktree, this will
// be the same as RepoPath()
// be the same as RepoPath(). It is empty for a bare repo, which has no
// worktree at all.
func (self *RepoPaths) WorktreePath() string {
return self.worktreePath
}
@@ -53,10 +56,33 @@ func (self *RepoPaths) RepoName() string {
return self.repoName
}
// Whether we found no worktree, so that there is nothing for lazygit to show.
// Note that this isn't quite git's core.bare: a repo that calls itself non-bare
// but whose worktree we couldn't find counts as bare for us too. Concretely,
// this is true when we're in
//
// - a genuinely bare repo;
// - the git dir of a linked worktree (.git/worktrees/x), whose worktree is
// recorded but not somewhere we look;
// - a repo that keeps its worktree somewhere only GIT_WORK_TREE knows, such
// as a vcsh-style dotfiles repo that hasn't been given core.worktree.
//
// The .git dir of an ordinary repo is not one of them: GetRepoPathsForDir
// notices the worktree holding it and hands back that repo instead.
func (self *RepoPaths) IsBareRepo() bool {
return self.isBareRepo
}
// The environment that tells git where this repo is, as "NAME=value" entries.
// It is empty for the vast majority of repos, which git finds for itself by
// looking for a .git in the directory a command runs in. It is only non-empty
// when that doesn't work — when the git dir lives somewhere else entirely,
// because of core.worktree or --work-tree — and then every command addressing
// the repo has to carry it.
func (self *RepoPaths) GitLocationEnvVars() []string {
return self.gitLocationEnvVars
}
// Returns the repo paths for a typical repo
func MockRepoPaths(currentPath string) *RepoPaths {
return &RepoPaths{
@@ -84,26 +110,76 @@ func GetRepoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
) (*RepoPaths, error) {
gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree")
repoPaths, err := repoPathsForDir(dir, cmd)
if err != nil || !repoPaths.IsBareRepo() {
return repoPaths, err
}
// We're in a git dir rather than in a working tree, which usually just means
// somebody ran lazygit in the .git of an ordinary repo. git's convention is
// that a git dir called .git belongs to the directory holding it, so look
// there: if that is a working tree, it is the repo we were asked about, and
// there's no reason to make the user go up a directory and try again.
//
// The git dirs that aren't called .git keep the paths we have. A linked
// worktree's (.git/worktrees/x) and a submodule's (.git/modules/x) do have a
// working tree, but only the directory holding a .git tells us where, so we
// would be guessing. A bare repo's has none to find.
if filepath.Base(repoPaths.WorktreeGitDirPath()) != ".git" {
return repoPaths, nil
}
pathsFromWorkTree, err := repoPathsForDir(filepath.Dir(repoPaths.WorktreeGitDirPath()), cmd)
if err != nil || pathsFromWorkTree.IsBareRepo() {
return repoPaths, nil
}
return pathsFromWorkTree, nil
}
// repoPathsForDir asks git about the repo at dir, and reports a bare repo when
// there is no working tree there. Unlike GetRepoPathsForDir it never looks
// anywhere but dir, which is what keeps that one from going round in circles.
func repoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
) (*RepoPaths, error) {
gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree")
if err != nil {
return nil, err
// --show-toplevel is the only one of these that needs a work tree, and
// git makes it fatal when there isn't one. So this may just mean we're in
// a repo that has no work tree.
return getBareRepoPathsForDir(dir, cmd, err)
}
gitDirResults := strings.Split(utils.NormalizeLinefeeds(gitDirOutput), "\n")
worktreePath := gitDirResults[0]
worktreeGitDirPath := gitDirResults[1]
repoGitDirPath := gitDirResults[2]
isBareRepo := gitDirResults[3] == "true"
// If we're in a submodule, --show-superproject-working-tree will return
// a value, meaning gitDirResults will be length 5. In that case
// return the worktree path as the repoPath. Otherwise we're in a
// normal repo or a worktree so return the parent of the git common
// dir (repoGitDirPath)
isSubmodule := len(gitDirResults) == 5
// A worktree that has the repo's common git dir to itself is the repo's main
// worktree, so it is the repoPath. That holds for a submodule as well: its
// git dir lives under the superproject's .git/modules, but it is still the
// submodule's own common dir.
isMainWorktree := worktreeGitDirPath == repoGitDirPath
// If we're in a submodule, --show-superproject-working-tree will return a
// value, meaning gitDirResults will be length 4. That only tells us anything
// new for a linked worktree of a submodule, which isMainWorktree misses.
isSubmodule := len(gitDirResults) == 4
// Otherwise we're in a linked worktree, and the repoPath is the repo's main
// worktree. git won't tell us where that is: `git worktree list` reports it
// as the common git dir with a trailing "/.git" removed, which is this same
// derivation. So take the directory holding the common git dir. That is the
// main worktree of an ordinary repo, and of a bare one it is the directory
// its worktrees live in. It is not the main worktree of a repo that moved
// that elsewhere with core.worktree; there we end up naming the git dir's
// directory, which means that the repo name we display in the status panel
// isn't correct, and we start looking for .lazygit.yml in the wrong place.
// Both of those are not severe enough to justify the extra git call to get
// the real main worktree, so we accept this for this rather niche use case.
var repoPath string
if isSubmodule {
if isMainWorktree || isSubmodule {
repoPath = worktreePath
} else {
repoPath = filepath.Dir(repoGitDirPath)
@@ -116,21 +192,110 @@ func GetRepoPathsForDir(
repoPath: repoPath,
repoGitDirPath: repoGitDirPath,
repoName: repoName,
isBareRepo: isBareRepo,
isBareRepo: false,
gitLocationEnvVars: gitLocationEnvVars(cmd, worktreePath, worktreeGitDirPath),
}, nil
}
// gitLocationEnvVars works out whether git can find the repo by itself when a
// command runs in its worktree, and if it can't, returns the environment that
// tells git where it is. See RepoPaths.GitLocationEnvVars.
func gitLocationEnvVars(
cmd oscommands.ICmdObjBuilder,
worktreePath string,
worktreeGitDirPath string,
) []string {
// The ordinary repo, where the git dir sits in the worktree. Both paths are
// git's own answers from the same invocation, so they are spelled alike and
// comparing them is safe.
if worktreeGitDirPath == filepath.Join(worktreePath, ".git") {
return nil
}
// A linked worktree or a submodule instead has a .git file naming its git
// dir, and git follows that just as happily. We could read the file, but the
// path in it may well name the same directory differently than git did
// above, so ask git to resolve it — from the worktree and nothing else.
discoveredGitDirPath, err := callGitRevParseInOtherRepo(cmd, worktreePath, "--absolute-git-dir")
if err == nil && discoveredGitDirPath == worktreeGitDirPath {
return nil
}
return []string{
env.GitDirEnvVar + "=" + worktreeGitDirPath,
env.GitWorkTreeEnvVar + "=" + worktreePath,
}
}
// getBareRepoPathsForDir is the fallback for when we couldn't ask git for the
// work tree. Everything but --show-toplevel works fine without one, so if the
// remaining queries succeed we are in a bare repo, and we return what we know
// about it with an empty worktreePath. If they fail too we simply aren't in a
// repo, and the caller's original error says so better than ours would.
func getBareRepoPathsForDir(
dir string,
cmd oscommands.ICmdObjBuilder,
errWithWorktree error,
) (*RepoPaths, error) {
output, err := callGitRevParseWithDir(cmd, dir, "--absolute-git-dir", "--git-common-dir")
if err != nil {
return nil, errWithWorktree
}
results := strings.Split(utils.NormalizeLinefeeds(output), "\n")
repoGitDirPath := results[1]
// A bare repo has no worktree, and so no repo path in the sense the caller
// with a worktree means. It doesn't matter much what we say here, because
// nobody reads it: whoever is handed a bare repo either offers to open a
// recent one instead (app.setupRepo) or is turned away by NewGitCommand. The
// directory holding the git dir is the nearest thing there is to a repo
// path.
repoPath := filepath.Dir(repoGitDirPath)
return &RepoPaths{
worktreePath: "",
worktreeGitDirPath: results[0],
repoPath: repoPath,
repoGitDirPath: repoGitDirPath,
repoName: filepath.Base(repoPath),
isBareRepo: true,
}, nil
}
// Asks git about the repo at dir. This is how we find our own repo, so it has
// to be answered the way git itself would answer it there, GIT_DIR and
// GIT_WORK_TREE included.
func callGitRevParseWithDir(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) (string, error) {
return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...))
}
// Asks git about a repo that isn't the one we have open; see forOtherRepo.
func callGitRevParseInOtherRepo(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) (string, error) {
return runGitRevParse(forOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...)))
}
func newGitRevParseCmd(
cmd oscommands.ICmdObjBuilder,
dir string,
gitRevArgs ...string,
) *oscommands.CmdObj {
gitRevParse := NewGitCmd("rev-parse").Arg("--path-format=absolute").Arg(gitRevArgs...)
if dir != "" {
gitRevParse.Dir(dir)
}
gitCmd := cmd.New(gitRevParse.ToArgv()).DontLog()
return cmd.New(gitRevParse.ToArgv()).DontLog()
}
func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
res, err := gitCmd.RunWithOutput()
if err != nil {
return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err)
+139 -39
View File
@@ -38,8 +38,6 @@ func TestGetRepoPaths(t *testing.T) {
`C:\path\to\repo\.git`,
// --git-common-dir
`C:\path\to\repo\.git`,
// --is-bare-repository
"false",
// --show-superproject-working-tree
}, []string{
// --show-toplevel
@@ -48,12 +46,10 @@ func TestGetRepoPaths(t *testing.T) {
"/path/to/repo/.git",
// --git-common-dir
"/path/to/repo/.git",
// --is-bare-repository
"false",
// --show-superproject-working-tree
})
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
strings.Join(mockOutput, "\n"),
nil)
},
@@ -76,53 +72,147 @@ func TestGetRepoPaths(t *testing.T) {
Err: nil,
},
{
// git refuses to answer --show-toplevel when there's no work tree, so
// we have to ask a second time without it.
Name: "bare repo",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
// setup for main worktree
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
"",
errors.New("fatal: this operation must be run in a work tree"))
mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{
// --show-toplevel
`C:\path\to\repo`,
// --git-dir
`C:\path\to\bare_repo\bare.git`,
`C:\path\to\project\bare.git`,
// --git-common-dir
`C:\path\to\bare_repo\bare.git`,
// --is-bare-repository
`true`,
// --show-superproject-working-tree
`C:\path\to\project\bare.git`,
}, []string{
// --show-toplevel
"/path/to/repo",
// --git-dir
"/path/to/bare_repo/bare.git",
"/path/to/project/bare.git",
// --git-common-dir
"/path/to/bare_repo/bare.git",
// --is-bare-repository
"true",
// --show-superproject-working-tree
"/path/to/project/bare.git",
})
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
strings.Join(mockOutput, "\n"),
nil)
},
Path: "/path/to/repo",
Path: "/path/to/project",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
worktreePath: `C:\path\to\repo`,
worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`,
repoPath: `C:\path\to\bare_repo`,
repoGitDirPath: `C:\path\to\bare_repo\bare.git`,
repoName: `bare_repo`,
worktreePath: "",
worktreeGitDirPath: `C:\path\to\project\bare.git`,
repoPath: `C:\path\to\project`,
repoGitDirPath: `C:\path\to\project\bare.git`,
repoName: `project`,
isBareRepo: true,
}, &RepoPaths{
worktreePath: "/path/to/repo",
worktreeGitDirPath: "/path/to/bare_repo/bare.git",
repoPath: "/path/to/bare_repo",
repoGitDirPath: "/path/to/bare_repo/bare.git",
repoName: "bare_repo",
worktreePath: "",
worktreeGitDirPath: "/path/to/project/bare.git",
repoPath: "/path/to/project",
repoGitDirPath: "/path/to/project/bare.git",
repoName: "project",
isBareRepo: true,
}),
Err: nil,
},
{
// Standing in the .git dir of an ordinary repo: git refuses to name a
// work tree, but the directory holding the .git is one, so we open the
// repo from there.
Name: "in a repo's .git dir",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git`, "/path/to/repo/.git")
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo`, "/path/to/repo")
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
"",
errors.New("fatal: this operation must be run in a work tree"))
runner.ExpectGitArgs(
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
strings.Join([]string{gitDir, gitDir}, "\n"),
nil)
// asking again from the directory holding the .git
runner.ExpectGitArgs(
append(append([]string{"-C", worktree}, getRevParseArgs()...), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
strings.Join([]string{worktree, gitDir, gitDir}, "\n"),
nil)
},
Path: "/path/to/repo/.git",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
worktreePath: `C:\path\to\repo`,
worktreeGitDirPath: `C:\path\to\repo\.git`,
repoPath: `C:\path\to\repo`,
repoGitDirPath: `C:\path\to\repo\.git`,
repoName: `repo`,
isBareRepo: false,
}, &RepoPaths{
worktreePath: "/path/to/repo",
worktreeGitDirPath: "/path/to/repo/.git",
repoPath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
repoName: "repo",
isBareRepo: false,
}),
Err: nil,
},
{
// A repo whose work tree lives somewhere else entirely, as set up by
// core.worktree or by --work-tree. We're in the main worktree, but the
// git dir is not inside it.
Name: "repo with a separate work tree",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{
// --show-toplevel
`C:\path\to\worktree`,
// --git-dir
`C:\path\to\repo\.git`,
// --git-common-dir
`C:\path\to\repo\.git`,
// --show-superproject-working-tree
}, []string{
// --show-toplevel
"/path/to/worktree",
// --git-dir
"/path/to/repo/.git",
// --git-common-dir
"/path/to/repo/.git",
// --show-superproject-working-tree
})
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
strings.Join(mockOutput, "\n"),
nil)
// asking git to find the repo from the work tree gets us nowhere,
// because there is no .git there
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\worktree`, "/path/to/worktree")
runner.ExpectGitArgs(
append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...),
"",
errors.New("fatal: not a git repository (or any of the parent directories): .git"))
},
Path: "/path/to/repo",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
worktreePath: `C:\path\to\worktree`,
worktreeGitDirPath: `C:\path\to\repo\.git`,
repoPath: `C:\path\to\worktree`,
repoGitDirPath: `C:\path\to\repo\.git`,
repoName: `worktree`,
isBareRepo: false,
gitLocationEnvVars: []string{`GIT_DIR=C:\path\to\repo\.git`, `GIT_WORK_TREE=C:\path\to\worktree`},
}, &RepoPaths{
worktreePath: "/path/to/worktree",
worktreeGitDirPath: "/path/to/repo/.git",
repoPath: "/path/to/worktree",
repoGitDirPath: "/path/to/repo/.git",
repoName: "worktree",
isBareRepo: false,
gitLocationEnvVars: []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"},
}),
Err: nil,
},
{
Name: "submodule",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
@@ -133,8 +223,6 @@ func TestGetRepoPaths(t *testing.T) {
`C:\path\to\repo\.git\modules\submodule1`,
// --git-common-dir
`C:\path\to\repo\.git\modules\submodule1`,
// --is-bare-repository
`false`,
// --show-superproject-working-tree
`C:\path\to\repo`,
}, []string{
@@ -144,15 +232,22 @@ func TestGetRepoPaths(t *testing.T) {
"/path/to/repo/.git/modules/submodule1",
// --git-common-dir
"/path/to/repo/.git/modules/submodule1",
// --is-bare-repository
"false",
// --show-superproject-working-tree
"/path/to/repo",
})
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
strings.Join(mockOutput, "\n"),
nil)
// git finds the submodule's git dir from its work tree, via the
// .git file there
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\submodule1`, "/path/to/repo/submodule1")
gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git\modules\submodule1`, "/path/to/repo/.git/modules/submodule1")
runner.ExpectGitArgs(
append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...),
gitDir,
nil)
},
Path: "/path/to/repo/submodule1",
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
@@ -176,7 +271,12 @@ func TestGetRepoPaths(t *testing.T) {
Name: "git rev-parse returns an error",
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
runner.ExpectGitArgs(
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
"",
errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git"))
// we're not in a repo at all, so asking about a bare one fails too
runner.ExpectGitArgs(
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
"",
errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git"))
},
@@ -184,7 +284,7 @@ func TestGetRepoPaths(t *testing.T) {
Expected: nil,
Err: func(getRevParseArgs argFn) error {
args := strings.Join(getRevParseArgs(), " ")
return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --is-bare-repository --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args)
return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args)
},
},
}
+3 -10
View File
@@ -80,21 +80,14 @@ func (self *StashCommands) Hash(index int) (string, error) {
return strings.Trim(hash, "\r\n"), err
}
func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj {
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig()
func (self *StashCommands) ShowStashEntryCmdObj(index int, ignoreExternalDiff bool) *oscommands.CmdObj {
// "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason
cmdArgs := NewGitCmd("stash").Arg("show").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("-p").
Arg("--stat").
Arg("-u").
ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd).
ArgIfElse(extDiffCmd != "" || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())).
Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)).
ArgIf(self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(fmt.Sprintf("refs/stash@{%d}", index)).
Dir(self.repoPaths.worktreePath).
ToArgv()
+13 -13
View File
@@ -103,7 +103,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
contextSize uint64
similarityThreshold int
ignoreWhitespace bool
pagerConfig *config.PagingConfig
diffRendererConfig *config.DiffRendererConfig
expected []string
}
@@ -114,7 +114,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
ignoreWhitespace: false,
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=3", "--find-renames=50%", "refs/stash@{5}"},
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
},
{
testName: "Show diff with custom context size",
@@ -122,7 +122,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
contextSize: 77,
similarityThreshold: 50,
ignoreWhitespace: false,
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=77", "--find-renames=50%", "refs/stash@{5}"},
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=77", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
},
{
testName: "Show diff with custom similarity threshold",
@@ -130,7 +130,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
contextSize: 3,
similarityThreshold: 33,
ignoreWhitespace: false,
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=3", "--find-renames=33%", "refs/stash@{5}"},
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=3", "--find-renames=33%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
},
{
testName: "Show diff with external diff command",
@@ -138,8 +138,8 @@ func TestStashStashEntryCmdObj(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
ignoreWhitespace: false,
pagerConfig: &config.PagingConfig{ExternalDiffCommand: "difft --color=always"},
expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "-p", "--stat", "-u", "--ext-diff", "--color=always", "--unified=3", "--find-renames=50%", "refs/stash@{5}"},
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
},
{
testName: "Show diff using git's external diff config",
@@ -147,16 +147,16 @@ func TestStashStashEntryCmdObj(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
ignoreWhitespace: false,
pagerConfig: &config.PagingConfig{UseExternalDiffGitConfig: true},
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--ext-diff", "--color=always", "--unified=3", "--find-renames=50%", "refs/stash@{5}"},
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff"},
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
},
{
testName: "Default case",
testName: "Ignore whitespace",
index: 5,
contextSize: 3,
similarityThreshold: 50,
ignoreWhitespace: true,
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=3", "--ignore-all-space", "--find-renames=50%", "refs/stash@{5}"},
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
},
}
@@ -166,15 +166,15 @@ func TestStashStashEntryCmdObj(t *testing.T) {
userConfig.Git.IgnoreWhitespaceInDiffView = s.ignoreWhitespace
userConfig.Git.DiffContextSize = s.contextSize
userConfig.Git.RenameSimilarityThreshold = s.similarityThreshold
if s.pagerConfig != nil {
userConfig.Git.Pagers = []config.PagingConfig{*s.pagerConfig}
if s.diffRendererConfig != nil {
userConfig.Git.DiffRenderers = []config.DiffRendererConfig{*s.diffRendererConfig}
}
repoPaths := RepoPaths{
worktreePath: "/path/to/worktree",
}
instance := buildStashCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
cmdStr := instance.ShowStashEntryCmdObj(s.index).Args()
cmdStr := instance.ShowStashEntryCmdObj(s.index, false).Args()
assert.Equal(t, s.expected, cmdStr)
})
}
+44 -51
View File
@@ -28,10 +28,15 @@ func NewSubmoduleCommands(gitCommon *GitCommon) *SubmoduleCommands {
}
func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig) ([]*models.SubmoduleConfig, error) {
gitModulesPath := ".gitmodules"
// Resolve the path against the repo this commands object was created for
// rather than the process working directory, so that a read from a
// still-running refresh keeps addressing that repo after the user
// switched to another one.
dir := self.repoPaths.WorktreePath()
if parentModule != nil {
gitModulesPath = filepath.Join(parentModule.FullPath(), gitModulesPath)
dir = filepath.Join(dir, parentModule.FullPath())
}
gitModulesPath := filepath.Join(dir, ".gitmodules")
file, err := os.Open(gitModulesPath)
if err != nil {
if os.IsNotExist(err) {
@@ -152,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
Config("log.showsignature=false").
ToArgv()
summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
return strings.TrimSpace(summary), err
}
@@ -162,7 +167,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
// caller then stages the submodule to record the resolution.
func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error {
cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv()
return self.cmd.New(cmdArgs).Run()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
}
// ConflictSideLog returns a oneline log, run inside the submodule, of the commits
@@ -174,13 +179,13 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi
Arg("--oneline", "--color=always", otherSide+".."+side).
ToArgv()
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
}
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
// if the path does not exist then it hasn't yet been initialized so we'll swallow the error
// because the intention here is to have no dirty worktree state
if _, err := os.Stat(submodule.Path); os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(self.repoPaths.WorktreePath(), submodule.FullPath())); os.IsNotExist(err) {
self.Log.Infof("submodule path %s does not exist, returning", submodule.FullPath())
return nil
}
@@ -190,20 +195,15 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
Arg("--include-untracked").
ToArgv()
return self.cmd.New(cmdArgs).Run()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
parentDir := ""
if submodule.ParentModule != nil {
parentDir = submodule.ParentModule.FullPath()
}
cmdArgs := NewGitCmd("submodule").
Arg("update", "--init", "--force", "--", submodule.Path).
DirIf(parentDir != "", parentDir).
ToArgv()
return self.cmd.New(cmdArgs).Run()
return self.runInParentModule(submodule, self.cmd.New(cmdArgs))
}
func (self *SubmoduleCommands) UpdateAll() error {
@@ -213,51 +213,58 @@ func (self *SubmoduleCommands) UpdateAll() error {
return self.cmd.New(cmdArgs).Run()
}
// runInParentModule runs the given command in the submodule's parent module's
// directory when the submodule is nested: its path arguments (and the
// .gitmodules file the config commands touch) are relative to the parent
// module. The directory is set on the command itself rather than by
// temporarily chdir-ing the process there, which would leak the parent
// module's directory into whatever other commands run concurrently (e.g. a
// background refresh's).
//
// That directory is relative, so it resolves against the process working
// directory rather than against the repo directory the command builder
// otherwise pins commands to. Only foreground commands the user issued end up
// here, and lazygit won't switch repos while one of those is in flight, so the
// two are the same directory; don't call this from background work, where they
// need not be.
func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error {
if submodule.ParentModule != nil {
forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
}
return cmdObj.Run()
}
func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error {
// based on https://gist.github.com/myusuf3/7f645819ded92bda6677
if submodule.ParentModule != nil {
wd, err := os.Getwd()
if err != nil {
return err
}
err = os.Chdir(submodule.ParentModule.FullPath())
if err != nil {
return err
}
defer func() { _ = os.Chdir(wd) }()
}
if err := self.cmd.New(
if err := self.runInParentModule(submodule, self.cmd.New(
NewGitCmd("submodule").
Arg("deinit", "--force", "--", submodule.Path).ToArgv(),
).Run(); err != nil {
)); err != nil {
if !strings.Contains(err.Error(), "did not match any file(s) known to git") {
return err
}
if err := self.cmd.New(
if err := self.runInParentModule(submodule, self.cmd.New(
NewGitCmd("config").
Arg("--file", ".gitmodules", "--remove-section", "submodule."+submodule.Path).
ToArgv(),
).Run(); err != nil {
)); err != nil {
return err
}
if err := self.cmd.New(
if err := self.runInParentModule(submodule, self.cmd.New(
NewGitCmd("config").
Arg("--remove-section", "submodule."+submodule.Path).
ToArgv(),
).Run(); err != nil {
)); err != nil {
return err
}
}
if err := self.cmd.New(
if err := self.runInParentModule(submodule, self.cmd.New(
NewGitCmd("rm").Arg("--force", "-r", submodule.Path).ToArgv(),
).Run(); err != nil {
)); err != nil {
// if the directory isn't there then that's fine
self.Log.Error(err)
}
@@ -282,20 +289,6 @@ func (self *SubmoduleCommands) Add(name string, path string, url string) error {
}
func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newUrl string) error {
if submodule.ParentModule != nil {
wd, err := os.Getwd()
if err != nil {
return err
}
err = os.Chdir(submodule.ParentModule.FullPath())
if err != nil {
return err
}
defer func() { _ = os.Chdir(wd) }()
}
setUrlCmdStr := NewGitCmd("config").
Arg(
"--file", ".gitmodules", "submodule."+submodule.Name+".url", newUrl,
@@ -303,14 +296,14 @@ func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newU
ToArgv()
// the set-url command is only for later git versions so we're doing it manually here
if err := self.cmd.New(setUrlCmdStr).Run(); err != nil {
if err := self.runInParentModule(submodule, self.cmd.New(setUrlCmdStr)); err != nil {
return err
}
syncCmdStr := NewGitCmd("submodule").Arg("sync", "--", submodule.Path).
ToArgv()
if err := self.cmd.New(syncCmdStr).Run(); err != nil {
if err := self.runInParentModule(submodule, self.cmd.New(syncCmdStr)); err != nil {
return err
}
@@ -1,10 +1,13 @@
package git_commands
import (
"strings"
"testing"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
@@ -80,6 +83,27 @@ func TestSubmoduleCheckoutConflictCommit(t *testing.T) {
runner.CheckForMissingCalls()
}
// A command that runs inside a submodule mustn't inherit the GIT_DIR and
// GIT_WORK_TREE that say where the superproject is; git would answer it from
// there instead, and the answer would look perfectly plausible.
func TestSubmoduleCommandDoesntUseOurGitLocation(t *testing.T) {
t.Setenv(env.GitDirEnvVar, "/path/to/repo/.git")
t.Setenv(env.GitWorkTreeEnvVar, "/path/to/repo")
runner := oscommands.NewFakeRunner(t).
ExpectFunc("has neither GIT_DIR nor GIT_WORK_TREE", func(cmdObj *oscommands.CmdObj) bool {
return lo.NoneBy(cmdObj.GetEnvVars(), func(envVar string) bool {
return strings.HasPrefix(envVar, env.GitDirEnvVar+"=") ||
strings.HasPrefix(envVar, env.GitWorkTreeEnvVar+"=")
})
}, "bbbbbbb the subject\n", nil)
instance := buildSubmoduleCommands(commonDeps{runner: runner})
_, err := instance.GetCommitSummary("mysub", "bbbbbbb")
assert.NoError(t, err)
runner.CheckForMissingCalls()
}
func TestSubmoduleConflictSideLog(t *testing.T) {
runner := oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil)
+3 -3
View File
@@ -43,7 +43,7 @@ func (self *TagCommands) HasTag(tagName string) bool {
Arg("refs/tags/" + tagName).
ToArgv()
return self.cmd.New(cmdArgs).Run() == nil
return self.cmd.New(cmdArgs).DontLog().Run() == nil
}
func (self *TagCommands) LocalDelete(tagName string) error {
@@ -74,7 +74,7 @@ func (self *TagCommands) ShowAnnotationInfo(tagName string) (string, error) {
Arg("refs/tags/" + tagName).
ToArgv()
return self.cmd.New(cmdArgs).RunWithOutput()
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
}
func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) {
@@ -83,6 +83,6 @@ func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) {
Arg("refs/tags/" + tagName).
ToArgv()
output, err := self.cmd.New(cmdArgs).RunWithOutput()
output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
return strings.TrimSpace(output) == "tag", err
}
+11 -26
View File
@@ -385,25 +385,24 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
// WorktreeFileDiff returns the diff of a file
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
// for now we assume an error means the file was deleted
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, false, nil).RunWithOutput()
return s
}
// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
// the node's path (used to diff only filtered/visible files within a directory).
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
colorArg := self.pagerConfig.GetColorArg()
// ignoreExternalDiff forces git's own (coloured) diff regardless of a configured
// external diff command, for the focused main view's raw-diff fallback (see
// StagingHelper.DiffMainViewShouldRenderRaw); unlike plain it keeps the colour.
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, ignoreExternalDiff bool, pathOverrides []string) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
}
contextSize := self.UserConfig().Git.DiffContextSize
prevPath := node.GetPreviousPath()
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
useExtDiff := extDiffCmd != "" && !plain
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain
paths := pathOverrides
if len(paths) == 0 {
@@ -411,13 +410,9 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
}
cmdArgs := NewGitCmd("diff").
ConfigIf(useExtDiff, "diff.external="+extDiffCmd).
ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff).
Arg("--submodule").
Arg(fmt.Sprintf("--unified=%d", contextSize)).
Arg(fmt.Sprintf("--color=%s", colorArg)).
ArgIf(!plain && self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
ArgIf(cached, "--cached").
ArgIf(noIndex, "--no-index").
Arg("--").
@@ -439,33 +434,23 @@ func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bo
if previousPath != "" {
fileNames = append(fileNames, previousPath)
}
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput()
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain, false).RunWithOutput()
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
contextSize := self.UserConfig().Git.DiffContextSize
colorArg := self.pagerConfig.GetColorArg()
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool, ignoreExternalDiff bool) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
}
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
useExtDiff := extDiffCmd != "" && !plain
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain
cmdArgs := NewGitCmd("diff").
Config("diff.noprefix=false").
ConfigIf(useExtDiff, "diff.external="+extDiffCmd).
ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff).
Arg("--submodule").
Arg(fmt.Sprintf("--unified=%d", contextSize)).
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
Arg(fmt.Sprintf("--color=%s", colorArg)).
Arg(from).
Arg(to).
ArgIf(reverse, "-R").
ArgIf(!plain && self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg("--").
Arg(fileNames...).
Dir(self.repoPaths.worktreePath).
+11 -11
View File
@@ -221,7 +221,7 @@ func TestWorkingTreeDiff(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "cached",
@@ -236,7 +236,7 @@ func TestWorkingTreeDiff(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--cached", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--cached", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "plain",
@@ -251,7 +251,7 @@ func TestWorkingTreeDiff(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=never", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=never", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "File not tracked and file has no staged changes",
@@ -266,7 +266,7 @@ func TestWorkingTreeDiff(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--no-index", "--", "/dev/null", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--no-index", "--", "/dev/null", "test.txt"}, expectedResult, nil),
},
{
testName: "Default case (ignore whitespace)",
@@ -281,7 +281,7 @@ func TestWorkingTreeDiff(t *testing.T) {
contextSize: 3,
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--ignore-all-space", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "Show diff with custom context size",
@@ -296,7 +296,7 @@ func TestWorkingTreeDiff(t *testing.T) {
contextSize: 17,
similarityThreshold: 50,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=17", "--color=always", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=17", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "Show diff with custom similarity threshold",
@@ -311,7 +311,7 @@ func TestWorkingTreeDiff(t *testing.T) {
contextSize: 3,
similarityThreshold: 33,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=33%", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=33%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil),
},
}
@@ -360,7 +360,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "Show diff with custom context size",
@@ -372,7 +372,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
ignoreWhitespace: false,
contextSize: 123,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=123", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=123", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "Default case (ignore whitespace)",
@@ -384,7 +384,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
ignoreWhitespace: true,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--ignore-all-space", "--", "test.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
},
{
testName: "Renamed file passes both paths so the rename is detected",
@@ -397,7 +397,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "new.txt", "old.txt"}, expectedResult, nil),
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "new.txt", "old.txt"}, expectedResult, nil),
},
}
+1 -1
View File
@@ -51,7 +51,7 @@ func (self *WorktreeCommands) Delete(worktreePath string, force bool) error {
func (self *WorktreeCommands) Detach(worktreePath string) error {
cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv()
return self.cmd.New(cmdArgs).Run()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) {
+21 -11
View File
@@ -22,9 +22,6 @@ func NewWorktreeLoader(gitCommon *GitCommon) *WorktreeLoader {
}
func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
currentRepoPath := self.repoPaths.RepoPath()
worktreePath := self.repoPaths.WorktreePath()
cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain").ToArgv()
worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
if err != nil {
@@ -54,17 +51,13 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
if strings.HasPrefix(splitLine, "worktree ") {
path := strings.SplitN(splitLine, " ", 2)[1]
isMain := path == currentRepoPath
isCurrent := path == worktreePath
isPathMissing := self.pathExists(path)
current = &models.Worktree{
IsMain: isMain,
IsCurrent: isCurrent,
IsPathMissing: isPathMissing,
IsPathMissing: self.pathExists(path),
Path: path,
// we defer populating GitDir until a loop below so that
// we can parallelize the calls to git rev-parse
// we can parallelize the calls to git rev-parse, and
// IsMain/IsCurrent because they are derived from GitDir
GitDir: "",
}
} else if strings.HasPrefix(splitLine, "HEAD ") {
@@ -84,7 +77,7 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
if worktree.IsPathMissing {
return
}
gitDir, err := callGitRevParseWithDir(self.cmd, worktree.Path, "--absolute-git-dir")
gitDir, err := callGitRevParseInOtherRepo(self.cmd, worktree.Path, "--absolute-git-dir")
if err != nil {
self.Log.Warnf("Could not find git dir for worktree %s: %v", worktree.Path, err)
return
@@ -95,6 +88,23 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
}
wg.Wait()
// Identify the current and the main worktree by their git dir rather than by
// their path: `git worktree list` reports the main worktree as the common
// git dir with a trailing "/.git" removed, which is the working tree only
// when the git dir sits inside it. In a submodule, a bare repo or a repo
// using core.worktree it doesn't, and comparing paths then matches nothing.
// A worktree whose directory is gone has no git dir to compare, so there we
// have nothing better than its path.
for _, worktree := range worktrees {
if worktree.GitDir != "" {
worktree.IsCurrent = worktree.GitDir == self.repoPaths.WorktreeGitDirPath()
worktree.IsMain = worktree.GitDir == self.repoPaths.RepoGitDirPath()
} else {
worktree.IsCurrent = worktree.Path == self.repoPaths.WorktreePath()
worktree.IsMain = worktree.Path == self.repoPaths.RepoPath()
}
}
names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string {
return worktree.Path
}))
@@ -23,8 +23,10 @@ func TestGetWorktrees(t *testing.T) {
{
testName: "Single worktree (main)",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@@ -55,8 +57,10 @@ branch refs/heads/mybranch
{
testName: "Multiple worktrees (main + linked)",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@@ -106,8 +110,10 @@ branch refs/heads/mybranch-worktree
{
testName: "Worktree missing path",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@@ -136,8 +142,10 @@ branch refs/heads/missingbranch
{
testName: "In linked worktree",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo-worktree",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo-worktree",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git/worktrees/repo-worktree",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@@ -184,11 +192,51 @@ branch refs/heads/mybranch-worktree
},
expectedErr: "",
},
{
testName: "In a submodule",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo/mysubmodule",
worktreePath: "/path/to/repo/mysubmodule",
repoGitDirPath: "/path/to/repo/.git/modules/mysubmodule",
worktreeGitDirPath: "/path/to/repo/.git/modules/mysubmodule",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
// A submodule's git dir doesn't live inside its working tree, and
// `git worktree list` reports the git dir rather than the working
// tree it belongs to.
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
`worktree /path/to/repo/.git/modules/mysubmodule
HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d
branch refs/heads/mybranch
`,
nil)
gitArgs := append(append([]string{"-C", "/path/to/repo/.git/modules/mysubmodule"}, getRevParseArgs()...), "--absolute-git-dir")
runner.ExpectGitArgs(gitArgs, "/path/to/repo/.git/modules/mysubmodule", nil)
_ = fs.MkdirAll("/path/to/repo/.git/modules/mysubmodule", 0o755)
},
expectedWorktrees: []*models.Worktree{
{
IsMain: true,
IsCurrent: true,
Path: "/path/to/repo/.git/modules/mysubmodule",
IsPathMissing: false,
GitDir: "/path/to/repo/.git/modules/mysubmodule",
Branch: "mybranch",
Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d",
Name: "mysubmodule",
},
},
expectedErr: "",
},
{
testName: "Detached HEAD worktree",
repoPaths: &RepoPaths{
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoPath: "/path/to/repo",
worktreePath: "/path/to/repo",
repoGitDirPath: "/path/to/repo/.git",
worktreeGitDirPath: "/path/to/repo/.git",
},
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
@@ -16,11 +16,19 @@ type IGitConfig interface {
// this is for when you want to pass 'mykey' and check if the result is truthy
GetBool(string) bool
// SetDir pins the config commands to the given repo directory, so that
// they keep reading that repo's local config even if the process working
// directory changes later (i.e. the user switches repos while this
// instance is still in use by in-flight work). Called once, before the
// first read.
SetDir(string)
DropCache()
}
type CachedGitConfig struct {
cache map[string]string
dir string
runGitConfigCmd func(*exec.Cmd) (string, error)
log *logrus.Entry
mutex sync.Mutex
@@ -39,6 +47,13 @@ func NewCachedGitConfig(runGitConfigCmd func(*exec.Cmd) (string, error), log *lo
}
}
func (self *CachedGitConfig) SetDir(dir string) {
self.mutex.Lock()
defer self.mutex.Unlock()
self.dir = dir
}
func (self *CachedGitConfig) Get(key string) string {
self.mutex.Lock()
defer self.mutex.Unlock()
@@ -69,6 +84,7 @@ func (self *CachedGitConfig) GetGeneral(args string) string {
func (self *CachedGitConfig) getGeneralAux(args string) string {
cmd := getGitConfigGeneralCmd(args)
cmd.Dir = self.dir
value, err := self.runGitConfigCmd(cmd)
if err != nil {
self.log.Debugf("Error getting git config value for args: %s. Error: %v", args, err.Error())
@@ -79,6 +95,7 @@ func (self *CachedGitConfig) getGeneralAux(args string) string {
func (self *CachedGitConfig) getAux(key string) string {
cmd := getGitConfigCmd(key)
cmd.Dir = self.dir
value, err := self.runGitConfigCmd(cmd)
if err != nil {
self.log.Debugf("Error getting git config value for key: %s. Error: %v", key, err.Error())
@@ -116,3 +116,20 @@ func TestGet(t *testing.T) {
assert.Equal(t, "blah", result)
assert.Equal(t, 1, count)
}
// The config commands run in the directory set by SetDir rather than in the
// process's current directory: lazygit chdirs when switching repos, and config
// reads issued for the previous repo after that must keep addressing the repo
// they were created for.
func TestSetDirPinsCommandsToDirectory(t *testing.T) {
real := NewCachedGitConfig(
func(cmd *exec.Cmd) (string, error) {
assert.Equal(t, "/path/to/repo", cmd.Dir)
return "blah", nil
},
utils.NewDummyLog(),
)
real.SetDir("/path/to/repo")
real.Get("commit.gpgsign")
real.GetGeneral("--local --get-regexp foo")
}
@@ -28,5 +28,8 @@ func (self *FakeGitConfig) GetBool(key string) bool {
return isTruthy(self.Get(key))
}
func (self *FakeGitConfig) SetDir(dir string) {
}
func (self *FakeGitConfig) DropCache() {
}
+8 -4
View File
@@ -18,10 +18,14 @@ type File struct {
Deleted bool
HasMergeConflicts bool
HasInlineMergeConflicts bool
DisplayString string
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
LinesDeleted int
LinesAdded int
// How long the conflict markers in this file are, taken from its
// conflict-marker-size gitattribute; 0 if it doesn't have that attribute. We
// only look this up for files that have inline merge conflicts.
ConflictMarkerSize int
DisplayString string
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
LinesDeleted int
LinesAdded int
// If true, this must be a worktree folder
IsWorktree bool
+1
View File
@@ -5,6 +5,7 @@ type GithubPullRequest struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"` // "MERGED", "OPEN", "CLOSED", "DRAFT"
ChecksState string `json:"checksState"`
Url string `json:"url"`
HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
}
+40 -30
View File
@@ -1,12 +1,12 @@
package oscommands
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/go-errors/errors"
"github.com/samber/lo"
@@ -228,37 +228,47 @@ func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
// keeping this here in case I adapt this code for some other purpose in the future
// cmds[len(cmds)-1].Stdout = os.Stdout
finalErrors := []string{}
wg := sync.WaitGroup{}
wg.Add(len(cmds))
for _, cmd := range cmds {
go utils.Safe(func() {
stderr, err := cmd.StderrPipe()
if err != nil {
c.Log.Error(err)
}
if err := cmd.Start(); err != nil {
c.Log.Error(err)
}
if b, err := io.ReadAll(stderr); err == nil {
if len(b) > 0 {
finalErrors = append(finalErrors, string(b))
}
}
if err := cmd.Wait(); err != nil {
c.Log.Error(err)
}
wg.Done()
})
stderrs := make([]bytes.Buffer, len(cmds))
for i := range cmds {
cmds[i].Stderr = &stderrs[i]
}
wg.Wait()
// Start every command before waiting for any of them: waiting for a command
// closes our end of the pipe that feeds the next one, and a command that
// hasn't been started by then would inherit a closed stdin.
started := 0
var startErr error
for _, cmd := range cmds {
if err := cmd.Start(); err != nil {
startErr = err
break
}
started++
}
finalErrors := []string{}
if startErr != nil {
c.Log.Error(startErr)
finalErrors = append(finalErrors, startErr.Error())
// Without the rest of the pipeline to drain them, the commands we did
// start could block forever writing to a full pipe.
for _, cmd := range cmds[:started] {
_ = cmd.Process.Kill()
}
}
for i, cmd := range cmds[:started] {
if err := cmd.Wait(); err != nil {
c.Log.Error(err)
}
if stderrs[i].Len() > 0 {
finalErrors = append(finalErrors, stderrs[i].String())
}
}
if len(finalErrors) > 0 {
return errors.New(strings.Join(finalErrors, "\n"))
+6
View File
@@ -32,3 +32,9 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) {
Wait: cmd.Wait,
}, nil
}
// TerminateLivePtys is a no-op on Unix: stopping a pty task signals the
// child (SIGTERM, plus SIGHUP to the foreground process group when the
// master closes), and the processes clean themselves up without lazygit
// having to wait for them.
func TerminateLivePtys() {}
+234 -12
View File
@@ -4,7 +4,9 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
"unsafe"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -12,7 +14,14 @@ import (
)
type winPty struct {
hpc windows.Handle
hpc windows.Handle
// job holds the child and every descendant it spawns; terminating it
// kills whatever is left of the process tree (see Close).
job windows.Handle
// conhost is a handle to the conhost.exe serving this pty, or 0 if it
// couldn't be identified. Held so that the teardown in Close can reap
// it on Windows builds whose conhost fails to run down on its own.
conhost windows.Handle
inWrite *os.File
outRead *os.File
@@ -36,7 +45,16 @@ func (p *winPty) Resize(cols, rows uint16) error {
// there is nothing left to resize.
return nil
}
return windows.ResizePseudoConsole(p.hpc, windows.Coord{X: int16(cols), Y: int16(rows)})
return windows.ResizePseudoConsole(p.hpc, clampPtySize(cols, rows))
}
// clampPtySize clamps a requested pty size to the minimum that ConPTY
// accepts: CreatePseudoConsole and ResizePseudoConsole reject zero
// dimensions with E_INVALIDARG, but callers legitimately request them — the
// pty is sized after the main view, which is zero-sized while hidden, e.g.
// in full-screen mode with a side panel focused.
func clampPtySize(cols, rows uint16) windows.Coord {
return windows.Coord{X: int16(max(cols, 1)), Y: int16(max(rows, 1))}
}
// closeHpc closes the pseudoconsole exactly once. Safe to call from multiple
@@ -55,6 +73,46 @@ func (p *winPty) closeHpc() {
windows.ClosePseudoConsole(p.hpc)
}
// How long Close waits for the conhost to run itself down after its clients
// are gone, before concluding that it never will (see Close) and reaping it.
const conhostExitTimeout = time.Second
var (
// ptyTeardowns counts the in-flight teardown goroutines spawned by
// Close; TerminateLivePtys waits for them when lazygit exits.
ptyTeardowns sync.WaitGroup
// ptyQuit is closed by TerminateLivePtys. In-flight teardowns skip the
// conhost rundown wait once it is closed: the conhost serves nothing
// once its clients are gone, and the exit must not stall for its sake.
ptyQuit = make(chan struct{})
ptyQuitOnce sync.Once
)
// TerminateLivePtys synchronously terminates the process trees and console
// hosts of all ptys whose teardown hasn't finished yet. Call it when lazygit
// is about to exit: the asynchronous teardowns in Close won't get to finish
// (the conhost rundown wait outlives the process), and while
// KILL_ON_JOB_CLOSE reaps the clients when the job handles are closed at
// process death, nothing would reap the conhosts on the Windows builds that
// need it (see Close). A long diff on screen keeps its git process running
// the whole time it is shown, so quitting with such a teardown in flight is
// the rule, not the exception.
func TerminateLivePtys() {
ptyQuitOnce.Do(func() { close(ptyQuit) })
done := make(chan struct{})
go utils.Safe(func() {
ptyTeardowns.Wait()
close(done)
})
select {
case <-done:
case <-time.After(2 * time.Second):
// Don't hold up the exit any longer; the job handles' rundown
// still covers the clients.
}
}
// Close tears the pty down without waiting for it: the teardown runs on a
// background goroutine and Close returns immediately.
//
@@ -62,11 +120,11 @@ func (p *winPty) closeHpc() {
// Windows 11 24H2 it waits for the console host to exit, and since closing
// only delivers CTRL_CLOSE_EVENT to the attached client without terminating
// it, a client that keeps running (git still computing an expensive diff, a
// pager waiting for input) keeps the host — and with it ClosePseudoConsole —
// alive arbitrarily long. Close is called while holding the global PtyMutex
// and while the task's onDone once is executing, where blocking wedges every
// subsequent task for the view (and with it the UI), so none of this may
// happen on the caller's thread.
// diff renderer waiting for input) keeps the host — and with it
// ClosePseudoConsole — alive arbitrarily long. Close is called while holding
// the global PtyMutex and while the task's onDone once is executing, where
// blocking wedges every subsequent task for the view (and with it the UI), so
// none of this may happen on the caller's thread.
//
// Within the teardown, the pipe ends must be closed before the
// pseudoconsole, and without holding p.mu: closing the pseudoconsole flushes
@@ -74,11 +132,65 @@ func (p *winPty) closeHpc() {
// nobody is reading anymore, so that flush can only complete once the pipe
// is broken. The background waiter's closeHpc may already be wedged in such
// a flush while holding p.mu; closing the pipes is what unblocks it.
//
// Closing the pseudoconsole delivers CTRL_CLOSE_EVENT only to the clients
// attached to it at that moment. A child that is stopped right after being
// spawned is still starting up and not attached yet, so the event misses it
// and it survives, running its command to completion as an orphan — and
// keeping its console host alive with it (#5879); the same holds for
// grandchildren spawned while the console is going down, and for clients
// that ignore the event (the Windows flavor of #5675). The job kill reaps
// all of those. There is no point in delaying it: the close event is not a
// graceful signal worth waiting on — git and the common diff tools leave it
// to the default handler, which calls ExitProcess at whatever instruction
// the process happens to execute — so clients that got the event are
// already dying. Killing at an arbitrary point cannot leak a stale
// index.lock, because pty-rendered commands don't take that lock (see
// withPtyGitConfig in pkg/gui/pty.go).
//
// The pseudoconsole close gets its own goroutine because the kill must not
// wait for it: on builds where ClosePseudoConsole blocks until the console
// host exits (pre-24H2), the host keeps running as long as a surviving
// client does, and that client only goes away through the job kill —
// sequencing the kill after a blocking close would thus deadlock in
// exactly the case the kill exists for.
//
// After the kill, the conhost serving the pty is reaped as well if it
// doesn't exit by itself: a healthy conhost runs down once the reference
// handle is closed and its clients are gone, but conhost builds before
// Windows 11 24H2 fail to complete the rundown when a client attached
// after the close event was delivered and was then killed — the fate of
// exactly the clients the job kill is for — and such a conhost sits
// around forever, serving nothing (#5879). The reap is inert on healthy
// builds: the wait succeeds and only the handle is closed.
//
// When lazygit is quitting, the conhost rundown wait is skipped; see
// TerminateLivePtys.
func (p *winPty) Close() error {
ptyTeardowns.Add(1)
go utils.Safe(func() {
defer ptyTeardowns.Done()
p.inWrite.Close()
p.outRead.Close()
p.closeHpc()
go utils.Safe(p.closeHpc)
_ = windows.TerminateJobObject(p.job, 1)
_ = windows.CloseHandle(p.job)
if p.conhost != 0 {
timeout := conhostExitTimeout
select {
case <-ptyQuit:
timeout = 0
default:
}
event, err := windows.WaitForSingleObject(p.conhost, uint32(timeout/time.Millisecond))
if err != nil || event != windows.WAIT_OBJECT_0 {
_ = windows.TerminateProcess(p.conhost, 1)
}
_ = windows.CloseHandle(p.conhost)
}
})
return nil
}
@@ -114,6 +226,52 @@ func startWaiter(proc *os.Process, p *winPty) func() error {
}
}
// conhostScanMu serializes CreatePseudoConsole and the child-process scans
// around it, so that two concurrently starting ptys can't make each other's
// "which conhost is new" diff ambiguous.
var conhostScanMu sync.Mutex
// conhostChildren returns the pids of all conhost.exe processes that are
// direct children of this process. Errors just yield a smaller (possibly
// empty) set; the caller treats identification as best-effort.
func conhostChildren() map[uint32]bool {
pids := map[uint32]bool{}
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return pids
}
defer func() { _ = windows.CloseHandle(snap) }()
me := uint32(os.Getpid())
var pe windows.ProcessEntry32
pe.Size = uint32(unsafe.Sizeof(pe))
for err := windows.Process32First(snap, &pe); err == nil; err = windows.Process32Next(snap, &pe) {
if pe.ParentProcessID == me && strings.EqualFold(windows.UTF16ToString(pe.ExeFile[:]), "conhost.exe") {
pids[pe.ProcessID] = true
}
}
return pids
}
// openNewConhostChild returns a handle to the single conhost child that
// appeared since the before scan, or 0 if there isn't exactly one candidate
// or it can't be opened.
func openNewConhostChild(before map[uint32]bool) windows.Handle {
var found []uint32
for pid := range conhostChildren() {
if !before[pid] {
found = append(found, pid)
}
}
if len(found) != 1 {
return 0
}
h, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, found[0])
if err != nil {
return 0
}
return h
}
func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
// Two pipes: one for the child's stdin (we never write to it, but ConPTY
// needs a handle), one for the child's stdout/stderr multiplexed through
@@ -139,9 +297,24 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
// CreatePseudoConsole dupes the handles it needs internally; we release
// our references to the child-side ends immediately after.
var hpc windows.Handle
size := windows.Coord{X: int16(cols), Y: int16(rows)}
if err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc); err != nil {
//
// It also spawns the conhost.exe serving the console session, as a
// direct child of this process. The teardown in Close needs a handle to
// that conhost (see there), but Windows offers no way to obtain one
// from the HPCON, so identify it by diffing our conhost children around
// the call. Open a real handle right away so that pid reuse can't later
// misdirect the teardown's reap. If identification fails, the handle
// stays 0 and the teardown skips the reap.
var hpc, conhost windows.Handle
size := clampPtySize(cols, rows)
conhostScanMu.Lock()
conhostsBefore := conhostChildren()
err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc)
if err == nil {
conhost = openNewConhostChild(conhostsBefore)
}
conhostScanMu.Unlock()
if err != nil {
_ = windows.CloseHandle(inRead)
_ = windows.CloseHandle(outWrite)
return StartedPty{}, fmt.Errorf("CreatePseudoConsole: %w", err)
@@ -151,9 +324,40 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
defer func() {
if err != nil {
windows.ClosePseudoConsole(hpc)
if conhost != 0 {
_ = windows.CloseHandle(conhost)
}
}
}()
// The child goes into a job object so that the teardown in Close can
// terminate the whole process tree. KILL_ON_JOB_CLOSE makes the OS do
// that when the last handle to the job is closed, which doubles as a
// safety net: if lazygit exits without running the teardown, the handle
// is closed for it and the tree is reaped.
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return StartedPty{}, fmt.Errorf("CreateJobObject: %w", err)
}
defer func() {
if err != nil {
// Kills the child on error paths where it was already assigned
// to the job; plain handle cleanup before that.
_ = windows.CloseHandle(job)
}
}()
limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
if _, err = windows.SetInformationJobObject(
job, windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits)),
); err != nil {
return StartedPty{}, fmt.Errorf("SetInformationJobObject: %w", err)
}
// Attach the pseudoconsole to the child via a process attribute list.
attrList, err := windows.NewProcThreadAttributeList(1)
if err != nil {
@@ -212,7 +416,7 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
nil, // process security
nil, // thread security
false,
windows.EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT,
windows.EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT|windows.CREATE_SUSPENDED,
envPtr,
dirPtr,
&si.StartupInfo,
@@ -221,6 +425,22 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
if err != nil {
return StartedPty{}, fmt.Errorf("CreateProcess: %w", err)
}
// The child was created suspended so that it can be assigned to the job
// before it runs its first instruction; that way every descendant it
// ever spawns is in the job from the start.
if err = windows.AssignProcessToJobObject(job, pi.Process); err != nil {
// Not in the job yet, so the deferred job-handle close can't reap it.
_ = windows.TerminateProcess(pi.Process, 1)
_ = windows.CloseHandle(pi.Thread)
_ = windows.CloseHandle(pi.Process)
return StartedPty{}, fmt.Errorf("AssignProcessToJobObject: %w", err)
}
if _, err = windows.ResumeThread(pi.Thread); err != nil {
_ = windows.CloseHandle(pi.Thread)
_ = windows.CloseHandle(pi.Process)
return StartedPty{}, fmt.Errorf("ResumeThread: %w", err)
}
_ = windows.CloseHandle(pi.Thread)
// Re-open the process by PID to get an *os.Process to wait on. Do this
@@ -236,6 +456,8 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
wp := &winPty{
hpc: hpc,
job: job,
conhost: conhost,
inWrite: os.NewFile(uintptr(inWrite), "conpty-in"),
outRead: os.NewFile(uintptr(outRead), "conpty-out"),
}
+105
View File
@@ -0,0 +1,105 @@
package oscommands
import (
"os/exec"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// The requested size can legitimately be zero: the pty inherits the main
// view's dimensions, and that view is zero-sized while hidden, e.g. in
// full-screen mode with a side panel focused.
func TestStartPtyWithZeroSize(t *testing.T) {
// The command deliberately produces no output: go test runs with
// redirected std handles, which CreateProcess duplicates into the child
// in place of handles to the attached pseudoconsole, so command output
// would bypass the pty and pollute the test log.
sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 0, 0)
assert.NoError(t, err)
if err == nil {
_ = sp.Wait()
_ = sp.Pty.Close()
}
}
// StartPty must identify the conhost.exe that CreatePseudoConsole spawned to
// serve the pty: the teardown in Close reaps it on Windows builds whose
// conhost fails to run down on its own, and a failed identification silently
// degrades to not reaping. If this fails, the child-scan in
// openNewConhostChild no longer matches how Windows hosts pseudoconsoles.
func TestStartPtyIdentifiesConhost(t *testing.T) {
sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 80, 24)
assert.NoError(t, err)
if err != nil {
return
}
assert.NotZero(t, sp.Pty.(*winPty).conhost)
_ = sp.Wait()
_ = sp.Pty.Close()
}
// TerminateLivePtys must reap a still-running pty synchronously: it runs
// when lazygit is about to exit, where the asynchronous teardown would not
// get to finish. Note that it switches the package's pty teardowns into
// quit mode for the remainder of the test binary's lifetime; that's fine
// for the other tests here, which must hold in either mode (quit mode only
// shortens the teardown's conhost rundown wait).
func TestTerminateLivePtysReapsRunningPty(t *testing.T) {
// The output redirect is there for the reason described in
// TestStartPtyWithZeroSize.
sp, err := StartPty(exec.Command("cmd", "/c", "ping -n 30 127.0.0.1 >nul"), 80, 24)
assert.NoError(t, err)
if err != nil {
return
}
_ = sp.Pty.Close()
TerminateLivePtys()
// The teardown has completed as part of TerminateLivePtys, so the child
// must be gone already; the timeout is generosity, not a grace period.
exited := make(chan struct{})
go func() {
_ = sp.Wait()
close(exited)
}()
select {
case <-exited:
case <-time.After(time.Second):
t.Fatal("child process was not terminated by TerminateLivePtys")
}
}
// Closing the pty must terminate the process tree it was running, even when
// it is closed so soon after starting that the child hasn't attached to the
// pseudoconsole yet: such a child misses the CTRL_CLOSE_EVENT that the close
// delivers to attached clients, and only the job-object kill reaps it.
// Without the kill, cmd and its ping child keep running for ~30 seconds and
// the Wait here times out.
func TestClosePtyTerminatesChildProcessTree(t *testing.T) {
// The output redirect is there for the reason described in
// TestStartPtyWithZeroSize.
sp, err := StartPty(exec.Command("cmd", "/c", "ping -n 30 127.0.0.1 >nul"), 80, 24)
assert.NoError(t, err)
if err != nil {
return
}
_ = sp.Pty.Close()
exited := make(chan struct{})
go func() {
_ = sp.Wait()
close(exited)
}()
select {
case <-exited:
case <-time.After(5 * time.Second):
t.Fatal("child process was not terminated by closing the pty")
}
}
+5
View File
@@ -16,6 +16,11 @@ type Hunk struct {
newStart int
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
headerContext string
// the lengths declared in the hunk header ('2' and '3' in the above example),
// kept so we can verify the parsed body actually matches the header (see
// Patch.IsWellFormed). Only populated by Parse.
declaredOldLength int
declaredNewLength int
// the body of the hunk, excluding the header line
bodyLines []*PatchLine
}
+26 -11
View File
@@ -7,7 +7,9 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
// Captures, in order: old start, old length (omitted when 1), new start, new
// length (omitted when 1), and the trailing context.
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
func Parse(patchStr string) *Patch {
// ignore trailing newline.
@@ -19,13 +21,15 @@ func Parse(patchStr string) *Patch {
var currentHunk *Hunk
for _, line := range lines {
if strings.HasPrefix(line, "@@") {
oldStart, newStart, headerContext := headerInfo(line)
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
currentHunk = &Hunk{
oldStart: oldStart,
newStart: newStart,
headerContext: headerContext,
bodyLines: []*PatchLine{},
oldStart: oldStart,
newStart: newStart,
declaredOldLength: oldLength,
declaredNewLength: newLength,
headerContext: headerContext,
bodyLines: []*PatchLine{},
}
hunks = append(hunks, currentHunk)
} else if currentHunk != nil {
@@ -41,14 +45,25 @@ func Parse(patchStr string) *Patch {
}
}
func headerInfo(header string) (int, int, string) {
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
match := hunkHeaderRegexp.FindStringSubmatch(header)
oldStart := utils.MustConvertToInt(match[1])
newStart := utils.MustConvertToInt(match[2])
headerContext := match[3]
oldStart = utils.MustConvertToInt(match[1])
oldLength = declaredLength(match[2])
newStart = utils.MustConvertToInt(match[3])
newLength = declaredLength(match[4])
headerContext = match[5]
return oldStart, newStart, headerContext
return oldStart, oldLength, newStart, newLength, headerContext
}
// declaredLength parses a hunk header length capture, which git omits when it
// is 1 (e.g. "@@ -0,0 +1 @@").
func declaredLength(match string) int {
if match == "" {
return 1
}
return utils.MustConvertToInt(match)
}
func newHunkLine(line string) *PatchLine {
+116
View File
@@ -79,6 +79,23 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
}
// IsWellFormed reports whether every hunk's body matches the lengths declared
// in its header. A faithful unified diff always satisfies this. A rendering that
// restructured the diff body does not — e.g. delta with line-number gutters
// shifts the +/- marker off the start of each line, so every body line reads as
// context and the computed lengths no longer match the header. This lets a
// parser tell a real unified diff from a mangled one and fall back rather than
// trust a mis-parse. Only meaningful for patches produced by Parse (the lengths
// are read from the header there).
func (self *Patch) IsWellFormed() bool {
for _, hunk := range self.hunks {
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
return false
}
}
return true
}
func (self *Patch) ContainsChanges() bool {
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
return hunk.containsChanges()
@@ -114,6 +131,105 @@ func (self *Patch) LineNumberOfLine(idx int) int {
return hunk.newStart + offset
}
// Takes a line index in the patch and returns the line number in the old file.
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
// the line's position in the old file (additions get the position they sit at).
// If the line is a header line, returns 1.
// If the line is a hunk header line, returns the first old-file line number in
// that hunk.
// If the line is out of range below, returns the last old-file line number in
// the last hunk.
func (self *Patch) OldLineNumberOfLine(idx int) int {
if idx < len(self.header) || len(self.hunks) == 0 {
return 1
}
hunkIdx := self.HunkContainingLine(idx)
// cursor out of range, return last file line number
if hunkIdx == -1 {
lastHunk := self.hunks[len(self.hunks)-1]
return lastHunk.oldStart + lastHunk.oldLength() - 1
}
hunk := self.hunks[hunkIdx]
hunkStartIdx := self.HunkStartIdx(hunkIdx)
idxInHunk := idx - hunkStartIdx
if idxInHunk == 0 {
return hunk.oldStart
}
lines := hunk.bodyLines[:idxInHunk-1]
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
return hunk.oldStart + offset
}
// Takes a line number in the new file and returns the line index in the patch.
// This is the opposite of LineNumberOfLine.
// If the line number is not contained in any of the hunks, it returns the
// closest position.
func (self *Patch) PatchLineForLineNumber(lineNumber int) int {
if len(self.hunks) == 0 {
return len(self.header)
}
for hunkIdx, hunk := range self.hunks {
if lineNumber <= hunk.newStart {
return self.HunkStartIdx(hunkIdx)
}
if lineNumber < hunk.newStart+hunk.newLength() {
lines := hunk.bodyLines
offset := lineNumber - hunk.newStart
for i, line := range lines {
if offset == 0 {
return self.HunkStartIdx(hunkIdx) + i + 1
}
if line.Kind == ADDITION || line.Kind == CONTEXT {
offset--
}
}
}
}
return self.LineCount() - 1
}
// Takes a line number in the old file and returns the line index in the patch.
// This is the old-file counterpart of PatchLineForLineNumber. It is what lets us
// land on the right patch line for a deletion: two consecutive deletions share a
// new-file line number, so only the old-file number tells them apart.
// If the line number is not contained in any of the hunks, it returns the
// closest position.
func (self *Patch) PatchLineForOldLineNumber(lineNumber int) int {
if len(self.hunks) == 0 {
return len(self.header)
}
for hunkIdx, hunk := range self.hunks {
if lineNumber <= hunk.oldStart {
return self.HunkStartIdx(hunkIdx)
}
if lineNumber < hunk.oldStart+hunk.oldLength() {
lines := hunk.bodyLines
offset := lineNumber - hunk.oldStart
for i, line := range lines {
if offset == 0 {
return self.HunkStartIdx(hunkIdx) + i + 1
}
if line.Kind == DELETION || line.Kind == CONTEXT {
offset--
}
}
}
}
return self.LineCount() - 1
}
// Returns hunk index containing the line at the given patch line index
func (self *Patch) HunkContainingLine(idx int) int {
for hunkIdx, hunk := range self.hunks {
+160 -1
View File
@@ -1,6 +1,7 @@
package patch
import (
"os"
"sort"
"strings"
@@ -60,12 +61,26 @@ type PatchBuilder struct {
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
loadFileDiff loadFileDiffFunc
// newTempDir creates a fresh temp dir for the current patch, into which the patch is
// materialized as two file trees so it can be rendered through any pager (see
// PatchCommands.WriteCustomPatchDiffTrees). The patch builder owns its lifetime: a dir
// is created on Start and removed on Reset. Nil in tests that don't render the patch.
newTempDir func() (string, error)
tempDir string
// generation is bumped on every change to the patch's contents, so a consumer that
// materializes the patch (the secondary pane's two file trees) can tell when it's stale
// and rebuild only then — covering every path that mutates the patch (the focused main
// view and the old explorer alike) without rebuilding on mere navigation.
generation int
}
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder {
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error)) *PatchBuilder {
return &PatchBuilder{
Log: log,
loadFileDiff: loadFileDiff,
newTempDir: newTempDir,
}
}
@@ -73,6 +88,16 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.generation++
p.removeTempDir()
if p.newTempDir != nil {
if dir, err := p.newTempDir(); err != nil {
p.Log.Error(err)
} else {
p.tempDir = dir
}
}
p.To = to
p.From = from
p.reverse = reverse
@@ -91,6 +116,37 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
return p.fileInfoMap
}
// TempDir is the directory the current patch is materialized into for rendering, or "" if
// none was created. See PatchCommands.WriteCustomPatchDiffTrees.
func (p *PatchBuilder) TempDir() string {
return p.tempDir
}
// Generation is bumped each time the patch's contents change; see the field comment.
func (p *PatchBuilder) Generation() int {
return p.generation
}
func (p *PatchBuilder) removeTempDir() {
if p.tempDir != "" {
_ = os.RemoveAll(p.tempDir)
p.tempDir = ""
}
}
// ActiveFilenames returns the files currently part of the patch (mode != UNSELECTED), in
// sorted order — the files to materialize when rendering the patch.
func (p *PatchBuilder) ActiveFilenames() []string {
filenames := make([]string, 0, len(p.fileInfoMap))
for filename, info := range p.fileInfoMap {
if info.mode != UNSELECTED {
filenames = append(filenames, filename)
}
}
sort.Strings(filenames)
return filenames
}
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
var patch strings.Builder
@@ -135,6 +191,7 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error
return err
}
p.generation++
p.addFileWhole(info)
return nil
@@ -146,6 +203,8 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
return err
}
p.generation++
p.removeFile(info)
return nil
@@ -182,6 +241,7 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices)
@@ -193,6 +253,7 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string,
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices)
if len(info.includedLineIndices) == 0 {
@@ -299,11 +360,109 @@ func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath strin
return info.includedLineIndices, nil
}
// LineIdentity identifies a change line (an addition or deletion) by its file line
// number and whether it's a deletion, independently of the line's index in the parsed
// patch. It is the identity the diff-line metadata resolves a rendered row to (see
// types.DiffLineInfo.PatchSelectLine), and lets the focused main view toggle patch
// membership and drive the inclusion gutter without dealing in patch-line indices —
// which differ between the raw diff and however a pager renders it.
type LineIdentity struct {
LineNumber int
IsDeletion bool
}
// changeLineIndexByIdentity scans a parsed diff and returns, for each change line, its
// index in the patch keyed by the line's identity. An addition is keyed by its new-file
// line number, a deletion by its old-file line number, so each change line has a
// distinct identity (two consecutive deletions share a new-file number but differ in
// the old-file one).
func changeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
byIdentity := map[LineIdentity]int{}
for idx, line := range parsed.Lines() {
switch {
case line.IsAddition():
byIdentity[LineIdentity{parsed.LineNumberOfLine(idx), false}] = idx
case line.IsDeletion():
byIdentity[LineIdentity{parsed.OldLineNumberOfLine(idx), true}] = idx
}
}
return byIdentity
}
// PatchLineIndicesForLines maps the given change-line identities to their indices in
// filename's parsed diff — the index form that AddFileLineRange / RemoveFileLineRange
// and GetFileIncLineIndices work in. Identities that don't correspond to a change line
// (e.g. a context line) are skipped. It is how the focused main view, which knows a
// selection only as metadata identities, drives patch building.
func (p *PatchBuilder) PatchLineIndicesForLines(filename string, lines []LineIdentity) ([]int, error) {
info, err := p.getFileInfo(filename, "")
if err != nil {
return nil, err
}
byIdentity := changeLineIndexByIdentity(Parse(info.diff))
indices := make([]int, 0, len(lines))
for _, line := range lines {
if idx, ok := byIdentity[line]; ok {
indices = append(indices, idx)
}
}
return indices, nil
}
// IncludedChangeLineIndices returns the patch-line indices of the change lines (additions
// and deletions) currently included in the patch for filename, in ascending order. These
// are exactly the change lines the aggregated patch renders for the file, in the same
// order, so the k-th change line shown in the custom-patch (secondary) view corresponds to
// the k-th index here. That correspondence lets the focused main view remove a selection
// from the patch by its ordinal among the shown change lines, sidestepping the line-number
// renumbering the aggregated patch applies (which makes matching by identity unreliable for
// additions). Empty when the file isn't part of the patch.
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
info, ok := p.fileInfoMap[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
lines := Parse(info.diff).Lines()
included := append([]int{}, info.includedLineIndices...)
sort.Ints(included)
result := make([]int, 0, len(included))
for _, idx := range included {
if idx >= 0 && idx < len(lines) && (lines[idx].IsAddition() || lines[idx].IsDeletion()) {
result = append(result, idx)
}
}
return result
}
// IncludedLineIdentities returns the identities of the change lines currently included
// in the patch for filename — the identity space the inclusion gutter matches rendered
// rows against. Empty when the file isn't part of the patch.
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
info, ok := p.fileInfoMap[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
includedIdx := make(map[int]bool, len(info.includedLineIndices))
for _, idx := range info.includedLineIndices {
includedIdx[idx] = true
}
var identities []LineIdentity
for identity, idx := range changeLineIndexByIdentity(Parse(info.diff)) {
if includedIdx[idx] {
identities = append(identities, identity)
}
}
return identities
}
// clears the patch
func (p *PatchBuilder) Reset() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.generation++
p.removeTempDir()
p.To = ""
p.fileInfoMap = map[string]*fileInfo{}
}
+53
View File
@@ -0,0 +1,53 @@
package patch
import (
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
// newTestPatchBuilder returns a PatchBuilder whose files all resolve to the given
// diff, started for a dummy commit.
func newTestPatchBuilder(diff string) *PatchBuilder {
pb := NewPatchBuilder(logrus.New().WithField("test", "test"),
func(from, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return diff, nil
},
nil)
pb.Start("from", "to", false, true)
return pb
}
// In simpleDiff the deletion "-orange" is patch line index 6 (old-file line 2) and the
// addition "+grape" is index 7 (new-file line 2).
func TestPatchLineIndicesForLines(t *testing.T) {
pb := newTestPatchBuilder(simpleDiff)
indices, err := pb.PatchLineIndicesForLines("filename", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
{LineNumber: 1, IsDeletion: false}, // " apple" — a context line, no change index
})
assert.NoError(t, err)
assert.Equal(t, []int{6, 7}, indices, "context-line identity is skipped; change lines map to their indices")
}
func TestIncludedLineIdentities(t *testing.T) {
pb := newTestPatchBuilder(simpleDiff)
// Nothing included yet.
assert.Empty(t, pb.IncludedLineIdentities("filename"))
// Include only the deletion: it comes back as its identity, the addition does not.
assert.NoError(t, pb.AddFileLineRange("filename", "", []int{6}))
assert.Equal(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}},
pb.IncludedLineIdentities("filename"))
// Including the addition too yields both identities (order-independent).
assert.NoError(t, pb.AddFileLineRange("filename", "", []int{7}))
assert.ElementsMatch(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
pb.IncludedLineIdentities("filename"))
}
+103
View File
@@ -120,6 +120,20 @@ index 9320895..6d79956 100644
lemon
`
// Two deletions with no line between them: they share a new-file line number
// (both sit at the same new-file position), so only their old-file line numbers
// distinguish them.
const consecutiveDeletions = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
-grape
-pear
lemon
`
const newFile = `diff --git a/newfile b/newfile
new file mode 100644
index 0000000..4e680cc
@@ -682,6 +696,95 @@ func TestLineNumberOfLine(t *testing.T) {
}
}
func TestOldLineNumberOfLine(t *testing.T) {
type scenario struct {
testName string
patchStr string
indexes []int
expecteds []int
}
scenarios := []scenario{
{
testName: "twoChangesInOneHunk",
patchStr: twoChangesInOneHunk,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5},
},
{
testName: "consecutiveDeletions",
patchStr: consecutiveDeletions,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
for i, idx := range s.indexes {
patch := Parse(s.patchStr)
result := patch.OldLineNumberOfLine(idx)
assert.Equal(t, s.expecteds[i], result)
}
})
}
}
func TestPatchLineForOldLineNumber(t *testing.T) {
type scenario struct {
testName string
patchStr string
lineNumbers []int
expecteds []int
}
scenarios := []scenario{
{
// The two consecutive deletions (grape, pear) sit at patch line
// indices 6 and 7; their old-file line numbers (2 and 3) keep them
// distinct even though their new-file line numbers are both 2.
testName: "consecutiveDeletions",
patchStr: consecutiveDeletions,
lineNumbers: []int{0, 1, 2, 3, 4, 5},
expecteds: []int{4, 4, 6, 7, 8, 8},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
for i, lineNumber := range s.lineNumbers {
patch := Parse(s.patchStr)
result := patch.PatchLineForOldLineNumber(lineNumber)
assert.Equal(t, s.expecteds[i], result)
}
})
}
}
func TestIsWellFormed(t *testing.T) {
assert.True(t, Parse(simpleDiff).IsWellFormed())
assert.True(t, Parse(twoHunks).IsWellFormed())
assert.True(t, Parse(consecutiveDeletions).IsWellFormed())
assert.True(t, Parse(newFile).IsWellFormed())
assert.True(t, Parse(deletedFile).IsWellFormed())
assert.True(t, Parse(addNewlineToEndOfFile).IsWellFormed())
// A diff whose body has been mangled so the +/- markers no longer start each
// line (as delta's line-number gutters do) reads as all-context, so the body
// lengths no longer match the header.
gutterMangled := `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
`
assert.False(t, Parse(gutterMangled).IsWellFormed())
}
func TestGetNextStageableLineIndex(t *testing.T) {
type scenario struct {
testName string
+143 -46
View File
@@ -21,17 +21,18 @@ import (
// AppConfig contains the base configuration fields required for lazygit.
type AppConfig struct {
debug bool `long:"debug" env:"DEBUG" default:"false"`
version string `long:"version" env:"VERSION" default:"unversioned"`
buildDate string `long:"build-date" env:"BUILD_DATE"`
name string `long:"name" env:"NAME" default:"lazygit"`
buildSource string `long:"build-source" env:"BUILD_SOURCE" default:""`
userConfig *UserConfig
globalUserConfigFiles []*ConfigFile
userConfigFiles []*ConfigFile
userConfigDir string
tempDir string
appState *AppState
debug bool `long:"debug" env:"DEBUG" default:"false"`
version string `long:"version" env:"VERSION" default:"unversioned"`
buildDate string `long:"build-date" env:"BUILD_DATE"`
name string `long:"name" env:"NAME" default:"lazygit"`
buildSource string `long:"build-source" env:"BUILD_SOURCE" default:""`
userConfig *UserConfig
globalUserConfigFiles []*ConfigFile
userConfigFiles []*ConfigFile
userConfigDir string
tempDir string
appState *AppState
githubPullRequestCache *githubPullRequestCache
}
type AppConfigurer interface {
@@ -51,6 +52,8 @@ type AppConfigurer interface {
GetAppState() *AppState
SaveAppState() error
GetCachedGithubPullRequests(repoPath string) ([]CachedPullRequest, error)
SaveCachedGithubPullRequests(repoPath string, pullRequests []CachedPullRequest) error
}
type ConfigFilePolicy int
@@ -107,19 +110,21 @@ func NewAppConfig(
if err != nil {
return nil, err
}
githubPullRequestCache := loadGithubPullRequestCache()
appConfig := &AppConfig{
name: name,
version: version,
buildDate: date,
debug: debuggingFlag,
buildSource: buildSource,
userConfig: userConfig,
globalUserConfigFiles: configFiles,
userConfigFiles: configFiles,
userConfigDir: configDir,
tempDir: tempDir,
appState: appState,
name: name,
version: version,
buildDate: date,
debug: debuggingFlag,
buildSource: buildSource,
userConfig: userConfig,
globalUserConfigFiles: configFiles,
userConfigFiles: configFiles,
userConfigDir: configDir,
tempDir: tempDir,
appState: appState,
githubPullRequestCache: githubPullRequestCache,
}
return appConfig, nil
@@ -288,6 +293,8 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
}{
{[]string{"gui", "skipUnstageLineWarning"}, "skipDiscardChangeWarning"},
{[]string{"keybinding", "universal", "executeCustomCommand"}, "executeShellCommand"},
{[]string{"keybinding", "universal", "cyclePagers"}, "cycleDiffRenderers"},
{[]string{"keybinding", "universal", "cyclePagersReverse"}, "cycleDiffRenderersReverse"},
{[]string{"gui", "windowSize"}, "screenMode"},
{[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"},
}
@@ -347,7 +354,12 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
}
err = migratePagers(&rootNode, changes)
err = migratePaging(&rootNode, changes)
if err != nil {
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
}
err = migratePagersToDiffRenderers(&rootNode, changes)
if err != nil {
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
}
@@ -512,7 +524,9 @@ func migrateAllBranchesLogCmd(rootNode *yaml.Node, changes *ChangesSet) error {
})
}
func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error {
// Migrate the single 'paging' node to an array of 'pagers'. This is not the final structure, we
// migrate it to diffRenderers from there in a separate step below.
func migratePaging(rootNode *yaml.Node, changes *ChangesSet) error {
return yaml_utils.TransformNode(rootNode, []string{"git"}, func(gitNode *yaml.Node) error {
pagingKeyNode, pagingValueNode := yaml_utils.LookupKey(gitNode, "paging")
if pagingKeyNode == nil || pagingValueNode.Kind != yaml.MappingNode {
@@ -521,10 +535,11 @@ func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error {
}
pagersKeyNode, _ := yaml_utils.LookupKey(gitNode, "pagers")
if pagersKeyNode != nil {
// Conversely, if there *is* already a "pagers" array, we also have nothing to do.
// This covers the case where the user keeps both the "paging" section and the "pagers"
// array for the sake of easier testing of old versions.
diffRenderersKeyNode, _ := yaml_utils.LookupKey(gitNode, "diffRenderers")
if pagersKeyNode != nil || diffRenderersKeyNode != nil {
// Conversely, if there is already a newer array config, we also have nothing to do.
// This covers the case where the user keeps both formats for the sake of easier testing
// of old versions.
return nil
}
@@ -532,6 +547,7 @@ func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error {
pagingContentCopy := pagingValueNode.Content
pagingValueNode.Kind = yaml.SequenceNode
pagingValueNode.Tag = "!!seq"
pagingValueNode.Style &^= yaml.FlowStyle
pagingValueNode.Content = []*yaml.Node{{
Kind: yaml.MappingNode,
Content: pagingContentCopy,
@@ -543,6 +559,90 @@ func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error {
})
}
func migratePagersToDiffRenderers(rootNode *yaml.Node, changes *ChangesSet) error {
return yaml_utils.TransformNode(rootNode, []string{"git"}, func(gitNode *yaml.Node) error {
pagersKeyNode, pagersValueNode := yaml_utils.LookupKey(gitNode, "pagers")
if pagersKeyNode == nil || pagersValueNode.Kind != yaml.SequenceNode {
// If there's no "pagers" section (or it's not a sequence), there's nothing to do
return nil
}
diffRenderersKeyNode, _ := yaml_utils.LookupKey(gitNode, "diffRenderers")
if diffRenderersKeyNode != nil {
// Conversely, if there *is* already a "diffRenderers" array, we also have nothing to do.
// This covers the case where the user keeps both the "pagers" and the "diffRenderers"
// arrays for the sake of easier testing of old versions.
return nil
}
pagersKeyNode.Value = "diffRenderers"
changes.Add("Renamed git.pagers to git.diffRenderers")
for _, diffRendererNode := range pagersValueNode.Content {
if diffRendererNode.Kind != yaml.MappingNode {
continue
}
pagerKeyNode, pagerValueNode := yaml_utils.LookupKey(diffRendererNode, "pager")
externalDiffCommandKeyNode, externalDiffCommandValueNode := yaml_utils.LookupKey(diffRendererNode, "externalDiffCommand")
useExternalDiffGitConfigKeyNode, useExternalDiffGitConfigValueNode := yaml_utils.LookupKey(diffRendererNode, "useExternalDiffGitConfig")
hasPager := hasNonNullScalarValue(pagerValueNode)
hasExternalDiffCommand := hasNonNullScalarValue(externalDiffCommandValueNode)
useExternalDiffGitConfig := yamlBoolValue(useExternalDiffGitConfigValueNode)
if hasPager {
pagerKeyNode.Value = "command"
changes.Add("Renamed 'pager' to 'command' in git pager")
} else if hasExternalDiffCommand {
externalDiffCommandKeyNode.Value = "command"
yaml_utils.AddStringKey(diffRendererNode, "type", "extDiff")
changes.Add("Changed 'externalDiffCommand' to 'command' with 'type: extDiff' in git pager")
} else if useExternalDiffGitConfig {
yaml_utils.RemoveKey(diffRendererNode, "useExternalDiffGitConfig")
yaml_utils.AddStringKey(diffRendererNode, "type", "extDiff")
changes.Add("Changed 'useExternalDiffGitConfig: true' to 'type: extDiff' in git pager")
} else {
yaml_utils.AddStringKey(diffRendererNode, "type", "rawGit")
diffRendererNode.Style &^= yaml.FlowStyle
changes.Add("Changed git pager without a command to 'type: rawGit'")
}
if pagerKeyNode != nil && !hasPager {
yaml_utils.RemoveKey(diffRendererNode, "pager")
changes.Add("Removed empty 'pager' from git pager")
}
if externalDiffCommandKeyNode != nil && !hasExternalDiffCommand {
yaml_utils.RemoveKey(diffRendererNode, "externalDiffCommand")
changes.Add("Removed empty 'externalDiffCommand' from git pager")
}
if useExternalDiffGitConfigKeyNode != nil && !useExternalDiffGitConfig {
yaml_utils.RemoveKey(diffRendererNode, "useExternalDiffGitConfig")
if useExternalDiffGitConfigValueNode.Tag == "!!null" {
changes.Add("Removed empty 'useExternalDiffGitConfig' from git pager")
} else {
changes.Add("Removed 'useExternalDiffGitConfig: false' from git pager")
}
}
}
return nil
})
}
func hasNonNullScalarValue(node *yaml.Node) bool {
return node != nil && node.Kind == yaml.ScalarNode && node.Tag != "!!null" && node.Value != ""
}
func yamlBoolValue(node *yaml.Node) bool {
if node == nil {
return false
}
var value bool
return node.Decode(&value) == nil && value
}
func (c *AppConfig) GetDebug() bool {
return c.debug
}
@@ -571,6 +671,20 @@ func (c *AppConfig) GetAppState() *AppState {
return c.appState
}
func (c *AppConfig) GetCachedGithubPullRequests(repoPath string) ([]CachedPullRequest, error) {
if c.githubPullRequestCache == nil {
return nil, nil
}
return c.githubPullRequestCache.get(repoPath), c.githubPullRequestCache.takeLoadError()
}
func (c *AppConfig) SaveCachedGithubPullRequests(repoPath string, pullRequests []CachedPullRequest) error {
if c.githubPullRequestCache == nil {
return nil
}
return c.githubPullRequestCache.save(repoPath, pullRequests)
}
func (c *AppConfig) GetUserConfigPaths() []string {
return lo.FilterMap(c.userConfigFiles, func(f *ConfigFile, _ int) (string, bool) {
return f.Path, f.exists
@@ -742,27 +856,10 @@ type AppState struct {
ShellCommandsHistory []string `yaml:"customcommandshistory"`
HideCommandLog bool
// Cache of GitHub pull requests per repo path, so that PR info can be
// shown instantly on startup before the async refresh completes.
GithubPullRequests map[string][]CachedPullRequest `yaml:"githubPullRequests"`
}
// CachedPullRequest stores the essential fields of a GitHub pull request
// for persisting in the app state cache.
type CachedPullRequest struct {
HeadRefName string `yaml:"headRefName"`
Number int `yaml:"number"`
Title string `yaml:"title"`
State string `yaml:"state"`
Url string `yaml:"url"`
HeadRepositoryOwner string `yaml:"headRepositoryOwner"`
}
func getDefaultAppState() *AppState {
return &AppState{
GithubPullRequests: make(map[string][]CachedPullRequest),
}
return &AppState{}
}
func LogPath() (string, error) {
+204 -687
View File
@@ -83,24 +83,28 @@ func TestMigrationOfRenamedKeys(t *testing.T) {
},
{
name: "Rename several",
input: `gui:
windowSize: half
skipUnstageLineWarning: true
keybinding:
universal:
executeCustomCommand: a
`,
expected: `gui:
screenMode: half
skipDiscardChangeWarning: true
keybinding:
universal:
executeShellCommand: a
`,
input: "gui:\n" +
" windowSize: half\n" +
" skipUnstageLineWarning: true\n" +
"keybinding:\n" +
" universal:\n" +
" executeCustomCommand: a\n" +
" cyclePagers: b\n" +
" cyclePagersReverse: c\n",
expected: "gui:\n" +
" screenMode: half\n" +
" skipDiscardChangeWarning: true\n" +
"keybinding:\n" +
" universal:\n" +
" executeShellCommand: a\n" +
" cycleDiffRenderers: b\n" +
" cycleDiffRenderersReverse: c\n",
expectedDidChange: true,
expectedChanges: []string{
"Renamed 'gui.skipUnstageLineWarning' to 'skipDiscardChangeWarning'",
"Renamed 'keybinding.universal.executeCustomCommand' to 'executeShellCommand'",
"Renamed 'keybinding.universal.cyclePagers' to 'cycleDiffRenderers'",
"Renamed 'keybinding.universal.cyclePagersReverse' to 'cycleDiffRenderersReverse'",
"Renamed 'gui.windowSize' to 'screenMode'",
},
},
@@ -470,629 +474,6 @@ func TestCustomCommandsOutputMigration(t *testing.T) {
}
}
var largeConfiguration = []byte(`
# Config relating to the Lazygit UI
gui:
# The number of lines you scroll by when scrolling the main window
scrollHeight: 2
# If true, allow scrolling past the bottom of the content in the main window
scrollPastBottom: true
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#scroll-off-margin
scrollOffMargin: 2
# One of: 'margin' (default) | 'jump'
scrollOffBehavior: margin
# The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.
# Note that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command.
tabWidth: 4
# If true, capture mouse events.
# When mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS.
mouseEvents: true
# If true, do not show a warning when amending a commit.
skipAmendWarning: false
# If true, do not show a warning when discarding changes in the staging view.
skipDiscardChangeWarning: false
# If true, do not show warning when applying/popping the stash
skipStashWarning: false
# If true, do not show a warning when attempting to commit without any staged files; instead stage all unstaged files.
skipNoStagedFilesWarning: false
# If true, do not show a warning when rewording a commit via an external editor
skipRewordInEditorWarning: false
# Fraction of the total screen width to use for the left side section. You may want to pick a small number (e.g. 0.2) if you're using a narrow screen, so that you can see more of the main section.
# Number from 0 to 1.0.
sidePanelWidth: 0.3333
# If true, increase the height of the focused side window; creating an accordion effect.
expandFocusedSidePanel: false
# The weight of the expanded side panel, relative to the other panels. 2 means
# twice as tall as the other panels. Only relevant if expandFocusedSidePanel is true.
expandedSidePanelWeight: 2
# Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split.
# Options are:
# - 'horizontal': split the window horizontally
# - 'vertical': split the window vertically
# - 'flexible': (default) split the window horizontally if the window is wide enough, otherwise split vertically
mainPanelSplitMode: flexible
# How the window is split when in half screen mode (i.e. after hitting '+' once).
# Possible values:
# - 'left': split the window horizontally (side panel on the left, main view on the right)
# - 'top': split the window vertically (side panel on top, main view below)
enlargedSideViewLocation: left
# If true, wrap lines in the staging view to the width of the view. This
# makes it much easier to work with diffs that have long lines, e.g.
# paragraphs of markdown text.
wrapLinesInStagingView: true
# One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru'
language: auto
# Format used when displaying time e.g. commit time.
# Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
timeFormat: 02 Jan 06
# Format used when displaying time if the time is less than 24 hours ago.
# Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
shortTimeFormat: 3:04PM
# Config relating to colors and styles.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
theme:
# Border color of focused window
activeBorderColor:
- green
- bold
# Border color of non-focused windows
inactiveBorderColor:
- default
# Border color of focused window when searching in that window
searchingActiveBorderColor:
- cyan
- bold
# Color of keybindings help text in the bottom line
optionsTextColor:
- blue
# Background color of selected line.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
selectedLineBgColor:
- blue
# Background color of selected line when view doesn't have focus.
inactiveViewSelectedLineBgColor:
- bold
# Foreground color of copied commit
cherryPickedCommitFgColor:
- blue
# Background color of copied commit
cherryPickedCommitBgColor:
- cyan
# Foreground color of marked base commit (for rebase)
markedBaseCommitFgColor:
- blue
# Background color of marked base commit (for rebase)
markedBaseCommitBgColor:
- yellow
# Color for file with unstaged changes
unstagedChangesColor:
- red
# Default text color
defaultFgColor:
- default
# Config relating to the commit length indicator
commitLength:
# If true, show an indicator of commit message length
show: true
# If true, show the '5 of 20' footer at the bottom of list views
showListFooter: true
# If true, display the files in the file views as a tree. If false, display the files as a flat list.
# This can be toggled from within Lazygit with the '' key, but that will not change the default.
showFileTree: true
# If true, show the number of lines changed per file in the Files view
showNumstatInFilesView: false
# If true, show a random tip in the command log when Lazygit starts
showRandomTip: true
# If true, show the command log
showCommandLog: true
# If true, show the bottom line that contains keybinding info and useful buttons. If false, this line will be hidden except to display a loader for an in-progress action.
showBottomLine: true
# If true, show jump-to-window keybindings in window titles.
showPanelJumps: true
# Deprecated: use nerdFontsVersion instead
showIcons: false
# Nerd fonts version to use.
# One of: '2' | '3' | empty string (default)
# If empty, do not show icons.
nerdFontsVersion: ""
# If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.
showFileIcons: true
# Length of author name in (non-expanded) commits view. 2 means show initials only.
commitAuthorShortLength: 2
# Length of author name in expanded commits view. 2 means show initials only.
commitAuthorLongLength: 17
# Length of commit hash in commits view. 0 shows '*' if NF icons aren't on.
commitHashLength: 8
# If true, show commit hashes alongside branch names in the branches view.
showBranchCommitHash: false
# Whether to show the divergence from the base branch in the branches view.
# One of: 'none' | 'onlyArrow' | 'arrowAndNumber'
showDivergenceFromBaseBranch: none
# Height of the command log view
commandLogSize: 8
# Whether to split the main window when viewing file changes.
# One of: 'auto' | 'always'
# If 'auto', only split the main window when a file has both staged and unstaged changes
splitDiff: auto
# Default size for focused window. Can be changed from within Lazygit with '+' and '_' (but this won't change the default).
# One of: 'normal' (default) | 'half' | 'full'
screenMode: normal
# Window border style.
# One of 'rounded' (default) | 'single' | 'double' | 'hidden' | 'bold'
border: rounded
# If true, show a seriously epic explosion animation when nuking the working tree.
animateExplosion: true
# Whether to stack UI components on top of each other.
# One of 'auto' (default) | 'always' | 'never'
portraitMode: auto
# How things are filtered when typing '/'.
# One of 'substring' (default) | 'fuzzy'
filterMode: substring
# Config relating to the spinner.
spinner:
# The frames of the spinner animation.
frames:
- '|'
- /
- '-'
- \
# The "speed" of the spinner in milliseconds.
rate: 50
# Status panel view.
# One of 'dashboard' (default) | 'allBranchesLog'
statusPanelView: dashboard
# If true, jump to the Files panel after popping a stash
switchToFilesAfterStashPop: true
# If true, jump to the Files panel after applying a stash
switchToFilesAfterStashApply: true
# If true, when using the panel jump keys (default 1 through 5) and target panel is already active, go to next tab instead
switchTabsWithPanelJumpKeys: false
# Config relating to git
git:
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md
paging:
# Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never'
colorArg: always
# e.g.
# diff-so-fancy
# delta --dark --paging=never
# ydiff -p cat -s --wrap --width={{columnWidth}}
pager: ""
useConfig: false
# e.g. 'difft --color=always'
externalDiffCommand: ""
# Config relating to committing
commit:
# If true, pass '--signoff' flag when committing
signOff: false
# Automatic WYSIWYG wrapping of the commit message as you type
autoWrapCommitMessage: true
# If autoWrapCommitMessage is true, the width to wrap to
autoWrapWidth: 72
# Config relating to merging
merging:
# If true, run merges in a subprocess so that if a commit message is required, Lazygit will not hang
# Only applicable to unix users.
manualCommit: false
# Extra args passed to , e.g. --no-ff
args: ""
# The commit message to use for a squash merge commit. Can contain "{{selectedRef}}" and "{{currentBranch}}" placeholders.
squashMergeMessage: Squash merge {{selectedRef}} into {{currentBranch}}
# list of branches that are considered 'main' branches, used when displaying commits
mainBranches:
- master
- main
# Prefix to use when skipping hooks. E.g. if set to 'WIP', then pre-commit hooks will be skipped when the commit message starts with 'WIP'
skipHookPrefix: WIP
# If true, periodically fetch from remote
autoFetch: true
# If true, periodically refresh files and submodules
autoRefresh: true
# If true, poll the repo periodically for external ref changes (commits,
# branch updates, checkouts made outside lazygit) and refresh when one
# is detected. Independent of autoRefresh, which only governs the files
# panel.
autoDetectExternalChanges: true
# If true, pass the --all arg to git fetch
fetchAll: true
# If true, lazygit will automatically stage files that used to have merge
# conflicts but no longer do; and it will also ask you if you want to
# continue a merge or rebase if you've resolved all conflicts. If false, it
# won't do either of these things.
autoStageResolvedConflicts: true
# Command used when displaying the current branch git log in the main window
branchLogCmd: git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --
# Command used to display git log of all branches in the main window.
# Deprecated: Use allBranchesLogCmds instead.
allBranchesLogCmd: git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium
# If true, do not spawn a separate process when using GPG
overrideGpg: false
# If true, do not allow force pushes
disableForcePushing: false
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-branch-name-prefix
branchPrefix: ""
# If true, parse emoji strings in commit messages e.g. render :rocket: as 🚀
# (This should really be under 'gui', not 'git')
parseEmoji: false
# Config for showing the log in the commits view
log:
# One of: 'date-order' | 'author-date-order' | 'topo-order' | 'default'
# 'topo-order' makes it easier to read the git log graph, but commits may not
# appear chronologically. See https://git-scm.com/docs/
#
# Deprecated: Configure this with Log menu -> Commit sort order (<c-l> in the commits window by default).
order: topo-order
# This determines whether the git graph is rendered in the commits panel
# One of 'always' | 'never' | 'when-maximised'
#
# Deprecated: Configure this with Log menu -> Show git graph (<c-l> in the commits window by default).
showGraph: always
# displays the whole git graph by default in the commits view (equivalent to passing the --all argument to git log)
showWholeGraph: false
# When copying commit hashes to the clipboard, truncate them to this
# length. Set to 40 to disable truncation.
truncateCopiedCommitHashesTo: 12
# Periodic update checks
update:
# One of: 'prompt' (default) | 'background' | 'never'
method: prompt
# Period in days between update checks
days: 14
# Background refreshes
refresher:
# File/submodule refresh interval in seconds.
# Auto-refresh can be disabled via option 'git.autoRefresh'.
refreshInterval: 10
# Re-fetch interval in seconds.
# Auto-fetch can be disabled via option 'git.autoFetch'.
fetchInterval: 60
# Interval in seconds at which lazygit polls for external ref changes
# (commits, branch updates, checkouts made outside lazygit).
# Detection can be disabled via option 'git.autoDetectExternalChanges'.
externalChangeCheckInterval: 2
# If true, show a confirmation popup before quitting Lazygit
confirmOnQuit: false
# If true, exit Lazygit when the user presses escape in a context where there is nothing to cancel/close
quitOnTopLevelReturn: false
# Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc
os:
# Command for editing a file. Should contain "{{filename}}".
edit: ""
# Command for editing a file at a given line number. Should contain
# "{{filename}}", and may optionally contain "{{line}}".
editAtLine: ""
# Same as EditAtLine, except that the command needs to wait until the
# window is closed.
editAtLineAndWait: ""
# Whether lazygit suspends until an edit process returns
editInTerminal: false
# For opening a directory in an editor
openDirInEditor: ""
# A built-in preset that sets all of the above settings. Supported presets
# are defined in the getPreset function in editor_presets.go.
editPreset: ""
# Command for opening a file, as if the file is double-clicked. Should
# contain "{{filename}}", but doesn't support "{{line}}".
open: ""
# Command for opening a link. Should contain "{{link}}".
openLink: ""
# EditCommand is the command for editing a file.
# Deprecated: use Edit instead. Note that semantics are different:
# EditCommand is just the command itself, whereas Edit contains a
# "{{filename}}" variable.
editCommand: ""
# EditCommandTemplate is the command template for editing a file
# Deprecated: use EditAtLine instead.
editCommandTemplate: ""
# OpenCommand is the command for opening a file
# Deprecated: use Open instead.
openCommand: ""
# OpenLinkCommand is the command for opening a link
# Deprecated: use OpenLink instead.
openLinkCommand: ""
# CopyToClipboardCmd is the command for copying to clipboard.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard
copyToClipboardCmd: ""
# ReadFromClipboardCmd is the command for reading the clipboard.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard
readFromClipboardCmd: ""
# If true, don't display introductory popups upon opening Lazygit.
disableStartupPopups: false
# What to do when opening Lazygit outside of a git repo.
# - 'prompt': (default) ask whether to initialize a new repo or open in the most recent repo
# - 'create': initialize a new repo
# - 'skip': open most recent repo
# - 'quit': exit Lazygit
notARepository: prompt
# If true, display a confirmation when subprocess terminates. This allows you to view the output of the subprocess before returning to Lazygit.
promptToReturnFromSubprocess: true
# Keybindings
keybinding:
universal:
quit: q
quit-alt1: <c-c>
return: <esc>
quitWithoutChangingDirectory: Q
togglePanel: <tab>
prevItem: <up>
nextItem: <down>
prevItem-alt: k
nextItem-alt: j
prevPage: ','
nextPage: .
scrollLeft: H
scrollRight: L
gotoTop: <
gotoBottom: '>'
toggleRangeSelect: v
rangeSelectDown: <s-down>
rangeSelectUp: <s-up>
prevBlock: <left>
nextBlock: <right>
prevBlock-alt: h
nextBlock-alt: l
nextBlock-alt2: <tab>
prevBlock-alt2: <backtab>
jumpToBlock:
- "1"
- "2"
- "3"
- "4"
- "5"
nextMatch: "n"
prevMatch: "N"
startSearch: /
optionMenu: <disabled>
optionMenu-alt1: '?'
select: <space>
goInto: <enter>
confirm: <enter>
confirmInEditor: <a-enter>
remove: d
new: "n"
edit: e
openFile: o
scrollUpMain: <pgup>
scrollDownMain: <pgdown>
scrollUpMain-alt1: K
scrollDownMain-alt1: J
scrollUpMain-alt2: <c-u>
scrollDownMain-alt2: <c-d>
executeShellCommand: ':'
createRebaseOptionsMenu: m
# 'Files' appended for legacy reasons
pushFiles: P
# 'Files' appended for legacy reasons
pullFiles: p
refresh: R
createPatchOptionsMenu: <c-p>
nextTab: ']'
prevTab: '['
nextScreenMode: +
prevScreenMode: _
undo: z
redo: Z
filteringMenu: <c-s>
diffingMenu: W
diffingMenu-alt: <c-e>
copyToClipboard: <c-o>
openRecentRepos: <c-r>
submitEditorText: <enter>
extrasMenu: '@'
toggleWhitespaceInDiffView: <c-w>
increaseContextInDiffView: '}'
decreaseContextInDiffView: '{'
increaseRenameSimilarityThreshold: )
decreaseRenameSimilarityThreshold: (
openDiffTool: <c-t>
status:
checkForUpdate: u
recentRepos: <enter>
allBranchesLogGraph: a
files:
commitChanges: c
commitChangesWithoutHook: w
amendLastCommit: A
commitChangesWithEditor: C
findBaseCommitForFixup: <c-f>
confirmDiscard: x
ignoreFile: i
refreshFiles: r
stashAllChanges: s
viewStashOptions: S
toggleStagedAll: a
viewResetOptions: D
fetch: f
openMergeOptions: M
openStatusFilter: <c-b>
copyFileInfoToClipboard: "y"
collapseAll: '-'
expandAll: =
branches:
createPullRequest: o
viewPullRequestOptions: O
copyPullRequestURL: <c-y>
checkoutBranchByName: c
forceCheckoutBranch: F
rebaseBranch: r
renameBranch: R
mergeIntoCurrentBranch: M
viewGitFlowOptions: i
fastForward: f
createTag: T
pushTag: P
setUpstream: u
fetchRemote: f
sortOrder: s
worktrees:
viewWorktreeOptions: w
commits:
squashDown: s
renameCommit: r
renameCommitWithEditor: R
viewResetOptions: g
markCommitAsFixup: f
createFixupCommit: F
squashAboveCommits: S
moveDownCommit: <c-j>
moveUpCommit: <c-k>
amendToCommit: A
resetCommitAuthor: a
pickCommit: p
revertCommit: t
cherryPickCopy: C
pasteCommits: V
markCommitAsBaseForRebase: B
tagCommit: T
checkoutCommit: <space>
resetCherryPick: <c-R>
copyCommitAttributeToClipboard: "y"
openLogMenu: <c-l>
openInBrowser: o
viewBisectOptions: b
startInteractiveRebase: i
amendAttribute:
resetAuthor: a
setAuthor: A
addCoAuthor: c
stash:
popStash: g
renameStash: r
commitFiles:
checkoutCommitFile: c
main:
toggleSelectHunk: a
pickBothHunks: b
editSelectHunk: E
submodules:
init: i
update: u
bulkMenu: b
commitMessage:
commitMenu: <c-o>
`)
func BenchmarkMigrationOnLargeConfiguration(b *testing.B) {
for b.Loop() {
changes := NewChangesSet()
_, _, _ = computeMigratedConfig("path doesn't matter", largeConfiguration, changes)
}
}
func TestAllBranchesLogCmdMigrations(t *testing.T) {
scenarios := []struct {
name string
@@ -1218,6 +599,7 @@ func TestPagerMigration(t *testing.T) {
expectedDidChange bool
expectedChanges []string
}{
// Migrate 'paging' to 'pagers' array
{
name: "Incomplete Configuration Passes uneventfully",
input: "git:",
@@ -1226,68 +608,203 @@ func TestPagerMigration(t *testing.T) {
},
{
name: "No paging section",
input: `git:
autoFetch: true
`,
expected: `git:
autoFetch: true
`,
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "Both paging and pagers exist",
input: `git:
paging:
pager: delta --dark --paging=never
pagers:
- diff: diff-so-fancy
`,
expected: `git:
paging:
pager: delta --dark --paging=never
pagers:
- diff: diff-so-fancy
`,
input: "git:\n" +
" autoFetch: true\n",
expected: "git:\n" +
" autoFetch: true\n",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "paging is not an object",
input: `git:
paging: 5
`,
expected: `git:
paging: 5
`,
input: "git:\n" +
" paging: 5\n",
expected: "git:\n" +
" paging: 5\n",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "paging is moved to pagers array (keeping the order)",
input: `git:
paging:
pager: delta --dark --paging=never
autoFetch: true
`,
expected: `git:
pagers:
- pager: delta --dark --paging=never
autoFetch: true
`,
expectedDidChange: true,
expectedChanges: []string{"Moved git.paging object to git.pagers array"},
name: "pagers is not an array",
input: "git:\n" +
" pagers: 5\n",
expected: "git:\n" +
" pagers: 5\n",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "paging is moved to pagers array even if empty",
input: `git:
paging: {}
`,
expected: `git:
pagers: [{}]
`,
name: "paging and pagers coexist",
input: "git:\n" +
" paging:\n" +
" pager: delta --dark --paging=never\n" +
" pagers:\n" +
" - pager: diff-so-fancy\n",
expected: "git:\n" +
" paging:\n" +
" pager: delta --dark --paging=never\n" +
" diffRenderers:\n" +
" - command: diff-so-fancy\n",
expectedDidChange: true,
expectedChanges: []string{"Moved git.paging object to git.pagers array"},
expectedChanges: []string{
"Renamed git.pagers to git.diffRenderers",
"Renamed 'pager' to 'command' in git pager",
},
},
{
name: "paging and diffRenderers coexist",
input: "git:\n" +
" paging:\n" +
" pager: delta --dark --paging=never\n" +
" diffRenderers:\n" +
" - command: diff-so-fancy\n",
expected: "git:\n" +
" paging:\n" +
" pager: delta --dark --paging=never\n" +
" diffRenderers:\n" +
" - command: diff-so-fancy\n",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "pagers and diffRenderers coexist",
input: "git:\n" +
" pagers:\n" +
" - pager: delta --dark --paging=never\n" +
" diffRenderers:\n" +
" - command: diff-so-fancy\n",
expected: "git:\n" +
" pagers:\n" +
" - pager: delta --dark --paging=never\n" +
" diffRenderers:\n" +
" - command: diff-so-fancy\n",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "paging is moved to diffRenderers array preserving fields and order",
input: "git:\n" +
" paging:\n" +
" name: delta\n" +
" colorArg: never\n" +
" pager: delta --dark --paging=never\n" +
" autoFetch: true\n",
expected: "git:\n" +
" diffRenderers:\n" +
" - name: delta\n" +
" colorArg: never\n" +
" command: delta --dark --paging=never\n" +
" autoFetch: true\n",
expectedDidChange: true,
expectedChanges: []string{
"Moved git.paging object to git.pagers array",
"Renamed git.pagers to git.diffRenderers",
"Renamed 'pager' to 'command' in git pager",
},
},
{
name: "paging is moved to diffRenderers array even if empty",
input: "git:\n" +
" paging: {}\n",
expected: "git:\n" +
" diffRenderers:\n" +
" - type: rawGit\n",
expectedDidChange: true,
expectedChanges: []string{
"Moved git.paging object to git.pagers array",
"Renamed git.pagers to git.diffRenderers",
"Changed git pager without a command to 'type: rawGit'",
},
},
// Migrate 'pagers' array to 'diffRenderers' array
{
name: "empty pagers array is renamed",
input: "git:\n" +
" pagers: []\n",
expected: "git:\n" +
" diffRenderers: []\n",
expectedDidChange: true,
expectedChanges: []string{"Renamed git.pagers to git.diffRenderers"},
},
{
name: "pagers array entries are adapted",
input: "git:\n" +
" pagers:\n" +
" - name: delta\n" +
" colorArg: never\n" +
" pager: delta --dark --paging=never\n" +
" - name: difft\n" +
" colorArg: never\n" +
" externalDiffCommand: difft --color=always\n" +
" - name: git-config\n" +
" colorArg: never\n" +
" useExternalDiffGitConfig: TRUE\n" +
" - name: git\n" +
" colorArg: never\n" +
" autoFetch: true\n",
expected: "git:\n" +
" diffRenderers:\n" +
" - name: delta\n" +
" colorArg: never\n" +
" command: delta --dark --paging=never\n" +
" - name: difft\n" +
" colorArg: never\n" +
" command: difft --color=always\n" +
" type: extDiff\n" +
" - name: git-config\n" +
" colorArg: never\n" +
" type: extDiff\n" +
" - name: git\n" +
" colorArg: never\n" +
" type: rawGit\n" +
" autoFetch: true\n",
expectedDidChange: true,
expectedChanges: []string{
"Renamed git.pagers to git.diffRenderers",
"Renamed 'pager' to 'command' in git pager",
"Changed 'externalDiffCommand' to 'command' with 'type: extDiff' in git pager",
"Changed 'useExternalDiffGitConfig: true' to 'type: extDiff' in git pager",
"Changed git pager without a command to 'type: rawGit'",
},
},
{
name: "zero-valued mechanism fields do not take precedence and are removed",
input: "git:\n" +
" pagers:\n" +
" - pager: delta --dark --paging=never\n" +
" externalDiffCommand: null\n" +
" useExternalDiffGitConfig: false\n" +
" - pager: \"\"\n" +
" externalDiffCommand: difft --color=always\n" +
" useExternalDiffGitConfig: false\n" +
" - pager: null\n" +
" externalDiffCommand: \"\"\n" +
" useExternalDiffGitConfig: YES\n" +
" - pager: \"\"\n" +
" externalDiffCommand:\n" +
" useExternalDiffGitConfig: false\n" +
" - useExternalDiffGitConfig: null\n",
expected: "git:\n" +
" diffRenderers:\n" +
" - command: delta --dark --paging=never\n" +
" - command: difft --color=always\n" +
" type: extDiff\n" +
" - type: extDiff\n" +
" - type: rawGit\n" +
" - type: rawGit\n",
expectedDidChange: true,
expectedChanges: []string{
"Renamed git.pagers to git.diffRenderers",
"Renamed 'pager' to 'command' in git pager",
"Removed empty 'externalDiffCommand' from git pager",
"Removed 'useExternalDiffGitConfig: false' from git pager",
"Changed 'externalDiffCommand' to 'command' with 'type: extDiff' in git pager",
"Removed empty 'pager' from git pager",
"Changed 'useExternalDiffGitConfig: true' to 'type: extDiff' in git pager",
"Changed git pager without a command to 'type: rawGit'",
"Removed empty 'useExternalDiffGitConfig' from git pager",
},
},
}
+167
View File
@@ -0,0 +1,167 @@
package config
import (
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type DiffRendererConfigManager struct {
getUserConfig func() *UserConfig
diffRendererIndex int
}
type DiffRendererType int
const (
DiffRendererType_StdinFilter DiffRendererType = iota
DiffRendererType_ExtDiff
DiffRendererType_RawGit
)
func NewDiffRendererConfigManager(getUserConfig func() *UserConfig) *DiffRendererConfigManager {
return &DiffRendererConfigManager{getUserConfig: getUserConfig}
}
func (self *DiffRendererConfigManager) currentDiffRendererConfig() *DiffRendererConfig {
diffRenderers := self.getUserConfig().Git.DiffRenderers
if len(diffRenderers) == 0 {
return nil
}
// Guard against the diff renderer index being out of range, which can happen if the user
// has removed diff renderers from their config file while lazygit is running.
if self.diffRendererIndex >= len(diffRenderers) {
self.diffRendererIndex = 0
}
return &diffRenderers[self.diffRendererIndex]
}
func (self *DiffRendererConfig) getType() DiffRendererType {
switch self.Type {
case "stdinFilter", "":
return DiffRendererType_StdinFilter
case "extDiff":
return DiffRendererType_ExtDiff
case "rawGit":
return DiffRendererType_RawGit
}
panic("invalid diff renderer type: " + self.Type)
}
func (self *DiffRendererConfigManager) GetDiffRendererType() DiffRendererType {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil {
return DiffRendererType_RawGit
}
return currentDiffRendererConfig.getType()
}
func (self *DiffRendererConfigManager) GetStdinFilterCommand(width int) string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter {
return ""
}
templateValues := map[string]string{
"columnWidth": strconv.Itoa(width/2 - 6),
}
commandTemplate := string(currentDiffRendererConfig.Command)
return utils.ResolvePlaceholderString(commandTemplate, templateValues)
}
func (self *DiffRendererConfigManager) GetColorArg() string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter {
return "always"
}
colorArg := currentDiffRendererConfig.ColorArg
if colorArg == "" {
return "always"
}
return colorArg
}
func (self *DiffRendererConfigManager) GetNarrowSelectionHighlight() bool {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil {
return false
}
return currentDiffRendererConfig.NarrowSelectionHighlight
}
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
return ""
}
templateValues := map[string]string{
"diffContext": strconv.Itoa(int(diffContext)),
}
return utils.ResolvePlaceholderString(string(currentDiffRendererConfig.Command), templateValues)
}
func (self *DiffRendererConfigManager) GetRawGitArgs() []string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_RawGit {
return nil
}
return currentDiffRendererConfig.Args
}
func (self *DiffRendererConfigManager) CycleDiffRenderers() {
self.diffRendererIndex = (self.diffRendererIndex + 1) % len(self.getUserConfig().Git.DiffRenderers)
}
func (self *DiffRendererConfigManager) CycleDiffRenderersBackward() {
n := len(self.getUserConfig().Git.DiffRenderers)
self.diffRendererIndex = (self.diffRendererIndex - 1 + n) % n
}
func (self *DiffRendererConfigManager) CurrentDiffRendererIndex() (int, int) {
return self.diffRendererIndex, len(self.getUserConfig().Git.DiffRenderers)
}
// CurrentDiffRendererName returns a name for the current diff renderer, suitable for showing
// to the user.
func (self *DiffRendererConfigManager) CurrentDiffRendererName(tr *i18n.TranslationSet) string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil {
return tr.DefaultDiffRendererName
}
if name := currentDiffRendererConfig.displayName(); name != "" {
return name
}
if currentDiffRendererConfig.getType() == DiffRendererType_ExtDiff && currentDiffRendererConfig.Command == "" {
return tr.ExternalDiffDiffRendererName
}
return tr.DefaultDiffRendererName
}
func (self *DiffRendererConfig) displayName() string {
if self.Name != "" {
return self.Name
}
if self.getType() == DiffRendererType_RawGit && len(self.Args) > 0 {
return self.Args[0]
}
return firstWord(string(self.Command))
}
func firstWord(command string) string {
fields := strings.Fields(command)
if len(fields) == 0 {
return ""
}
return fields[0]
}
@@ -0,0 +1,110 @@
package config
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/stretchr/testify/assert"
)
func TestCurrentDiffRendererName(t *testing.T) {
tr := i18n.EnglishTranslationSet()
scenarios := []struct {
name string
diffRendererConfig DiffRendererConfig
expected string
}{
{
name: "explicit name takes precedence over the command",
diffRendererConfig: DiffRendererConfig{Name: "delta side-by-side", Command: "delta --side-by-side"},
expected: "delta side-by-side",
},
{
name: "derived from the first word of the stdinFilter command",
diffRendererConfig: DiffRendererConfig{Command: "delta --side-by-side"},
expected: "delta",
},
{
name: "surrounding whitespace in the command is ignored",
diffRendererConfig: DiffRendererConfig{Command: " diff-so-fancy "},
expected: "diff-so-fancy",
},
{
name: "derived from the first word of the extDiff command",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
expected: "difft",
},
{
name: "no name can be derived for external diff",
diffRendererConfig: DiffRendererConfig{Type: "extDiff"},
expected: tr.ExternalDiffDiffRendererName,
},
{
name: "derived from first argument of rawGit args",
diffRendererConfig: DiffRendererConfig{Type: "rawGit", Args: []string{"--color-words"}},
expected: "--color-words",
},
{
name: "no name can be derived for raw diff",
diffRendererConfig: DiffRendererConfig{Type: "rawGit"},
expected: tr.DefaultDiffRendererName,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
userConfig := &UserConfig{}
userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig}
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
assert.Equal(t, s.expected, config.CurrentDiffRendererName(tr))
})
}
}
func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
tr := i18n.EnglishTranslationSet()
assert.Equal(t, tr.DefaultDiffRendererName, config.CurrentDiffRendererName(tr))
}
func TestGetNarrowSelectionHighlight(t *testing.T) {
userConfig := &UserConfig{}
userConfig.Git.DiffRenderers = []DiffRendererConfig{{NarrowSelectionHighlight: true}}
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
assert.Equal(t, true, config.GetNarrowSelectionHighlight())
}
func TestGetNarrowSelectionHighlightWithoutPagers(t *testing.T) {
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
assert.Equal(t, false, config.GetNarrowSelectionHighlight())
}
func TestCycleDiffRenderers(t *testing.T) {
userConfig := &UserConfig{}
userConfig.Git.DiffRenderers = []DiffRendererConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}}
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
currentIndex := func() int {
index, _ := config.CurrentDiffRendererIndex()
return index
}
assert.Equal(t, 0, currentIndex())
config.CycleDiffRenderers()
assert.Equal(t, 1, currentIndex())
config.CycleDiffRenderers()
assert.Equal(t, 2, currentIndex())
config.CycleDiffRenderers()
assert.Equal(t, 0, currentIndex(), "cycling forward past the last diff renderer wraps to the first")
config.CycleDiffRenderersBackward()
assert.Equal(t, 2, currentIndex(), "cycling backward past the first diff renderer wraps to the last")
config.CycleDiffRenderersBackward()
assert.Equal(t, 1, currentIndex())
}
+6 -5
View File
@@ -9,11 +9,12 @@ func NewDummyAppConfig() *AppConfig {
userConfig := GetDefaultConfig()
userConfig.Keybinding.MergeLegacyAltKeybindings()
appConfig := &AppConfig{
name: "lazygit",
version: "unversioned",
debug: false,
userConfig: userConfig,
appState: &AppState{},
name: "lazygit",
version: "unversioned",
debug: false,
userConfig: userConfig,
appState: &AppState{},
githubPullRequestCache: newGithubPullRequestCache(""),
}
_ = yaml.Unmarshal([]byte{}, appConfig.appState)
return appConfig
+124
View File
@@ -0,0 +1,124 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
const githubPullRequestsCacheFileName = "github_pull_requests.json"
// CachedPullRequest stores the essential fields of a GitHub pull request.
type CachedPullRequest struct {
HeadRefName string `json:"headRefName"`
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
ChecksState string `json:"checksState,omitempty"`
Url string `json:"url"`
HeadRepositoryOwner string `json:"headRepositoryOwner"`
}
type githubPullRequestCache struct {
mutex sync.Mutex
path string
pullRequestsByRepoPath map[string][]CachedPullRequest
loadErr error
}
func loadGithubPullRequestCache() *githubPullRequestCache {
path, err := githubPullRequestCachePath()
if err != nil {
cache := newGithubPullRequestCache("")
cache.loadErr = err
return cache
}
cache := newGithubPullRequestCache(path)
cache.load()
return cache
}
func githubPullRequestCachePath() (string, error) {
path, err := stateFilePath(stateFileName)
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(path), githubPullRequestsCacheFileName), nil
}
func newGithubPullRequestCache(path string) *githubPullRequestCache {
return &githubPullRequestCache{
path: path,
pullRequestsByRepoPath: make(map[string][]CachedPullRequest),
}
}
func (c *githubPullRequestCache) load() {
if c.path == "" {
return
}
content, err := os.ReadFile(c.path)
if err != nil {
if !os.IsNotExist(err) {
c.loadErr = fmt.Errorf("reading GitHub pull request cache: %w", err)
}
return
}
if len(content) == 0 {
return
}
if err := json.Unmarshal(content, &c.pullRequestsByRepoPath); err != nil {
c.pullRequestsByRepoPath = make(map[string][]CachedPullRequest)
c.loadErr = fmt.Errorf("parsing GitHub pull request cache: %w", err)
} else if c.pullRequestsByRepoPath == nil {
c.pullRequestsByRepoPath = make(map[string][]CachedPullRequest)
}
}
func (c *githubPullRequestCache) get(repoPath string) []CachedPullRequest {
c.mutex.Lock()
defer c.mutex.Unlock()
return append([]CachedPullRequest(nil), c.pullRequestsByRepoPath[repoPath]...)
}
// takeLoadError returns the error, if any, that occurred while loading the
// cache from disk, clearing it so that it is reported only once.
func (c *githubPullRequestCache) takeLoadError() error {
c.mutex.Lock()
defer c.mutex.Unlock()
loadErr := c.loadErr
c.loadErr = nil
return loadErr
}
func (c *githubPullRequestCache) save(repoPath string, pullRequests []CachedPullRequest) error {
c.mutex.Lock()
defer c.mutex.Unlock()
c.pullRequestsByRepoPath[repoPath] = append([]CachedPullRequest(nil), pullRequests...)
if c.path == "" {
return nil
}
content, err := json.MarshalIndent(c.pullRequestsByRepoPath, "", " ")
if err != nil {
return err
}
content = append(content, '\n')
// Apparently when people have read-only permissions they prefer us to fail
// silently, so don't propagate permission errors.
if err := os.WriteFile(c.path, content, 0o644); err != nil && !os.IsPermission(err) {
return err
}
return nil
}
@@ -0,0 +1,141 @@
package config
import (
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGithubPullRequestCachePath(t *testing.T) {
stateDir := t.TempDir()
t.Setenv("CONFIG_DIR", stateDir)
path, err := githubPullRequestCachePath()
assert.NoError(t, err)
assert.Equal(t, filepath.Join(stateDir, githubPullRequestsCacheFileName), path)
}
func TestGithubPullRequestCache(t *testing.T) {
path := filepath.Join(t.TempDir(), githubPullRequestsCacheFileName)
cache := newGithubPullRequestCache(path)
repoOnePullRequests := []CachedPullRequest{{
HeadRefName: "first-branch",
Number: 1,
Title: "First pull request",
State: "OPEN",
Url: "https://github.com/owner/repo/pull/1",
HeadRepositoryOwner: "owner",
}}
repoTwoPullRequests := []CachedPullRequest{{
HeadRefName: "second-branch",
Number: 2,
Title: "Second pull request",
State: "MERGED",
Url: "https://github.com/other/repo/pull/2",
HeadRepositoryOwner: "other",
}}
assert.NoError(t, cache.save("/repo/one", repoOnePullRequests))
assert.NoError(t, cache.save("/repo/two", repoTwoPullRequests))
content, err := os.ReadFile(path)
assert.NoError(t, err)
assert.Equal(t, `{
"/repo/one": [
{
"headRefName": "first-branch",
"number": 1,
"title": "First pull request",
"state": "OPEN",
"url": "https://github.com/owner/repo/pull/1",
"headRepositoryOwner": "owner"
}
],
"/repo/two": [
{
"headRefName": "second-branch",
"number": 2,
"title": "Second pull request",
"state": "MERGED",
"url": "https://github.com/other/repo/pull/2",
"headRepositoryOwner": "other"
}
]
}
`, string(content))
reloadedCache := newGithubPullRequestCache(path)
reloadedCache.load()
assert.Equal(t, repoOnePullRequests, reloadedCache.get("/repo/one"))
assert.Equal(t, repoTwoPullRequests, reloadedCache.get("/repo/two"))
assert.NoError(t, reloadedCache.takeLoadError())
}
func TestGithubPullRequestCacheIgnoresMalformedContent(t *testing.T) {
path := filepath.Join(t.TempDir(), githubPullRequestsCacheFileName)
assert.NoError(t, os.WriteFile(path, []byte("{"), 0o644))
cache := newGithubPullRequestCache(path)
cache.load()
assert.ErrorContains(t, cache.takeLoadError(), "parsing GitHub pull request cache")
assert.Empty(t, cache.get("/repo"))
assert.NoError(t, cache.save("/repo", []CachedPullRequest{{Number: 1}}))
reloadedCache := newGithubPullRequestCache(path)
reloadedCache.load()
assert.Equal(t, []CachedPullRequest{{Number: 1}}, reloadedCache.get("/repo"))
assert.NoError(t, reloadedCache.takeLoadError())
}
func TestGithubPullRequestCacheDoesNotModifyAppState(t *testing.T) {
stateDir := t.TempDir()
t.Setenv("CONFIG_DIR", stateDir)
statePath := filepath.Join(stateDir, stateFileName)
stateContent := []byte("recentrepos:\n - /repo\n")
assert.NoError(t, os.WriteFile(statePath, stateContent, 0o644))
cache := loadGithubPullRequestCache()
assert.NoError(t, cache.save("/repo", []CachedPullRequest{{Number: 1}}))
actualStateContent, err := os.ReadFile(statePath)
assert.NoError(t, err)
assert.Equal(t, stateContent, actualStateContent)
_, err = os.Stat(filepath.Join(stateDir, githubPullRequestsCacheFileName))
assert.NoError(t, err)
}
func TestGithubPullRequestCacheSerializesConcurrentSaves(t *testing.T) {
path := filepath.Join(t.TempDir(), githubPullRequestsCacheFileName)
cache := newGithubPullRequestCache(path)
const repoCount = 20
var waitGroup sync.WaitGroup
errs := make(chan error, repoCount)
for index := range repoCount {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
repoPath := fmt.Sprintf("/repo/%d", index)
errs <- cache.save(repoPath, []CachedPullRequest{{Number: index}})
}()
}
waitGroup.Wait()
close(errs)
for err := range errs {
assert.NoError(t, err)
}
reloadedCache := newGithubPullRequestCache(path)
reloadedCache.load()
for index := range repoCount {
repoPath := fmt.Sprintf("/repo/%d", index)
assert.Equal(t, []CachedPullRequest{{Number: index}}, reloadedCache.get(repoPath))
}
assert.NoError(t, reloadedCache.takeLoadError())
}
-126
View File
@@ -1,126 +0,0 @@
package config
import (
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type PagerConfig struct {
getUserConfig func() *UserConfig
pagerIndex int
}
func NewPagerConfig(getUserConfig func() *UserConfig) *PagerConfig {
return &PagerConfig{getUserConfig: getUserConfig}
}
func (self *PagerConfig) currentPagerConfig() *PagingConfig {
pagers := self.getUserConfig().Git.Pagers
if len(pagers) == 0 {
return nil
}
// Guard against the pager index being out of range, which can happen if the user
// has removed pagers from their config file while lazygit is running.
if self.pagerIndex >= len(pagers) {
self.pagerIndex = 0
}
return &pagers[self.pagerIndex]
}
func (self *PagerConfig) GetPagerCommand(width int) string {
currentPagerConfig := self.currentPagerConfig()
if currentPagerConfig == nil {
return ""
}
templateValues := map[string]string{
"columnWidth": strconv.Itoa(width/2 - 6),
}
pagerTemplate := string(currentPagerConfig.Pager)
return utils.ResolvePlaceholderString(pagerTemplate, templateValues)
}
func (self *PagerConfig) GetColorArg() string {
currentPagerConfig := self.currentPagerConfig()
if currentPagerConfig == nil {
return "always"
}
colorArg := currentPagerConfig.ColorArg
if colorArg == "" {
return "always"
}
return colorArg
}
func (self *PagerConfig) GetExternalDiffCommand() string {
currentPagerConfig := self.currentPagerConfig()
if currentPagerConfig == nil {
return ""
}
return currentPagerConfig.ExternalDiffCommand
}
func (self *PagerConfig) GetUseExternalDiffGitConfig() bool {
currentPagerConfig := self.currentPagerConfig()
if currentPagerConfig == nil {
return false
}
return currentPagerConfig.UseExternalDiffGitConfig
}
func (self *PagerConfig) CyclePagers() {
self.pagerIndex = (self.pagerIndex + 1) % len(self.getUserConfig().Git.Pagers)
}
func (self *PagerConfig) CyclePagersBackward() {
n := len(self.getUserConfig().Git.Pagers)
self.pagerIndex = (self.pagerIndex - 1 + n) % n
}
func (self *PagerConfig) CurrentPagerIndex() (int, int) {
return self.pagerIndex, len(self.getUserConfig().Git.Pagers)
}
// CurrentPagerName returns a name for the current pager, suitable for showing
// to the user. It returns an empty string if no name can be derived; callers
// should substitute a localized fallback in that case.
func (self *PagerConfig) CurrentPagerName() string {
currentPagerConfig := self.currentPagerConfig()
if currentPagerConfig == nil {
return ""
}
return currentPagerConfig.displayName()
}
// CurrentPagerUsesGitConfigDiff reports whether the current pager defers to
// git's own external diff config. Such an entry has no name we can derive (the
// actual command may even vary per file via .gitattributes), so callers show a
// generic label rather than treating it like the default no-pager entry.
func (self *PagerConfig) CurrentPagerUsesGitConfigDiff() bool {
currentPagerConfig := self.currentPagerConfig()
return currentPagerConfig != nil && currentPagerConfig.UseExternalDiffGitConfig
}
func (self *PagingConfig) displayName() string {
if self.Name != "" {
return self.Name
}
if word := firstWord(string(self.Pager)); word != "" {
return word
}
return firstWord(self.ExternalDiffCommand)
}
func firstWord(command string) string {
fields := strings.Fields(command)
if len(fields) == 0 {
return ""
}
return fields[0]
}
-82
View File
@@ -1,82 +0,0 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCurrentPagerName(t *testing.T) {
scenarios := []struct {
name string
pager PagingConfig
expected string
}{
{
name: "explicit name takes precedence over the command",
pager: PagingConfig{Name: "delta side-by-side", Pager: "delta --side-by-side"},
expected: "delta side-by-side",
},
{
name: "derived from the first word of the pager command",
pager: PagingConfig{Pager: "delta --side-by-side"},
expected: "delta",
},
{
name: "surrounding whitespace in the command is ignored",
pager: PagingConfig{Pager: " diff-so-fancy "},
expected: "diff-so-fancy",
},
{
name: "falls back to the external diff command when there is no pager",
pager: PagingConfig{ExternalDiffCommand: "difft --color=always"},
expected: "difft",
},
{
name: "no name can be derived",
pager: PagingConfig{UseExternalDiffGitConfig: true},
expected: "",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
userConfig := &UserConfig{}
userConfig.Git.Pagers = []PagingConfig{s.pager}
config := NewPagerConfig(func() *UserConfig { return userConfig })
assert.Equal(t, s.expected, config.CurrentPagerName())
})
}
}
func TestCurrentPagerNameWithoutPagers(t *testing.T) {
config := NewPagerConfig(func() *UserConfig { return &UserConfig{} })
assert.Equal(t, "", config.CurrentPagerName())
}
func TestCyclePagers(t *testing.T) {
userConfig := &UserConfig{}
userConfig.Git.Pagers = []PagingConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}}
config := NewPagerConfig(func() *UserConfig { return userConfig })
currentIndex := func() int {
index, _ := config.CurrentPagerIndex()
return index
}
assert.Equal(t, 0, currentIndex())
config.CyclePagers()
assert.Equal(t, 1, currentIndex())
config.CyclePagers()
assert.Equal(t, 2, currentIndex())
config.CyclePagers()
assert.Equal(t, 0, currentIndex(), "cycling forward past the last pager wraps to the first")
config.CyclePagersBackward()
assert.Equal(t, 2, currentIndex(), "cycling backward past the first pager wraps to the last")
config.CyclePagersBackward()
assert.Equal(t, 1, currentIndex())
}
+68 -55
View File
@@ -87,7 +87,7 @@ type GuiConfig struct {
// One of: 'margin' (default) | 'jump'
ScrollOffBehavior string `yaml:"scrollOffBehavior"`
// The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.
// Note that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command.
// Note that when using a diff renderer, the renderer has its own tab width setting, so you need to pass it separately in the renderer command.
TabWidth int `yaml:"tabWidth" jsonschema:"minimum=1"`
// If true, capture mouse events.
// When mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS.
@@ -269,37 +269,45 @@ type SpinnerConfig struct {
}
type GitConfig struct {
// Array of pagers. Each entry has the following format:
// [dev] The following documentation is duplicated from the PagingConfig struct below.
// Array of diff renderers. Each entry has the following format:
// [dev] The following documentation is duplicated from the DiffRendererConfig struct below.
//
// # A name for the pager, shown in the notification when cycling pagers.
// # If not set, the name is derived from the first word of the pager
// # command (or of the external diff command).
// # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'
// # | 'rawGit'
// type: "stdinFilter"
//
// # A name for the diff renderer, shown in the notification when cycling
// # renderers. If not set, the name is derived from the first word of the
// # renderer command.
// name: ""
//
// # Value of the --color arg in the git diff command. Some pagers want
// # this to be set to 'always' and some want it set to 'never'
// # If true, selected diff lines are highlighted only with a narrow bar
// # at the left edge of the view, rather than across the whole width.
// # Useful for pagers whose own line background colors should remain
// # visible.
// narrowSelectionHighlight: false
//
// # Value of the --color arg in the git diff command. Only used for type
// # 'stdinFilter'. Some renderers want this to be set to 'always' and some
// # want it set to 'never'.
// colorArg: "always"
//
// # The command to use for rendering diffs. This is either a stdinFilter or
// # an external diff command, depending on the type field; not applicable if
// # the type is 'rawGit'.
// # e.g.
// # diff-so-fancy
// # delta --dark --paging=never
// # ydiff -p cat -s --wrap --width={{columnWidth}}
// pager: ""
// # ydiff -p cat
// # difft --color=always
// command: ""
//
// # e.g. 'difft --color=always'
// externalDiffCommand: ""
// # Extra arguments (array of strings) passed to the git command. Only
// # applicable if the type is 'rawGit'.
// args: []
//
// # If true, Lazygit will use git's `diff.external` config for paging.
// # The advantage over `externalDiffCommand` is that this can be
// # configured per file type in .gitattributes; see
// # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
// useExternalDiffGitConfig: false
//
// 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually exclusive; set at most one per entry.
//
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information.
Pagers []PagingConfig `yaml:"pagers"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md for more information.
DiffRenderers []DiffRendererConfig `yaml:"diffRenderers"`
// Config relating to committing
Commit CommitConfig `yaml:"commit"`
// Config relating to merging
@@ -358,31 +366,36 @@ type GitConfig struct {
TruncateCopiedCommitHashesTo int `yaml:"truncateCopiedCommitHashesTo"`
}
type PagerType string
type DiffRendererCommandType string
func (PagerType) JSONSchemaExtend(schema *jsonschema.Schema) {
func (DiffRendererCommandType) JSONSchemaExtend(schema *jsonschema.Schema) {
schema.Examples = []any{
"delta --dark --paging=never",
"diff-so-fancy",
"ydiff -p cat -s --wrap --width={{columnWidth}}",
"ydiff -p cat",
"difft --color=always",
}
}
// [dev] This documentation is duplicated in the GitConfig struct. If you make changes here, make them there too.
type PagingConfig struct {
// A name for the pager, shown in the notification when cycling pagers. If not set, the name is derived from the first word of the pager command (or of the external diff command).
type DiffRendererConfig struct {
// The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' | 'rawGit'
Type string `yaml:"type" jsonschema:"enum=stdinFilter,enum=extDiff,enum=rawGit"`
// A name for the diff renderer, shown in the notification when cycling renderers. If not set, the name is derived from the first word of the renderer command.
Name string `yaml:"name"`
// Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never'
// If true, selected diff lines are highlighted only with a narrow bar at the left edge of the view, rather than across the whole width. Useful for pagers whose own line background colors should remain visible.
NarrowSelectionHighlight bool `yaml:"narrowSelectionHighlight"`
// Value of the --color arg in the git diff command. Only used for type 'stdinFilter'. Some renderers want this to be set to 'always' and some want it set to 'never'.
ColorArg string `yaml:"colorArg" jsonschema:"enum=always,enum=never"`
// The command to use for rendering diffs. This is either a stdinFilter or an external diff command, depending on the type field; not applicable if the type is 'rawGit'.
// e.g.
// diff-so-fancy
// delta --dark --paging=never
// ydiff -p cat -s --wrap --width={{columnWidth}}
Pager PagerType `yaml:"pager"`
// e.g. 'difft --color=always'
ExternalDiffCommand string `yaml:"externalDiffCommand"`
// If true, Lazygit will use git's `diff.external` config for paging. The advantage over `externalDiffCommand` is that this can be configured per file type in .gitattributes; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
UseExternalDiffGitConfig bool `yaml:"useExternalDiffGitConfig"`
// ydiff -p cat
// difft --color=always
Command DiffRendererCommandType `yaml:"command"`
// Extra arguments (array of strings) passed to the git command. Only applicable if the type is 'rawGit'.
Args []string `yaml:"args"`
}
type CommitConfig struct {
@@ -524,23 +537,23 @@ type KeybindingUniversalConfig struct {
// Deprecated: add the key to `scrollUpMain` instead.
ScrollUpMainAlt2 Keybinding `yaml:"scrollUpMain-alt2"`
// Deprecated: add the key to `scrollDownMain` instead.
ScrollDownMainAlt2 Keybinding `yaml:"scrollDownMain-alt2"`
ExecuteShellCommand Keybinding `yaml:"executeShellCommand"`
CreateRebaseOptionsMenu Keybinding `yaml:"createRebaseOptionsMenu"`
Push Keybinding `yaml:"pushFiles"` // 'Files' appended for legacy reasons
Pull Keybinding `yaml:"pullFiles"` // 'Files' appended for legacy reasons
Refresh Keybinding `yaml:"refresh"`
CreatePatchOptionsMenu Keybinding `yaml:"createPatchOptionsMenu"`
NextTab Keybinding `yaml:"nextTab"`
PrevTab Keybinding `yaml:"prevTab"`
NextScreenMode Keybinding `yaml:"nextScreenMode"`
PrevScreenMode Keybinding `yaml:"prevScreenMode"`
CyclePagers Keybinding `yaml:"cyclePagers"`
CyclePagersReverse Keybinding `yaml:"cyclePagersReverse"`
Undo Keybinding `yaml:"undo"`
Redo Keybinding `yaml:"redo"`
FilteringMenu Keybinding `yaml:"filteringMenu"`
DiffingMenu Keybinding `yaml:"diffingMenu"`
ScrollDownMainAlt2 Keybinding `yaml:"scrollDownMain-alt2"`
ExecuteShellCommand Keybinding `yaml:"executeShellCommand"`
CreateRebaseOptionsMenu Keybinding `yaml:"createRebaseOptionsMenu"`
Push Keybinding `yaml:"pushFiles"` // 'Files' appended for legacy reasons
Pull Keybinding `yaml:"pullFiles"` // 'Files' appended for legacy reasons
Refresh Keybinding `yaml:"refresh"`
CreatePatchOptionsMenu Keybinding `yaml:"createPatchOptionsMenu"`
NextTab Keybinding `yaml:"nextTab"`
PrevTab Keybinding `yaml:"prevTab"`
NextScreenMode Keybinding `yaml:"nextScreenMode"`
PrevScreenMode Keybinding `yaml:"prevScreenMode"`
CycleDiffRenderers Keybinding `yaml:"cycleDiffRenderers"`
CycleDiffRenderersReverse Keybinding `yaml:"cycleDiffRenderersReverse"`
Undo Keybinding `yaml:"undo"`
Redo Keybinding `yaml:"redo"`
FilteringMenu Keybinding `yaml:"filteringMenu"`
DiffingMenu Keybinding `yaml:"diffingMenu"`
// Deprecated: add the key to `diffingMenu` instead.
DiffingMenuAlt Keybinding `yaml:"diffingMenu-alt"`
CopyToClipboard Keybinding `yaml:"copyToClipboard"`
@@ -922,8 +935,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
PortraitModeAutoMinHeight: 46,
FilterMode: "substring",
Spinner: SpinnerConfig{
Frames: []string{"|", "/", "-", "\\"},
Rate: 50,
Frames: []string{"●∙∙", "∙●∙", "∙∙●", "∙●∙"},
Rate: 180,
},
StatusPanelView: "dashboard",
SwitchToFilesAfterStashPop: true,
@@ -1055,8 +1068,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
PrevTab: Keybinding{"["},
NextScreenMode: Keybinding{"+"},
PrevScreenMode: Keybinding{"_"},
CyclePagers: Keybinding{"|"},
CyclePagersReverse: Keybinding{"\\"},
CycleDiffRenderers: Keybinding{"|"},
CycleDiffRenderersReverse: Keybinding{"\\"},
Undo: Keybinding{"z"},
Redo: Keybinding{"Z"},
FilteringMenu: Keybinding{"<ctrl+s>"},
+21 -20
View File
@@ -46,7 +46,7 @@ func (config *UserConfig) Validate() error {
[]string{"always", "never", "when-maximised"}); err != nil {
return err
}
if err := validatePagers(config.Git.Pagers); err != nil {
if err := validateDiffRenderers(config.Git.DiffRenderers); err != nil {
return err
}
if err := validateKeybindings(config.Keybinding); err != nil {
@@ -110,25 +110,26 @@ func validateSpinner(spinner SpinnerConfig) error {
return nil
}
// validatePagers rejects pager entries that combine more than one diff
// mechanism. A pager (GIT_PAGER) formats the diff that git produces, whereas
// externalDiffCommand and useExternalDiffGitConfig change how git produces the
// diff in the first place; piping one through the other almost always yields
// garbled output, so we treat the three as mutually exclusive.
func validatePagers(pagers []PagingConfig) error {
for i, pager := range pagers {
count := 0
if pager.Pager != "" {
count++
}
if pager.ExternalDiffCommand != "" {
count++
}
if pager.UseExternalDiffGitConfig {
count++
}
if count > 1 {
return fmt.Errorf("git.pagers[%d]: at most one of 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' may be set; they are mutually exclusive", i)
func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
for _, diffRenderer := range diffRenderers {
switch diffRenderer.Type {
case "stdinFilter", "":
if diffRenderer.Command == "" {
return errors.New("git.diffRenderers: 'command' must be specified for diff renderer type 'stdinFilter'.")
}
if len(diffRenderer.Args) > 0 {
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'stdinFilter'.")
}
case "extDiff":
if len(diffRenderer.Args) > 0 {
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'extDiff'.")
}
case "rawGit":
if diffRenderer.Command != "" {
return errors.New("git.diffRenderers: 'command' cannot be used with diff renderer type 'rawGit'.")
}
default:
return fmt.Errorf("git.diffRenderers: unknown type '%s'. Allowed values: stdinFilter, extDiff, rawGit", diffRenderer.Type)
}
}
return nil
+17 -13
View File
@@ -361,26 +361,30 @@ func TestUserConfigValidate_sidePanels(t *testing.T) {
}
}
func TestUserConfigValidate_pagers(t *testing.T) {
func TestUserConfigValidate_diffRenderers(t *testing.T) {
scenarios := []struct {
name string
pager PagingConfig
valid bool
name string
diffRenderer DiffRendererConfig
valid bool
}{
{name: "empty", pager: PagingConfig{}, valid: true},
{name: "pager only", pager: PagingConfig{Pager: "delta"}, valid: true},
{name: "external diff command only", pager: PagingConfig{ExternalDiffCommand: "difft"}, valid: true},
{name: "git config external diff only", pager: PagingConfig{UseExternalDiffGitConfig: true}, valid: true},
{name: "pager and external diff command", pager: PagingConfig{Pager: "delta", ExternalDiffCommand: "difft"}, valid: false},
{name: "pager and git config external diff", pager: PagingConfig{Pager: "delta", UseExternalDiffGitConfig: true}, valid: false},
{name: "both external diff mechanisms", pager: PagingConfig{ExternalDiffCommand: "difft", UseExternalDiffGitConfig: true}, valid: false},
{name: "all three", pager: PagingConfig{Pager: "delta", ExternalDiffCommand: "difft", UseExternalDiffGitConfig: true}, valid: false},
{name: "stdinFilter with type default", diffRenderer: DiffRendererConfig{Command: "delta"}, valid: true},
{name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta"}, valid: true},
{name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter"}, valid: false},
{name: "stdinFilter with type default without command", diffRenderer: DiffRendererConfig{}, valid: false},
{name: "stdinFilter with args", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta", Args: []string{"-x"}}, valid: false},
{name: "external diff", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft"}, valid: true},
{name: "external diff without command", diffRenderer: DiffRendererConfig{Type: "extDiff"}, valid: true},
{name: "external diff with args", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft", Args: []string{"-x"}}, valid: false},
{name: "raw git", diffRenderer: DiffRendererConfig{Type: "rawGit"}, valid: true},
{name: "raw git with args", diffRenderer: DiffRendererConfig{Type: "rawGit", Args: []string{"-x"}}, valid: true},
{name: "raw git with command", diffRenderer: DiffRendererConfig{Type: "rawGit", Command: "delta"}, valid: false},
{name: "unknown type", diffRenderer: DiffRendererConfig{Type: "unknown"}, valid: false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config := GetDefaultConfig()
config.Git.Pagers = []PagingConfig{s.pager}
config.Git.DiffRenderers = []DiffRendererConfig{s.diffRenderer}
err := config.Validate()
if s.valid {
+16 -16
View File
@@ -1,14 +1,14 @@
package constants
type Docs struct {
CustomPagers string
CustomCommands string
CustomKeybindings string
Keybindings string
Undoing string
Config string
Tutorial string
CustomPatchDemo string
CustomDiffRenderers string
CustomCommands string
CustomKeybindings string
Keybindings string
Undoing string
Config string
Tutorial string
CustomPatchDemo string
}
var Links = struct {
@@ -25,13 +25,13 @@ var Links = struct {
Discussions: "https://github.com/jesseduffield/lazygit/discussions",
Releases: "https://github.com/jesseduffield/lazygit/releases",
Docs: Docs{
CustomPagers: "https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md",
CustomKeybindings: "https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md",
CustomCommands: "https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium",
Keybindings: "https://github.com/jesseduffield/lazygit/blob/%s/docs/keybindings",
Undoing: "https://github.com/jesseduffield/lazygit/blob/master/docs/Undoing.md",
Config: "https://github.com/jesseduffield/lazygit/blob/%s/docs/Config.md",
Tutorial: "https://youtu.be/VDXvbHZYeKY",
CustomPatchDemo: "https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches",
CustomDiffRenderers: "https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md",
CustomKeybindings: "https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md",
CustomCommands: "https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium",
Keybindings: "https://github.com/jesseduffield/lazygit/blob/%s/docs/keybindings",
Undoing: "https://github.com/jesseduffield/lazygit/blob/master/docs/Undoing.md",
Config: "https://github.com/jesseduffield/lazygit/blob/%s/docs/Config.md",
Tutorial: "https://youtu.be/VDXvbHZYeKY",
CustomPatchDemo: "https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches",
},
}
+38 -6
View File
@@ -2,27 +2,59 @@ package env
import (
"os"
"strings"
)
// This package encapsulates accessing/mutating the ENV of the program.
// The variables with which git can be told where a repo is, rather than having
// it find out from the working directory.
const (
GitDirEnvVar = "GIT_DIR"
GitWorkTreeEnvVar = "GIT_WORK_TREE"
)
func GetGitDirEnv() string {
return os.Getenv("GIT_DIR")
return os.Getenv(GitDirEnvVar)
}
func SetGitDirEnv(value string) {
os.Setenv("GIT_DIR", value)
os.Setenv(GitDirEnvVar, value)
}
func GetWorkTreeEnv() string {
return os.Getenv("GIT_WORK_TREE")
return os.Getenv(GitWorkTreeEnvVar)
}
func SetWorkTreeEnv(value string) {
os.Setenv("GIT_WORK_TREE", value)
os.Setenv(GitWorkTreeEnvVar, value)
}
func UnsetGitLocationEnvVars() {
_ = os.Unsetenv("GIT_DIR")
_ = os.Unsetenv("GIT_WORK_TREE")
_ = os.Unsetenv(GitDirEnvVar)
_ = os.Unsetenv(GitWorkTreeEnvVar)
}
// GetGitLocationEnvVars returns the location variables that are set, as
// "NAME=value" entries.
func GetGitLocationEnvVars() []string {
envVars := []string{}
for _, name := range []string{GitDirEnvVar, GitWorkTreeEnvVar} {
if value := os.Getenv(name); value != "" {
envVars = append(envVars, name+"="+value)
}
}
return envVars
}
// SetGitLocationEnvVars sets the location variables from "NAME=value" entries,
// clearing both first so that only what is given remains. Passing nothing is
// how you say the repo is to be found from the working directory.
func SetGitLocationEnvVars(envVars []string) {
UnsetGitLocationEnvVars()
for _, envVar := range envVars {
if name, value, ok := strings.Cut(envVar, "="); ok {
os.Setenv(name, value)
}
}
}

Some files were not shown because too many files have changed in this diff Show More