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
686 changed files with 29774 additions and 44090 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"additionalDirectories": [
"/Users/stk/Stk/Dev/Builds/delta",
"/Users/stk/Stk/Dev/Builds/difftastic"
]
}
}
+7
View File
@@ -0,0 +1,7 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,go.sum,*.lock,.codespellrc,vendor,translations,Keybindings_*.md,./pkg/gocui
check-hidden = true
# camel-cased
ignore-regex = (\b[A-Za-z][a-z]*[A-Z]\S+\b|\.edn\b|\S+…|\\nd\b)
ignore-words-list = fomrat,inbetween
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
check-required-label:
runs-on: ubuntu-latest
steps:
- uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5
- uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5
with:
mode: exactly
count: 1
+7 -7
View File
@@ -30,7 +30,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: 1.25.x
- name: Test code
@@ -93,7 +93,7 @@ jobs:
path: ~/git-${{matrix.git-version}}
key: ${{runner.os}}-git-${{matrix.git-version}}
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: 1.25.x
- name: Print git version
@@ -130,7 +130,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: 1.25.x
- name: Build linux binary
@@ -157,7 +157,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: 1.25.x
- name: Check Vendor Directory
@@ -183,7 +183,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: 1.25.x
- name: Check formatting
@@ -195,7 +195,7 @@ jobs:
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
with:
# If you change this, make sure to also update scripts/golangci-lint-shim.sh
version: v2.12.2
version: v2.4.0
upload-coverage:
# List all jobs that produce coverage files
needs: [unit-tests, integration-tests]
@@ -206,7 +206,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: 1.25.x
+25
View File
@@ -0,0 +1,25 @@
# Codespell configuration is within .codespellrc
---
name: Codespell
on:
push:
branches: [master]
pull_request:
branches: [master]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@9ba2c57125d4908eade4308f32c4ff814c184633 # v1.2.0
- name: Codespell
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
+1 -1
View File
@@ -160,7 +160,7 @@ jobs:
git push origin "refs/tags/$NEW_TAG"
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: 1.25.x
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v7
- name: Generate Sponsors 💖
uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1.6.0
with:
token: ${{ secrets.SPONSORS_TOKEN }}
file: "README.md"
+2
View File
@@ -99,6 +99,8 @@ linters:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- vendor/
+6 -63
View File
@@ -63,9 +63,7 @@ Prefer a fine-grained commit history. Commits should be as small as possible
while still being meaningful and self-contained.
- **Every commit must compile and pass all tests.** No "WIP" commits, no
commits that leave the tree broken and rely on a follow-up to fix it. A
`fixup!` is not such a follow-up; see "Iterate with `fixup!` commits" for
what one may leave broken until it is folded in.
commits that leave the tree broken and rely on a follow-up to fix it.
- **Every commit must be `gofumpt`-formatted.** Run `just format` before
committing.
- **Every commit must be lint-clean.** Run `just lint` before committing —
@@ -84,26 +82,11 @@ while still being meaningful and self-contained.
excuse bundling it in. Before committing, review your diff and split out any
hunk that is behavior-preserving (an extraction, a rename, a move) into a
preceding commit, by staging hunks or resetting and recommitting in order.
- **A preparatory refactor is a new commit only when it prepares something
new.** Before adding one, find the commit that introduced the code you are
about to restructure. If that commit is on this branch, the refactor is a
`fixup!` for it rather than a commit of its own: a branch must never contain
a commit whose code a later commit on the same branch tidies up. A prep
refactor earns a commit of its own only when the shape it corrects came from
before the branch. This holds across a branch stack too — if the commit that
introduced the code is in an earlier branch of the stack, the fixup belongs
there, and the branches above it get replayed. The one exception is when
fixing it there turns out to be unreasonably difficult; ask me what to do
rather than deciding to leave the repair at the tip.
- **Do not use conventional commits** (no `feat:`/`fix:`/`chore:` prefixes).
Match the plain English imperative style of the existing history.
- **Wrap message body to 72 characters**. The subject is allowed to go up to 80
characters, or even a little more if needed to convey a good single-line
summary; the body should be wrapped at 72 exactly, no more, no less.
- **End every commit message with the `Co-authored-by:` trailer** naming the
model that wrote it, exactly as your harness instructions spell it. Nothing
in `just check` catches a missing one, so it has to be part of writing the
message rather than something to notice afterwards.
## Iterate with `fixup!` commits
@@ -122,46 +105,6 @@ separate, reviewable commit that the user decides when to fold in. A bare
`--amend` rewrites the commit on the spot and skips that checkpoint. Don't
treat "I'm only touching the tip commit" as an exception.
Always use `fixup!` or `amend!` commits, never amend changes directly, even if
you naturally would because "the branch isn't pushed yet". The user always wants
to review what you changed, so make this transparent; no exceptions.
**When the tip is the wrong place for a fixup, insert it mid-branch.**
Committing a fixup at the tip of the branch only works while the code it
touches still looks the same there; once later commits have rewritten that
code — or the target has since been split — the fixup won't apply, and
rewriting the later commits to accommodate it defeats the point. Check out the
target, make the change, `git commit --fixup=<target>`, then
`git rebase --onto <the fixup> <target> <branch>` to replay the rest of the
branch. The fixup stays a separate, reviewable commit; only its position
changes.
**A fixup may leave commits before it broken until it is folded in.** If a
`fixup!` on an early commit deletes something that a later commit still uses,
the later commit doesn't build until its own `fixup!`, right behind it, catches
up; the same goes for lint. That is expected. The rules above about every
commit compiling, testing and linting clean describe the history _after_
autosquash, and I fold fixups in soon after reviewing them. Never amend a
commit directly, or edit the commits between two fixups, to keep every commit
of the un-squashed history green. The reviewable fixup is worth more than a
green intermediate state. Verify at each fixup instead, since the tree there
is what the folded-in history will have at that point, and say in the handoff
which commits stay broken until which fixup.
**After a mid-stack fixup, check every branch tip above it, not just the stack
tip.** A fixup that deletes or renames something rewrites every commit replayed
above it, and a commit further up can hide the damage at the tip. A helper
whose last caller the fixup deleted is flagged as unused by `just lint` at the
tip of its own PR, but a later PR that calls it again makes the stack tip lint
clean. Each PR is reviewed and merged on its own, so each PR branch tip has to
be green on its own. After the replay, run `just build`, `just unit-test` and
`just lint` at each branch tip from the insertion point up. If the fixup deleted
or renamed a symbol, also build every replayed commit, for example with
`git -c rebase.autosquash=false rebase -x 'go build ./...' <insertion point>`;
unchanged commits are fast-forwarded, so their hashes stay, and the commits a
fixup is expected to leave broken stop it, so `git rebase --continue` past
those.
If the changes don't map cleanly onto existing commits — say they cut
across several of them, or restructure something at a different layer
than any existing commit naturally owns — stop and ask the user how to
@@ -208,7 +151,7 @@ looks messy. The whole point of a fixup is that the iteration stays
**visible and reviewable**; squashing it away yourself destroys exactly the
artifact it exists to create. Collapsing fixups into their targets is the
user's action, taken once they've reviewed the iterations. Every mention of
`--autosquash` in this section describes what the _user_ will eventually
`--autosquash` in this section describes what the *user* will eventually
run, never a step for you to perform. If you think the history is ready to
collapse, say so and leave it to them.
@@ -348,7 +291,7 @@ refactor to an earlier commit (but don't do it without asking first).
## Don't read model state right after a `Refresh`
A `Refresh` (or `RefreshFromWorker`) does its git work on a worker and then
_enqueues_ the model update onto the UI thread. So when `Refresh` returns, the
*enqueues* the model update onto the UI thread. So when `Refresh` returns, the
model is **not** updated yet — the write is still queued. Reading a field
synchronously right after refreshing its scope reads the stale, pre-refresh
value (and this is true even for SYNC refreshes):
@@ -434,7 +377,7 @@ column. Applies only to `pkg/i18n/english.go`.
## Code comments are for future readers, not development history
Comments in source code explain _why this code is shaped the way it is_. They
Comments in source code explain *why this code is shaped the way it is*. They
are not the place to narrate the path we took during development — what was
tried first, what didn't work, what's "more reliable" or "cleaner" than some
alternative. That framing is interesting in the moment, but it's noise to
@@ -450,7 +393,7 @@ Avoid phrasings like:
- "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
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.
@@ -503,7 +446,7 @@ So:
struct, run `just generate` and include the regenerated
`docs-master/Config.md` (and `schema-master/config.json`) in your commit.
- Don't hard-wrap the doc comments on `userConfig` fields. This applies
_only_ to `userConfig`, because those comments are fed through the doc
*only* to `userConfig`, because those comments are fed through the doc
generator; comments on every other struct follow the normal Go wrapping
conventions. For `userConfig` fields, write each sentence (or paragraph)
as a single unwrapped line, however long — the generator re-wraps them for
+3 -3
View File
File diff suppressed because one or more lines are too long
-142
View File
@@ -1,142 +0,0 @@
// author_colors_repo creates a git repository for checking that the colors
// lazygit gives to authors are readable.
//
// If gui.authorColors names no color for an author, lazygit derives one from a
// hash of their name. The authors of an ordinary repository rarely land near the
// edges of the range that this color is picked from. Every commit in the
// repository created here is by an author at one of those edges: the lowest or
// highest lightness, combined with the lowest or highest saturation, at twelve
// hues around the color wheel. The commit subject says which edge it is.
//
// Usage:
//
// go run ./cmd/author_colors_repo <path>
//
// Then open the repository with lazygit in each terminal theme you want to check.
package main
import (
"fmt"
"log"
"math"
"os"
"os/exec"
"time"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
)
type extreme int
const (
lowest extreme = iota
highest
)
func (self extreme) String() string {
if self == lowest {
return "min"
}
return "max"
}
func (self extreme) matches(fraction float64) bool {
if self == lowest {
return fraction < 0.01
}
return fraction >= 0.99
}
const (
numHues = 12
// Small enough that the windows of neighbouring hues don't overlap
hueTolerance = 0.02
)
type commit struct {
author string
subject string
}
func main() {
if len(os.Args) != 2 {
log.Fatalf("usage: %s <path>", os.Args[0])
}
path := os.Args[1]
if _, err := os.Stat(path); err == nil {
log.Fatalf("%s already exists", path)
}
extremes := []extreme{lowest, highest}
commits := make([]commit, 0, len(extremes)*len(extremes)*numHues)
for _, lightness := range extremes {
for _, saturation := range extremes {
for i := range numHues {
hue := float64(i) / numHues
author, actualHue := findAuthor(lightness, saturation, hue)
subject := fmt.Sprintf("lightness %s, saturation %s, hue %.0f°", lightness, saturation, actualHue*360)
commits = append(commits, commit{author: author, subject: subject})
}
}
}
if err := runGit("", nil, "init", "-q", path); err != nil {
log.Fatal(err)
}
// Commit in reverse, so that the commits panel lists them in the order above
startTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i := range commits {
c := commits[len(commits)-1-i]
date := fmt.Sprintf("%d +0000", startTime.Add(time.Duration(i)*time.Hour).Unix())
env := []string{
"GIT_AUTHOR_NAME=" + c.author,
"GIT_AUTHOR_EMAIL=author@example.com",
"GIT_AUTHOR_DATE=" + date,
"GIT_COMMITTER_NAME=" + c.author,
"GIT_COMMITTER_EMAIL=author@example.com",
"GIT_COMMITTER_DATE=" + date,
}
if err := runGit(path, env, "commit", "-q", "--allow-empty", "-m", c.subject); err != nil {
log.Fatal(err)
}
}
fmt.Printf("Created %s with %d commits\n", path, len(commits))
}
// findAuthor returns the first name of the form "L<lightness> S<saturation> <n>"
// whose color lies at the given extremes of lightness and saturation, and close
// to the given hue. It also returns the hue that the name lands on.
//
// Every name of this form has the same initials, so the color is the only
// thing that differs between authors in the commits panel.
func findAuthor(lightness extreme, saturation extreme, hue float64) (string, float64) {
for n := 1; ; n++ {
name := fmt.Sprintf("L%s S%s %d", lightness, saturation, n)
h, s, l := authors.ColorPosition(name)
if lightness.matches(l) && saturation.matches(s) && hueDistance(h, hue) <= hueTolerance {
return name, h
}
}
}
// hueDistance is the distance between two hues on the color wheel, where each
// hue is a fraction of a full turn.
func hueDistance(a float64, b float64) float64 {
d := math.Abs(a - b)
return math.Min(d, 1-d)
}
func runGit(dir string, env []string, args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = dir
// Keep the user's git config out of it, so that hooks, commit signing and
// the like don't apply, and every run creates the same commits
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1")
cmd.Env = append(cmd.Env, env...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
+37 -109
View File
@@ -37,6 +37,12 @@ This is only meant as a reference for what config options exist, and what their
```yaml
# Config relating to the Lazygit UI
gui:
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
authorColors: {}
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
branchColorPatterns: {}
# Custom icons for filenames and file extensions
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color
customIcons:
@@ -72,7 +78,7 @@ gui:
# If true, do not show a warning when amending a commit.
skipAmendWarning: false
# If true, do not show a warning when discarding changes from a focused diff.
# 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
@@ -142,13 +148,14 @@ gui:
# - 'top': split the window vertically (side panel on top, main view below)
enlargedSideViewLocation: left
# If true, wrap lines in focused diffs to the width of the view. This makes it
# much easier to work with diffs that have long lines, e.g. paragraphs of
# 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.
wrapLinesInDiffView: true
wrapLinesInStagingView: true
# If true, hunk selection mode will be enabled by default when focusing a diff.
useHunkModeInDiffView: true
# If true, hunk selection mode will be enabled by default when entering the
# staging view.
useHunkModeInStagingView: true
# One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko'
# | 'ru' | 'pt'
@@ -162,14 +169,6 @@ gui:
# Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
shortTimeFormat: 3:04PM
# Whether the terminal has a dark or a light background. This decides whether
# 'darkTheme' or 'lightTheme' applies, and the colors of authors are picked to
# stand out against it.
# One of: 'auto' (default) | 'dark' | 'light'
# With 'auto', lazygit asks the terminal, and assumes a dark background if the
# terminal doesn't tell.
colorScheme: auto
# Config relating to colors and styles.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
theme:
@@ -191,22 +190,14 @@ gui:
optionsTextColor:
- blue
# Color and attributes of the text of the selected line. The attributes are
# added to those of the text, and a color replaces the colors of the text.
# Set it to 'default' to leave the text as it is, e.g. if you don't want the
# selected line in bold.
selectedLineFgColor:
- bold
# Background color of selected line.
# Default: 'blue' if the terminal has a dark background, or a suitable RGB blue
# computed from the background color if it is light.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
selectedLineBgColor: []
selectedLineBgColor:
- blue
# Background color of selected line when view doesn't have focus.
# Default: a suitable RGB grey computed from the terminal's background color.
inactiveViewSelectedLineBgColor: []
inactiveViewSelectedLineBgColor:
- bold
# Foreground color of copied commit
cherryPickedCommitFgColor:
@@ -232,22 +223,6 @@ gui:
defaultFgColor:
- default
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
authorColors: {}
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
branchColorPatterns: {}
# Colors and styles that override those in 'theme' when the terminal has a dark
# background. It has the same fields as 'theme'.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
darkTheme: {}
# Colors and styles that override those in 'theme' when the terminal has a light
# background. It has the same fields as 'theme'.
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
lightTheme: {}
# Config relating to the commit length indicator
commitLength:
# If true, show an indicator of commit message length
@@ -300,18 +275,6 @@ gui:
# NerdFontsVersion is not empty.
showFileIcons: true
# How the commit graph is drawn.
# One of: 'auto' (default) | 'classic' | 'detailed'
# 'detailed' connects the lines to the commit circles, and shows exactly where
# branches fork off and merge. It draws the graph with the git branch drawing
# symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself:
# kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with
# GPU acceleration. Other terminals need a font that contains them, such as
# https://github.com/rbong/flog-symbols.
# 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws
# these symbols (kitty and Ghostty), and 'classic' otherwise.
commitGraphStyle: auto
# Length of author name in (non-expanded) commits view. 2 means show initials
# only.
commitAuthorShortLength: 2
@@ -408,6 +371,12 @@ git:
# # renderer command.
# name: ""
#
# # 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'.
@@ -480,9 +449,7 @@ git:
# If not "none", lazygit will automatically fast-forward local branches to match
# their upstream after fetching. Applies to branches that are not the currently
# checked out branch, and only to those that are strictly behind their upstream
# (as opposed to diverged). A branch that is checked out in another worktree is
# fast-forwarded there, unless that worktree has changes to tracked files or is
# in the middle of a rebase or bisect.
# (as opposed to diverged).
# Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
autoForwardBranches: onlyMainBranches
@@ -856,8 +823,6 @@ keybinding:
main:
prevHunk: [<left>, h]
nextHunk: [<right>, l]
prevFile: "N"
nextFile: "n"
toggleSelectHunk: a
pickBothHunks: b
editSelectHunk: E
@@ -948,7 +913,7 @@ It is used, for example, when pasting a commit message into the commit message p
## Configuring File Editing
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file when you invoke it from a focused diff.
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file if you invoke it from the staging panel, for example.
To tell lazygit which editor to use for the `e` command, the easiest way to do that is to provide an editPreset config, e.g.
@@ -1011,7 +976,7 @@ When the selected line gets close to the bottom of the window and you hit down-a
That's the behavior when `gui.scrollOffBehavior` is set to "margin" (the default). If you set `gui.scrollOffBehavior` to "jump", then upon reaching the last line of a view and hitting down-arrow the view will scroll by half a page so that the selection ends up in the middle of the view. This may feel a little jarring because the cursor jumps around when continuously moving down, but it has the advantage that the view doesn't scroll as often.
This setting applies both to all list views (e.g. commits and branches etc), and to focused diffs.
This setting applies both to all list views (e.g. commits and branches etc), and to the staging view.
## Filtering
@@ -1040,7 +1005,6 @@ The available attributes are:
- bold
- default
- dim # faint text; not supported by every terminal
- reverse # useful for high-contrast
- underline
- strikethrough
@@ -1065,61 +1029,28 @@ gui:
- reverse
```
The text of the selected line is bold by default. If you don't want that, set `selectedLineFgColor` to `default`:
```yaml
gui:
theme:
selectedLineFgColor:
- default
```
## Themes for dark and light backgrounds
The colors in `gui.theme` apply whether your terminal has a dark or a light background. If you want different colors for the two, set them in `gui.darkTheme` or `gui.lightTheme`. These have the same fields as `gui.theme`, and a field that you set in them overrides the one in `gui.theme`:
```yaml
gui:
theme:
activeBorderColor:
- green
- bold
lightTheme:
activeBorderColor:
- blue
- bold
```
For `authorColors` and `branchColorPatterns`, each entry overrides the one with the same key in `gui.theme`, and the other entries of `gui.theme` still apply. Branch color patterns of `gui.darkTheme` or `gui.lightTheme` come before those of `gui.theme`.
Lazygit asks the terminal whether its background is dark or light. If your terminal doesn't tell, lazygit assumes a dark background; set `gui.colorScheme` to `light` if yours is light.
## Custom Author Color
Lazygit will assign a random color for every commit author in the commits pane by default.
These colors are picked to be readable against the background of your terminal, and lazygit asks the terminal whether its background is dark or light. If your terminal doesn't tell, lazygit assumes a dark background; set `gui.colorScheme` to `light` if yours is light.
You can customize the color in case you're not happy with the randomly assigned one:
```yaml
gui:
theme:
authorColors:
'John Smith': 'red' # use red for John Smith
'Alan Smithee': '#00ff00' # use green for Alan Smithee
authorColors:
'John Smith': 'red' # use red for John Smith
'Alan Smithee': '#00ff00' # use green for Alan Smithee
```
You can use wildcard to set a unified color in case your are lazy to customize the color for every author or you just want a single color for all/other authors:
```yaml
gui:
theme:
authorColors:
# use red for John Smith
'John Smith': 'red'
# use blue for other authors
'*': '#0000ff'
authorColors:
# use red for John Smith
'John Smith': 'red'
# use blue for other authors
'*': '#0000ff'
```
## Custom Branch Color
@@ -1128,16 +1059,13 @@ You can customize the color of branches based on branch patterns (regular expres
```yaml
gui:
theme:
branchColorPatterns:
'^docs/': '#11aaff' # use a light blue for branches beginning with 'docs/'
'ISSUE-\d+': '#ff5733' # use a bright orange for branches containing 'ISSUE-<some-number>'
branchColorPatterns:
'^docs/': '#11aaff' # use a light blue for branches beginning with 'docs/'
'ISSUE-\d+': '#ff5733' # use a bright orange for branches containing 'ISSUE-<some-number>'
```
Note that the regular expressions are not implicitly anchored to the beginning/end of the branch name. If you want to do that, add leading `^` and/or trailing `$` as needed.
If several patterns match a branch, the first one wins.
## Custom Files Icon & Color
You can customize the icon and color of files based on filenames or extensions:
+8 -17
View File
@@ -23,31 +23,24 @@ 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`), as an array of strings.
The `command` of a `stdinFilter` or `extDiff` renderer is a [Go template](https://pkg.go.dev/text/template) with these variables:
- `{{width}}`: the width of the view that the diff is rendered into.
- `{{colorScheme}}`: `dark` or `light`, depending on whether the terminal has a dark or a light background. Lazygit asks the terminal about this; if yours doesn't tell, set `gui.colorScheme`.
- `{{columnWidth}}` (only for `stdinFilter`): the width of one side of a side-by-side rendering, e.g. for `ydiff -p cat -s -w {{columnWidth}}`.
- `{{diffContext}}` (only for `extDiff`): lazygit's current diff context size, the value controlled by the `{`/`}` keybindings.
A variable can also be written with a leading dot, as in `{{.width}}`. The command can use template expressions too; for example, `delta --paging=never {{if gt .width 160}}--side-by-side{{end}}` shows the diff side by side only when there is room for it, or `delta --syntax-theme={{if eq .colorScheme "light"}}Github{{else}}Dracula{{end}}` picks a different syntax theme based on the background.
- **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 --{{colorScheme}} --paging=never
- command: delta --dark --paging=never
- command: ydiff -p cat
colorArg: never
- type: extDiff
command: difft --color=always --background={{colorScheme}} --context={{diffContext}}
command: difft --color=always --context={{diffContext}}
- type: rawGit
args: [--color-words]
args: --color-words
name: color-words
- type: rawGit # git's default diff
name: default
@@ -58,14 +51,12 @@ git:
```yaml
git:
diffRenderers:
- command: delta --{{colorScheme}} --paging=never
- command: delta --dark --paging=never
```
![](https://i.imgur.com/QJpQkF3.png)
`--{{colorScheme}}` passes `--dark` or `--light` to delta, so that it matches the background of your terminal.
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 --{{colorScheme}} --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.
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.
+1 -5
View File
@@ -4,14 +4,10 @@
Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`.
In the commits view we don't filter, but search; this is deliberate because you typically care about the commits that come before/after a matching commit.
We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit.
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this.
## Menu filtering
The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results.
## Filtering files by status
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.
-33
View File
@@ -16,36 +16,3 @@ branches properly stacked onto it.
Lazygit visualizes the individual branch heads in the stack by marking them with a
cyan asterisk (or a cyan branch symbol if you are using [nerd
fonts](Config.md#display-nerd-fonts-icons)).
When you push the topmost branch of the stack with `P`, and the branches below
it have commits that haven't been pushed yet, lazygit offers to push them along
with it. After rebasing the stack this saves you from checking out and
force-pushing every branch one by one; you are asked to confirm the force push
once for all of them. Only branches that already have an upstream are included.
Each of them is pushed to where `git push` would push it if it were checked out,
so your push configuration applies to them as usual.
When somebody else rebases the stack and force-pushes it, all your branches
show up as diverged, for example `↓5↑3`, even though the commits they are ahead
by are only the old versions of the ones that are now on the remote. Lazygit
tells this apart from a branch that carries work of your own, and shows the
divergence dimmed for such a branch. Pressing `f` on it resets it to its
upstream instead of refusing, so you don't have to check the branch out and pull
it. Lazygit only does this when every commit of the branch was on its remote
branch at some point. It finds that out from the reflog of the remote-tracking
branch. Reflogs are enabled by default, except in a bare repository; if you work
in one with linked worktrees, set `core.logAllRefUpdates` to true there to make
this work.
`f` works on a [range selection](Range_Select.md) too, so you can select the
whole stack and bring all of it back in sync at once. If any of the selected
branches can't be updated, none of them is, so that you don't end up with half
of the stack updated.
Alternatively, check out the topmost branch of the stack and pull it with `p`.
If branches below it can be updated this way, or are simply behind their
upstream, lazygit offers to update them along with it. Lazygit decides this
from the last fetch, so a branch whose changes on the remote haven't been
fetched yet isn't offered; with auto-fetch turned off, pull a second time after
the first pull has fetched them. Branches that are checked out in another
worktree are left alone. The topmost branch itself is pulled as usual.
+1
View File
@@ -31,6 +31,7 @@
* `pkg/gui/keybindings`: Contains code for mapping between keybindings and their labels
* `pkg/gui/mergeconflicts`: Contains code relating to the handling of merge conflicts
* `pkg/gui/modes`: Contains code relating to the state of different modes e.g. cherry picking mode, rebase mode.
* `pkg/gui/patch_exploring`: Contains code relating to the state of patch-oriented views like the staging view.
* `pkg/gui/popup`: Contains code that lets you easily raise popups
* `pkg/gui/presentation`: Contains presentation code i.e. code concerned with rendering content inside views
* `pkg/gui/services/custom_commands`: Contains code related to user-defined custom commands.
+49 -23
View File
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Scroll up main window | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Scroll down main window | |
| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` ) `` | 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'. |
@@ -65,7 +65,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Enter file / Toggle directory collapsed | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -149,7 +149,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | View stash options | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Stage all | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Focus file diff / Collapse directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Stage lines / Collapse directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | Discard | View options for discarding changes to the selected file. |
| `` g `` | View upstream reset options | |
| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). |
@@ -189,7 +189,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | Delete | View delete options for local/remote branch. |
| `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. |
| `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. |
| `` T `` | New tag | |
| `` s `` | Sort order | |
| `` g `` | Reset | |
@@ -222,19 +222,51 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | Scroll down | |
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
## Main panel (patch building)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` o `` | Open file | Open file in default application. |
| `` e `` | Edit file | Open file in external editor. |
| `` <space> `` | Toggle lines in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | Search the current view by text | |
## Main panel (staging)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <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. |
| `` o `` | Open file | Open file in default application. |
| `` e `` | Edit file | Open file in external editor. |
| `` <esc> `` | Return to files panel | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` c `` | Commit | Commit staged changes. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Commit changes using git editor | |
@@ -304,23 +336,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Commit staged changes. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Commit changes using git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Search the current view by text | |
## Stash
+49 -23
View File
@@ -114,7 +114,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 外部差分ツールを開く(git difftool) | |
| `` <space> `` | パッチに含めるファイルを切り替え | ファイルがカスタムパッチに含まれるかどうかを切り替えます。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 |
| `` a `` | すべてのファイルを切り替え | コミットのすべてのファイルをカスタムパッチに追加/削除します。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | ファイルに入る / ディレクトリの折りたたみを切り替える | ファイルが選択されている場合、そのファイルに入ってカスタムパッチに個々の行を追加/削除できます。ディレクトリが選択されている場合、ディレクトリを切り替えます。 |
| `` ` `` | ファイルツリービューを切り替え | ファイル表示をフラット表示とツリー表示で切り替えます。フラット表示はすべてのファイルパスを一覧で表示し、ツリー表示はディレクトリごとにファイルをグループ化します。<br><br>デフォルトは設定ファイル内の 'gui.showFileTree' キーで変更できます。 |
| `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます |
| `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します |
@@ -191,23 +191,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## タグ
@@ -259,6 +253,44 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` 0 `` | メインビューにフォーカス | |
| `` / `` | 現在のビューをテキストでフィルタリング | |
## メインパネル(ステージング)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` v `` | 範囲選択を切り替え | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <esc> `` | ファイルパネルに戻る | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## メインパネル(パッチ作成)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` v `` | 範囲選択を切り替え | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | パッチ内の行を切り替え | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | カスタムパッチビルダーを終了 | |
| `` / `` | 現在のビューをテキストで検索 | |
## メインパネル(マージ中)
| Key | Action | Info |
@@ -281,23 +313,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 下にスクロール | |
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## メニュー
+49 -23
View File
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | |
| `` @ `` | 명령어 로그 메뉴 열기 | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
| `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
| `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` ) `` | 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'. |
| `` } `` | Diff 보기의 변경 사항 주위에 표시되는 컨텍스트의 크기를 늘리기 | 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'. |
@@ -83,23 +83,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 검색 시작 | |
## Stash
@@ -176,19 +170,51 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 아래로 스크롤 | |
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Patch Building)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` v `` | 드래그 선택 전환 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` o `` | 파일 닫기 | Open file in default application. |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <space> `` | Line(s)을 패치에 추가/삭제 | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Staging)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` v `` | 드래그 선택 전환 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <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. |
| `` o `` | 파일 닫기 | Open file in default application. |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <esc> `` | 파일 목록으로 돌아가기 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
@@ -215,7 +241,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | 삭제 | View delete options for local/remote branch. |
| `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. |
| `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. |
| `` T `` | 태그를 생성 | |
| `` s `` | Sort order | |
| `` g `` | View reset options | |
@@ -337,7 +363,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files included in patch | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Enter file to add selected lines to the patch (or toggle directory collapsed) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -387,7 +413,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Stash 옵션 보기 | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | 모든 변경을 Staged/unstaged으로 전환 | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | View 'discard changes' options | View options for discarding changes to the selected file. |
| `` g `` | View upstream reset options | |
| `` D `` | 초기화 | View reset options for working tree (e.g. nuking the working tree). |
+49 -23
View File
@@ -72,7 +72,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Bekijk stash opties | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Toggle staged alle | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage individuele hunks/lijnen | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Stage individuele hunks/lijnen | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | Bekijk 'veranderingen ongedaan maken' opties | View options for discarding changes to the selected file. |
| `` g `` | Bekijk upstream reset opties | |
| `` D `` | Resetten | View reset options for working tree (e.g. nuking the working tree). |
@@ -113,7 +113,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | Verwijderen | View delete options for local/remote branch. |
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. |
| `` T `` | Creëer tag | |
| `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | |
@@ -144,7 +144,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open externe diff applicatie (git difftool) | |
| `` <space> `` | Toggle bestand inbegrepen in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Enter bestand om geselecteerde regels toe te voegen aan de patch | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Vouw alle bestanden uit | Vouw alle mappen in de bestandsstructuur uit |
@@ -230,23 +230,33 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | Scroll omlaag | |
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
| `` w `` | Commit veranderingen zonder pre-commit hook | |
| `` C `` | Commit veranderingen met de git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Start met zoeken | |
## Patch bouwen
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` 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. |
| `` <space> `` | Voeg toe/verwijder lijn(en) in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sluit lijn-bij-lijn modus | |
| `` / `` | Start met zoeken | |
## Reflog
@@ -279,7 +289,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` w `` | New worktree | |
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
| `` d `` | Verwijderen | Verwijder de remote branch van de remote. |
| `` d `` | Verwijderen | Delete the remote branch from the remote. |
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
| `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. |
@@ -294,7 +304,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <enter> `` | Bekijk branches | |
| `` n `` | Voeg een nieuwe remote toe | |
| `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. |
| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | Edit | Wijzig remote |
| `` f `` | Fetch | Fetch remote |
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
@@ -304,19 +314,35 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
## Staging
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` 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. |
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <esc> `` | Ga terug naar het bestanden paneel | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
| `` w `` | Commit veranderingen zonder pre-commit hook | |
| `` C `` | Commit veranderingen met de git editor | |
+49 -23
View File
@@ -98,23 +98,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <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. |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Drzewa pracy
@@ -147,6 +141,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <enter> `` | Pokaż commity | |
| `` / `` | Filtruj bieżący widok po tekście | |
## Główny panel (budowanie łatki)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <space> `` | Przełącz linie w łatce | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Wyjdź z budowniczego niestandardowej łatki | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Input prompt
| Key | Action | Info |
@@ -199,23 +209,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | Przewiń w dół | |
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <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. |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Panel główny (scalanie)
@@ -234,6 +238,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Wróć do panelu plików | |
## Panel główny (zatwierdzanie)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <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. |
| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <esc> `` | Wróć do panelu plików | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Panel potwierdzenia
| Key | Action | Info |
@@ -288,7 +314,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | |
| `` <space> `` | Przełącz plik włączony w łatkę | Przełącz, czy plik jest włączony w niestandardową łatkę. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Przełącz wszystkie pliki | Dodaj/usuń wszystkie pliki commita do niestandardowej łatki. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Wejdź do pliku / Przełącz zwiń katalog | Jeśli plik jest wybrany, wejdź do pliku, aby móc dodawać/usuwać poszczególne linie do niestandardowej łatki. Jeśli wybrany jest katalog, przełącz katalog. |
| `` ` `` | Przełącz widok drzewa plików | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
+45 -19
View File
@@ -148,7 +148,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Abrir ferramenta de diff externa (git difftool) | |
| `` <space> `` | Alternar entre o arquivo incluído no patch | Alternar se o arquivo está incluído no patch personalizado. Veja https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Alternar todos os arquivos | Adicionar/remover todos os arquivos de commit para atualização personalizada. Consulte https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Insira o arquivo / Alternar diretório recolhido | Se um arquivo estiver selecionado, insira o arquivo para que você possa adicionar/remover linhas individuais no patch personalizado. Se um diretório for selecionado, ative o diretório. |
| `` ` `` | Alternar exibição de árvore de arquivo | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos |
| `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo |
@@ -234,19 +234,35 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | Rolar para baixo | |
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <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. |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Painel Principal (preparação)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` <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. |
| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <esc> `` | Retornar ao painel de arquivos | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` c `` | Commit | Submeter mudanças em staging |
| `` w `` | Fazer commit de alterações sem pré-commit | |
| `` C `` | Enviar alteração usando um editor Git | |
@@ -277,6 +293,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Retornar ao painel de arquivos | |
## Painel principal (patch build)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <space> `` | Alternar linhas no caminho | |
| `` d `` | Remover linhas do commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sair do construtor de patch personalizado | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Reflog
| Key | Action | Info |
@@ -313,23 +345,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <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. |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Submeter mudanças em staging |
| `` w `` | Fazer commit de alterações sem pré-commit | |
| `` C `` | Enviar alteração usando um editor Git | |
| `` <ctrl+f> `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado<br>Veja a documentação:<br><https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Pesquisar na visualização atual por texto | |
## Stash
+49 -23
View File
@@ -10,8 +10,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Прокрутить вверх главную панель | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Прокрутить вниз главную панель | |
| `` @ `` | Открыть меню журнала команд | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have commits to push, you are offered to push those too. |
| `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. If other branches are stacked below the current one and have changed on the remote, you are offered to update those too. |
| `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. |
| `` ) `` | 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 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'. |
@@ -73,19 +73,35 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <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. |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
## Главная панель (Индексирование)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` v `` | Переключить выборку перетаскивания | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | Открыть файл | Open file in default application. |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <esc> `` | Вернуться к панели файлов | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` c `` | Сохранить изменения | Commit staged changes. |
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
| `` C `` | Сохранить изменения с помощью редактора git | |
@@ -98,23 +114,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | Прокрутить вниз | |
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <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. |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Сохранить изменения | Commit staged changes. |
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
| `` C `` | Сохранить изменения с помощью редактора git | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Найти | |
## Главная панель (Слияние)
@@ -133,6 +143,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Вернуться к панели файлов | |
## Главная панель (сборка патчей)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` v `` | Переключить выборку перетаскивания | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` o `` | Открыть файл | Open file in default application. |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Добавить/удалить строку(и) для патча | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Выйти из сборщика пользовательских патчей | |
| `` / `` | Найти | |
## Журнал ссылок (Reflog)
| Key | Action | Info |
@@ -215,7 +241,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | Delete | View delete options for local/remote branch. |
| `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. |
| `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. |
| `` T `` | Создать тег | |
| `` s `` | Порядок сортировки | |
| `` g `` | Просмотреть параметры сброса | |
@@ -296,7 +322,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Переключить файлы включённые в патч | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Переключить все файлы, включённые в патч | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Введите файл, чтобы добавить выбранные строки в патч (или свернуть каталог переключения) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -381,7 +407,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Просмотреть параметры хранилища | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Все проиндексированные/непроиндексированные | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | Просмотреть параметры «отмены изменении» | View options for discarding changes to the selected file. |
| `` g `` | Просмотреть параметры сброса upstream-ветки | |
| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). |
+49 -23
View File
@@ -178,7 +178,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 使用外部差异比较工具(git difftool) | |
| `` <space> `` | 补丁中包含的切换文件 | 切换文件是否包含在自定义补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` a `` | 操作所有文件 | 添加或删除所有提交中的文件到自定义的补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` <enter> `` | 输入文件以将所选行添加到补丁中(或切换目录折叠) | 如果已选择一个文件,则Enter进入该文件,以便您可以向自定义补丁添加/删除单独的行。如果选择了目录,则切换目录。 |
| `` ` `` | 切换文件树视图 | 在平面布局和树布局之间切换文件视图。平面布局在单个列表中显示所有文件路径,树布局按目录分组文件。<br><br>可以在配置文件中使用 'gui.showFileTree' 键更改默认设置。 |
| `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 |
| `` = `` | 展开全部文件 | 展开文件树中的全部目录 |
@@ -249,6 +249,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <enter> `` | 查看提交 | |
| `` / `` | 通过文本过滤当前视图 | |
## 构建补丁中
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` v `` | 切换拖动选择 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` o `` | 打开文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 添加/移除 行到补丁 | |
| `` d `` | 从提交中移除行 | 从本次提交中移除所选行。此操作会在后台运行交互式变基,因此如果后续提交也修改了这些行,您可能会遇到合并冲突。 |
| `` <esc> `` | 退出逐行模式 | |
| `` / `` | 开始搜索 | |
## 标签
| Key | Action | Info |
@@ -269,23 +285,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 正在合并
@@ -304,29 +314,45 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | 查看合并冲突选项 | 查看用于解决合并冲突的选项。 |
| `` <esc> `` | 返回文件面板 | |
## 正在暂存
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` v `` | 切换拖动选择 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` o `` | 打开文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <esc> `` | 返回文件面板 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 正常
| Key | Action | Info |
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 状态
+177 -151
View File
@@ -9,28 +9,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+r> `` | 切換到最近使用的版本庫 | |
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 向下捲動主面板 | |
| `` @ `` | 開啟命令記錄選單 | 檢視命令日誌的選項,例如顯示/隱藏命令日誌以及聚焦命令日誌。 |
| `` @ `` | 開啟命令記錄選單 | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 |
| `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 |
| `` ) `` | 提高重新命名相似度閾值 | 提高將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
| `` ( `` | 降低重新命名相似度閾值 | 降低將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 增加差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | 減少差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
| `` : `` | 執行 Shell 命令 | 調出可輸入shell命令執行的提示符。 |
| `` ) `` | 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 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 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. |
| `` <ctrl+p> `` | 檢視自訂補丁選項 | |
| `` m `` | 查看合併/變基選項 | 檢視目前合併或變基的中止、繼續、跳過選項。 |
| `` R `` | 重新整理 | 重新整理Git狀態(即在背景執行`git status`、`git branch`等命令以更新面板內容)。此操作不會執行`git fetch`。 |
| `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. |
| `` 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 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> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 檢視與比較兩個引用相關的選項,例如與選定的 ref 進行比較,輸入要比較的 ref,然後反轉比較方向。 |
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 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> `` | 結束 | |
| `` <ctrl+z> `` | 掛起應用程式 | |
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 切換是否在差異檢視中顯示空白字元更改。<br><br>預設值可在設定檔中透過鍵 'git.ignoreWhitespaceInDiffView' 更改。 |
| `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 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> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
@@ -44,37 +44,54 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <, <home> `` | 捲動到頂部 | |
| `` >, <end> `` | 捲動到底部 | |
| `` v `` | 切換拖曳選擇 | |
| `` <shift+down> `` | 向下擴充套件選擇範圍 | |
| `` <shift+up> `` | 向上擴充套件選擇範圍 | |
| `` <shift+down> `` | Range select down | |
| `` <shift+up> `` | Range select up | |
| `` / `` | 搜尋 | |
| `` H `` | 向左捲動 | |
| `` L `` | 向右捲動 | |
| `` ] `` | 下一個索引標籤 | |
| `` [ `` | 上一個索引標籤 | |
## Input prompt
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 確認 | |
| `` <esc> `` | 關閉/取消 | |
## 主面板 (補丁生成)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | 退出自訂補丁建立器 | |
| `` / `` | 搜尋 | |
## 主面板(一般)
| Key | Action | Info |
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` 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 | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 主面板(合併)
@@ -82,17 +99,39 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <space> `` | 挑選程式碼片段 | |
| `` b `` | 選取兩個區塊 | |
| `` b `` | Pick both hunks | |
| `` <up>, k `` | 選擇上一段 | |
| `` <down>, j `` | 選擇下一段 | |
| `` <left>, h `` | 選擇上一個衝突 | |
| `` <right>, l `` | 選擇下一個衝突 | |
| `` z `` | 復原 | 撤消上次合併衝突解決。 |
| `` z `` | 復原 | Undo last merge conflict resolution. |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | 返回檔案面板 | |
## 主面板(預存)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <esc> `` | 返回檔案面板 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 功能表
| Key | Action | Info |
@@ -105,19 +144,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
| `` w `` | 新建工作樹 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | |
| `` 0 `` | 聚焦主檢視 | |
| `` * `` | Select commits of current branch | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | |
@@ -126,12 +165,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | |
| `` <enter> `` | 進入 | 進入子模組 |
| `` d `` | 刪除 | 刪除選定的子模組及其相應的目錄。 |
| `` u `` | 更新 | 更新子模組 |
| `` <enter> `` | Enter | 進入子模組 |
| `` d `` | Remove | Remove the selected submodule and its corresponding directory. |
| `` u `` | Update | 更新子模組 |
| `` n `` | 新增子模組 | |
| `` e `` | 更新子模組 URL | |
| `` i `` | 初始化 | 初始化子模組 |
| `` i `` | Initialize | 初始化子模組 |
| `` b `` | 查看批量子模組選項 | |
| `` / `` | 搜尋 | |
@@ -139,27 +178,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` n `` | 新建工作樹 | |
| `` <space> `` | 切換 | 切換到選中的工作樹。 |
| `` n `` | New worktree | |
| `` <space> `` | Switch | Switch to the selected worktree. |
| `` o `` | 在編輯器中開啟 | |
| `` d `` | 刪除 | 刪除選定的工作樹。這將刪除工作樹的目錄以及 .git 目錄中有關工作樹的後設資料。 |
| `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. |
| `` / `` | 搜尋 | |
## 提交
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` b `` | 查看二分選項 | |
| `` s `` | 壓縮 (Squash) | 將已選提交壓縮到該提交之下。這些選定的提交的訊息會附加到該提交的訊息之下。 |
| `` f `` | 修復 (Fixup) | 將選定的提交合併到其下面的提交中。與壓縮類似,但所選提交的訊息將被丟棄。 |
| `` c `` | 設定修復提交資訊 | 設定修復提交的資訊選項。-C 選項表示使用此提交的資訊,而非目標提交的資訊。 |
| `` 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 `` | 改寫提交 | 改寫選中的提交訊息 |
| `` R `` | 使用編輯器改寫提交 | |
| `` d `` | 刪除提交 | 刪除選中的提交。這將透過變基從分支中刪除該提交,如果該提交修改的內容依賴於後續的提交,則需要解決合併衝突。 |
| `` d `` | 刪除提交 | 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 `` | 編輯(開始互動變基) | 編輯提交 |
| `` i `` | 開始互動變基 | 為分支上的提交啟動互動式變基。這將包括從 HEAD 提交到第一個合併提交或主分支提交的所有提交。<br>如果您想從所選提交啟動互動式變基,請按 `e`。 |
| `` i `` | 開始互動變基 | 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 `` | 挑選 | 挑選提交 (於變基過程中) |
| `` F `` | 建立修復提交 | 為此提交建立修復提交 |
| `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? |
@@ -168,22 +207,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` V `` | 貼上提交 (揀選) | |
| `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 |
| `` A `` | 修改 | 使用已預存的更改修正提交 |
| `` a `` | 設定/重設提交作者 | 設定或重置提交的作者,或新增其他作者。 |
| `` t `` | 還原 | 為所選提交建立還原提交,這會反向應用所選提交的更改。 |
| `` T `` | 打標籤到提交 | 建立一個新標籤指向所選提交。您可以在彈窗中輸入標籤名稱和描述(可選)。 |
| `` <ctrl+l> `` | 開啟記錄選單 | 檢視提交日誌的選項,例如更改排序順序、隱藏 git graph、顯示整個 git graph。 |
| `` G `` | 在瀏覽器中開啟拉取請求 | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
| `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. |
| `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <ctrl+l> `` | 開啟記錄選單 | 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> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
| `` w `` | 新建工作樹 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | |
| `` 0 `` | 聚焦主檢視 | |
| `` * `` | Select commits of current branch | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | |
@@ -201,30 +240,30 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
| `` y `` | 複製到剪貼簿 | |
| `` c `` | 檢出 | 檢出檔案 |
| `` d `` | 捨棄 | 放棄對此檔案的提交變更。 |
| `` d `` | 捨棄 | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯 | 使用外部編輯器開啟 |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
| `` 0 `` | 聚焦主檢視 | |
| `` <space> `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | 切換所有檔案是否包含在補丁中 | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | 搜尋 | |
## 收藏 (Stash)
| Key | Action | Info |
|-----|--------|-------------|
| `` <space> `` | 套用 | 將貯藏項應用到您的工作目錄。 |
| `` g `` | 還原 | 將儲存項應用到工作目錄並刪除儲存項。 |
| `` d `` | 捨棄 | 從貯藏列表中刪除該貯藏項。 |
| `` n `` | 新分支 | 從選定的貯藏項建立一個新分支。這是透過 git 檢查建立貯藏項的提交,從該提交建立一個新分支,然後將貯藏項作為附加提交應用到新分支來實現的。 |
| `` w `` | 新建工作樹 | |
| `` <space> `` | 套用 | Apply the stash entry to your working directory. |
| `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. |
| `` d `` | 捨棄 | Remove the stash entry from the stash list. |
| `` n `` | 新分支 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. |
| `` w `` | New worktree | |
| `` r `` | 重新命名收藏 | |
| `` 0 `` | 聚焦主檢視 | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | |
@@ -232,19 +271,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
| `` w `` | 新建工作樹 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | |
| `` 0 `` | 聚焦主檢視 | |
| `` * `` | Select commits of current branch | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | |
@@ -256,18 +295,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` i `` | 顯示 git-flow 選項 | |
| `` <space> `` | 檢出 | 檢出選定的項目。 |
| `` n `` | 新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
| `` w `` | 新建工作樹 | |
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` w `` | New worktree | |
| `` o `` | 建立拉取請求 | |
| `` O `` | 建立拉取請求選項 | |
| `` G `` | 在瀏覽器中開啟拉取請求 | |
| `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | |
| `` c `` | 根據名稱檢出 | 按名稱檢出。在輸入框中,您可以輸入'-' 來切換到最後一個分支。 |
| `` - `` | 簽出上一個分支 | |
| `` F `` | 強制檢出 | 強制檢出所選分支。這將在檢出所選分支之前放棄工作目錄中的所有本地更改。 |
| `` d `` | 刪除 | 檢視本地/遠端分支的刪除選項。 |
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
| `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
| `` d `` | 刪除 | View delete options for local/remote branch. |
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
| `` T `` | 建立標籤 | |
| `` s `` | 排序規則 | |
@@ -275,7 +314,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 重新命名分支 | |
| `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | |
@@ -283,15 +322,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製標籤到剪貼簿 | |
| `` <space> `` | 檢出 | 檢出選擇的標籤作為分離的HEAD。 |
| `` n `` | 建立標籤 | 基於目前提交建立一個新標籤。您將在彈窗中輸入標籤名稱和描述(可選)。 |
| `` w `` | 新建工作樹 | |
| `` d `` | 刪除 | 檢視本機/遠端標籤的刪除選項。 |
| `` P `` | 推送標籤 | 推送選擇的標籤到遠端。您將在彈窗中選擇一個遠端。 |
| `` g `` | 重設 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` <ctrl+o> `` | Copy tag to clipboard | |
| `` <space> `` | 檢出 | Checkout the selected tag as a detached HEAD. |
| `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
| `` w `` | New worktree | |
| `` d `` | 刪除 | View delete options for local/remote tag. |
| `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. |
| `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | |
@@ -300,55 +339,49 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
| `` <space> `` | 切換預存 | 切換所選檔案的暫存狀態。 |
| `` <space> `` | 切換預存 | Toggle staged for selected file. |
| `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | |
| `` y `` | 複製到剪貼簿 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` A `` | 修改上次提交 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` e `` | 編輯 | 使用外部編輯器開啟 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` i `` | 忽略或排除檔案 | |
| `` r `` | 重新整理檔案 | |
| `` s `` | 收藏 | 貯藏所有變更.若要使用其他貯藏變體,請使用檢視貯藏選項快捷鍵。 |
| `` S `` | 檢視收藏選項 | 檢視貯藏選項(例如:貯藏所有、貯藏已暫存變更、貯藏未暫存變更)。 |
| `` a `` | 全部預存/取消預存 | 切換工作區中所有檔案的已暫存/未暫存狀態。 |
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | 如果選中的是一個檔案,則會進入到暫存檢視,以便可以暫存單個程式碼塊/行。如果選中的是一個目錄,則會摺疊/展開這個目錄。 |
| `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 |
| `` g `` | 檢視遠端重設選項 | |
| `` D `` | 重設 | 檢視工作樹的重置選項(例如:清除工作樹)。 |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
| `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). |
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` f `` | 擷取 | 同步遠端異動 |
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
| `` 0 `` | 聚焦主檢視 | |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | 搜尋 | |
## 次要
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` 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 | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 狀態
@@ -358,9 +391,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` u `` | 檢查更新 | |
| `` <enter> `` | 切換到最近使用的版本庫 | |
| `` a `` | 顯示/迴圈所有分支日誌 | |
| `` A `` | 顯示/迴圈所有分支日誌(反向) | |
| `` 0 `` | 聚焦主檢視 | |
| `` a `` | Show/cycle all branch logs | |
| `` A `` | Show/cycle all branch logs (reverse) | |
| `` 0 `` | Focus main view | |
## 確認面板
@@ -370,23 +403,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | 關閉/取消 | |
| `` <ctrl+o> `` | 複製到剪貼簿 | |
## 輸入提示
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 確認 | |
| `` <esc> `` | 關閉/取消 | |
## 遠端
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 檢視分支 | |
| `` <enter> `` | View branches | |
| `` n `` | 新增遠端 | |
| `` d `` | 刪除 | 刪除選中的遠端。從遠端跟蹤遠端分支的任何本地分支都不會受到影響。 |
| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | 編輯 | 編輯遠端 |
| `` f `` | 擷取 | 擷取遠端 |
| `` F `` | 新增復刻遠端倉庫 | 透過替換 origin URL 中的所有者來快速新增復刻遠端倉庫,並可選擇從新遠端倉庫檢出分支。 |
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
| `` / `` | 搜尋 | |
## 遠端分支
@@ -394,16 +420,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | |
| `` <space> `` | 檢出 | 基於目前選中的遠端分支檢出一個新的本地分支,或者將遠端分支作分離的HEAD。 |
| `` <space> `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. |
| `` n `` | 新分支 | |
| `` w `` | 新建工作樹 | |
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
| `` d `` | 刪除 | 從遠端刪除遠端分支。 |
| `` w `` | New worktree | |
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
| `` d `` | 刪除 | Delete the remote branch from the remote. |
| `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 |
| `` s `` | 排序規則 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | |
+2 -2
View File
@@ -27,7 +27,7 @@ Fields only for `extDiff`:
Fields only for `rawGit`:
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
- **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:
@@ -40,7 +40,7 @@ git:
- type: extDiff
command: difft --color=always --context={{diffContext}}
- type: rawGit
args: [--color-words]
args: --color-words
name: color-words
- type: rawGit # git's default diff
name: default
+1 -5
View File
@@ -4,14 +4,10 @@
Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`.
In the commits view we don't filter, but search; this is deliberate because you typically care about the commits that come before/after a matching commit.
We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit.
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this.
## Menu filtering
The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results.
## Filtering files by status
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.
+2 -2
View File
@@ -280,7 +280,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` w `` | New worktree | |
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
| `` d `` | Verwijderen | Verwijder de remote branch van de remote. |
| `` d `` | Verwijderen | Delete the remote branch from the remote. |
| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch |
| `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. |
@@ -295,7 +295,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <enter> `` | Bekijk branches | |
| `` n `` | Voeg een nieuwe remote toe | |
| `` d `` | Verwijderen | Verwijder de geselecteerde remote. Locale branches die een branch tracken van de remote worden niet aangepast. |
| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | Edit | Wijzig remote |
| `` f `` | Fetch | Fetch remote |
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
+138 -138
View File
@@ -9,28 +9,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+r> `` | 切換到最近使用的版本庫 | |
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | 向上捲動主面板 | |
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | 向下捲動主面板 | |
| `` @ `` | 開啟命令記錄選單 | 檢視命令日誌的選項,例如顯示/隱藏命令日誌以及聚焦命令日誌。 |
| `` @ `` | 開啟命令記錄選單 | View options for the command log e.g. show/hide the command log and focus the command log. |
| `` P `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 |
| `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 |
| `` ) `` | 提高重新命名相似度閾值 | 提高將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
| `` ( `` | 降低重新命名相似度閾值 | 降低將刪除和新增對視為重新命名所需的相似度閾值。<br><br>預設值可在設定檔中透過鍵 'git.renameSimilarityThreshold' 更改。 |
| `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 增加差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
| `` { `` | 減小差異檢視中顯示變更周圍上下文的大小 | 減少差異檢視中變更周圍顯示的上下文量。<br><br>預設值可在設定檔中透過鍵 'git.diffContextSize' 更改。 |
| `` : `` | 執行 Shell 命令 | 調出可輸入shell命令執行的提示符。 |
| `` ) `` | 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 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 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. |
| `` <ctrl+p> `` | 檢視自訂補丁選項 | |
| `` m `` | 查看合併/變基選項 | 檢視目前合併或變基的中止、繼續、跳過選項。 |
| `` R `` | 重新整理 | 重新整理Git狀態(即在背景執行`git status`、`git branch`等命令以更新面板內容)。此操作不會執行`git fetch`。 |
| `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. |
| `` 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 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> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 檢視與比較兩個引用相關的選項,例如與選定的 ref 進行比較,輸入要比較的 ref,然後反轉比較方向。 |
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
| `` W, <ctrl+e> `` | 開啟差異比較選單 | 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> `` | 結束 | |
| `` <ctrl+z> `` | 掛起應用程式 | |
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 切換是否在差異檢視中顯示空白字元更改。<br><br>預設值可在設定檔中透過鍵 'git.ignoreWhitespaceInDiffView' 更改。 |
| `` <ctrl+z> `` | Suspend the application | |
| `` <ctrl+w> `` | 切換是否在差異檢視中顯示空格變更 | 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> `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 |
| `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 |
@@ -44,14 +44,21 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <, <home> `` | 捲動到頂部 | |
| `` >, <end> `` | 捲動到底部 | |
| `` v `` | 切換拖曳選擇 | |
| `` <shift+down> `` | 向下擴充套件選擇範圍 | |
| `` <shift+up> `` | 向上擴充套件選擇範圍 | |
| `` <shift+down> `` | Range select down | |
| `` <shift+up> `` | Range select up | |
| `` / `` | 搜尋 | |
| `` H `` | 向左捲動 | |
| `` L `` | 向右捲動 | |
| `` ] `` | 下一個索引標籤 | |
| `` [ `` | 上一個索引標籤 | |
## Input prompt
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 確認 | |
| `` <esc> `` | 關閉/取消 | |
## 主面板 (補丁生成)
| Key | Action | Info |
@@ -59,12 +66,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | 退出自訂補丁建立器 | |
| `` / `` | 搜尋 | |
@@ -74,8 +81,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` <esc> `` | 退出回到側邊面板 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 搜尋 | |
## 主面板(合併)
@@ -83,15 +90,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <space> `` | 挑選程式碼片段 | |
| `` b `` | 選取兩個區塊 | |
| `` b `` | Pick both hunks | |
| `` <up>, k `` | 選擇上一段 | |
| `` <down>, j `` | 選擇下一段 | |
| `` <left>, h `` | 選擇上一個衝突 | |
| `` <right>, l `` | 選擇下一個衝突 | |
| `` z `` | 復原 | 撤消上次合併衝突解決。 |
| `` z `` | 復原 | Undo last merge conflict resolution. |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | 返回檔案面板 | |
## 主面板(預存)
@@ -101,19 +108,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <esc> `` | 返回檔案面板 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` E `` | 編輯程式碼塊 | Edit selected hunk in external editor. |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 功能表
@@ -128,19 +135,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
| `` w `` | 新建工作樹 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | |
| `` 0 `` | 聚焦主檢視 | |
| `` * `` | Select commits of current branch | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | |
@@ -149,12 +156,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製子模組名稱到剪貼簿 | |
| `` <enter> `` | 進入 | 進入子模組 |
| `` d `` | 刪除 | 刪除選定的子模組及其相應的目錄。 |
| `` u `` | 更新 | 更新子模組 |
| `` <enter> `` | Enter | 進入子模組 |
| `` d `` | Remove | Remove the selected submodule and its corresponding directory. |
| `` u `` | Update | 更新子模組 |
| `` n `` | 新增子模組 | |
| `` e `` | 更新子模組 URL | |
| `` i `` | 初始化 | 初始化子模組 |
| `` i `` | Initialize | 初始化子模組 |
| `` b `` | 查看批量子模組選項 | |
| `` / `` | 搜尋 | |
@@ -162,27 +169,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` n `` | 新建工作樹 | |
| `` <space> `` | 切換 | 切換到選中的工作樹。 |
| `` n `` | New worktree | |
| `` <space> `` | Switch | Switch to the selected worktree. |
| `` o `` | 在編輯器中開啟 | |
| `` d `` | 刪除 | 刪除選定的工作樹。這將刪除工作樹的目錄以及 .git 目錄中有關工作樹的後設資料。 |
| `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. |
| `` / `` | 搜尋 | |
## 提交
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` b `` | 查看二分選項 | |
| `` s `` | 壓縮 (Squash) | 將已選提交壓縮到該提交之下。這些選定的提交的訊息會附加到該提交的訊息之下。 |
| `` f `` | 修復 (Fixup) | 將選定的提交合併到其下面的提交中。與壓縮類似,但所選提交的訊息將被丟棄。 |
| `` c `` | 設定修復提交資訊 | 設定修復提交的資訊選項。-C 選項表示使用此提交的資訊,而非目標提交的資訊。 |
| `` 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 `` | 改寫提交 | 改寫選中的提交訊息 |
| `` R `` | 使用編輯器改寫提交 | |
| `` d `` | 刪除提交 | 刪除選中的提交。這將透過變基從分支中刪除該提交,如果該提交修改的內容依賴於後續的提交,則需要解決合併衝突。 |
| `` d `` | 刪除提交 | 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 `` | 編輯(開始互動變基) | 編輯提交 |
| `` i `` | 開始互動變基 | 為分支上的提交啟動互動式變基。這將包括從 HEAD 提交到第一個合併提交或主分支提交的所有提交。<br>如果您想從所選提交啟動互動式變基,請按 `e`。 |
| `` i `` | 開始互動變基 | 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 `` | 挑選 | 挑選提交 (於變基過程中) |
| `` F `` | 建立修復提交 | 為此提交建立修復提交 |
| `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? |
@@ -191,22 +198,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` V `` | 貼上提交 (揀選) | |
| `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 |
| `` A `` | 修改 | 使用已預存的更改修正提交 |
| `` a `` | 設定/重設提交作者 | 設定或重置提交的作者,或新增其他作者。 |
| `` t `` | 還原 | 為所選提交建立還原提交,這會反向應用所選提交的更改。 |
| `` T `` | 打標籤到提交 | 建立一個新標籤指向所選提交。您可以在彈窗中輸入標籤名稱和描述(可選)。 |
| `` <ctrl+l> `` | 開啟記錄選單 | 檢視提交日誌的選項,例如更改排序順序、隱藏 git graph、顯示整個 git graph。 |
| `` G `` | 在瀏覽器中開啟拉取請求 | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
| `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. |
| `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
| `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
| `` <ctrl+l> `` | 開啟記錄選單 | 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> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
| `` w `` | 新建工作樹 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | |
| `` 0 `` | 聚焦主檢視 | |
| `` * `` | Select commits of current branch | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | |
@@ -224,30 +231,30 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
| `` y `` | 複製到剪貼簿 | |
| `` c `` | 檢出 | 檢出檔案 |
| `` d `` | 捨棄 | 放棄對此檔案的提交變更。 |
| `` d `` | 捨棄 | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯 | 使用外部編輯器開啟 |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案,則Enter進入該檔案,以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
| `` 0 `` | 聚焦主檢視 | |
| `` <space> `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | 切換所有檔案是否包含在補丁中 | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | 搜尋 | |
## 收藏 (Stash)
| Key | Action | Info |
|-----|--------|-------------|
| `` <space> `` | 套用 | 將貯藏項應用到您的工作目錄。 |
| `` g `` | 還原 | 將儲存項應用到工作目錄並刪除儲存項。 |
| `` d `` | 捨棄 | 從貯藏列表中刪除該貯藏項。 |
| `` n `` | 新分支 | 從選定的貯藏項建立一個新分支。這是透過 git 檢查建立貯藏項的提交,從該提交建立一個新分支,然後將貯藏項作為附加提交應用到新分支來實現的。 |
| `` w `` | 新建工作樹 | |
| `` <space> `` | 套用 | Apply the stash entry to your working directory. |
| `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. |
| `` d `` | 捨棄 | Remove the stash entry from the stash list. |
| `` n `` | 新分支 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. |
| `` w `` | New worktree | |
| `` r `` | 重新命名收藏 | |
| `` 0 `` | 聚焦主檢視 | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視所選項目的檔案 | |
| `` / `` | 搜尋 | |
@@ -255,19 +262,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製縮略提交雜湊值到剪貼簿 | |
| `` <space> `` | 檢出 | 檢出所選擇的提交作為分離HEAD。 |
| `` y `` | 複製提交屬性 | 複製提交屬性到剪貼簿(如hash、URL、diff、訊息、作者)。 |
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
| `` <space> `` | 檢出 | Checkout the selected commit as a detached HEAD. |
| `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
| `` o `` | 在瀏覽器中開啟提交 | |
| `` n `` | 從提交建立新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
| `` w `` | 新建工作樹 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` C `` | 複製提交 (揀選) | 標記提交為已複製。然後,在本地提交檢視中,您可以按 `V` (Cherry-Pick) 將已複製的提交貼上到已檢出的分支中。任何時候都可以按 `<esc>` 來取消選擇。 |
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` w `` | New worktree | |
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `<esc>` to cancel the selection. |
| `` <ctrl+r> `` | 重設選定的揀選 (複製) 提交 | |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` * `` | 選擇目前分支的提交 | |
| `` 0 `` | 聚焦主檢視 | |
| `` * `` | Select commits of current branch | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | |
@@ -279,18 +286,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` i `` | 顯示 git-flow 選項 | |
| `` <space> `` | 檢出 | 檢出選定的項目。 |
| `` n `` | 新分支 | |
| `` N `` | 移動提交至新分支 | 建立一個新分支,並將目前分支未推送的提交移動到該分支。如果您打算開始新工作但忘記先建立新分支,這會很有用。<br><br>請注意,此操作忽略選擇,新分支總是從主分支建立或堆疊在目前分支之上(您可以選擇哪種方式)。 |
| `` w `` | 新建工作樹 | |
| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.<br><br>Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). |
| `` w `` | New worktree | |
| `` o `` | 建立拉取請求 | |
| `` O `` | 建立拉取請求選項 | |
| `` G `` | 在瀏覽器中開啟拉取請求 | |
| `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | |
| `` c `` | 根據名稱檢出 | 按名稱檢出。在輸入框中,您可以輸入'-' 來切換到最後一個分支。 |
| `` - `` | 簽出上一個分支 | |
| `` F `` | 強制檢出 | 強制檢出所選分支。這將在檢出所選分支之前放棄工作目錄中的所有本地更改。 |
| `` d `` | 刪除 | 檢視本地/遠端分支的刪除選項。 |
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
| `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |
| `` d `` | 刪除 | View delete options for local/remote branch. |
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 |
| `` T `` | 建立標籤 | |
| `` s `` | 排序規則 | |
@@ -298,7 +305,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` R `` | 重新命名分支 | |
| `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | |
@@ -306,15 +313,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製標籤到剪貼簿 | |
| `` <space> `` | 檢出 | 檢出選擇的標籤作為分離的HEAD。 |
| `` n `` | 建立標籤 | 基於目前提交建立一個新標籤。您將在彈窗中輸入標籤名稱和描述(可選)。 |
| `` w `` | 新建工作樹 | |
| `` d `` | 刪除 | 檢視本機/遠端標籤的刪除選項。 |
| `` P `` | 推送標籤 | 推送選擇的標籤到遠端。您將在彈窗中選擇一個遠端。 |
| `` g `` | 重設 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` <ctrl+o> `` | Copy tag to clipboard | |
| `` <space> `` | 檢出 | Checkout the selected tag as a detached HEAD. |
| `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
| `` w `` | New worktree | |
| `` d `` | 刪除 | View delete options for local/remote tag. |
| `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. |
| `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | |
@@ -323,40 +330,40 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製檔案名稱到剪貼簿 | |
| `` <space> `` | 切換預存 | 切換所選檔案的暫存狀態。 |
| `` <space> `` | 切換預存 | Toggle staged for selected file. |
| `` <ctrl+b> `` | 篩選檔案 (預存/未預存) | |
| `` y `` | 複製到剪貼簿 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` A `` | 修改上次提交 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` e `` | 編輯 | 使用外部編輯器開啟 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` i `` | 忽略或排除檔案 | |
| `` r `` | 重新整理檔案 | |
| `` s `` | 收藏 | 貯藏所有變更.若要使用其他貯藏變體,請使用檢視貯藏選項快捷鍵。 |
| `` S `` | 檢視收藏選項 | 檢視貯藏選項(例如:貯藏所有、貯藏已暫存變更、貯藏未暫存變更)。 |
| `` a `` | 全部預存/取消預存 | 切換工作區中所有檔案的已暫存/未暫存狀態。 |
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | 如果選中的是一個檔案,則會進入到暫存檢視,以便可以暫存單個程式碼塊/行。如果選中的是一個目錄,則會摺疊/展開這個目錄。 |
| `` s `` | 收藏 | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | 檢視收藏選項 | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | 全部預存/取消預存 | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | 選擇檔案中的單個程式碼塊/行,或展開/折疊目錄 | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` d `` | 捨棄 | 檢視選中變動進行捨棄復原 |
| `` g `` | 檢視遠端重設選項 | |
| `` D `` | 重設 | 檢視工作樹的重置選項(例如:清除工作樹)。 |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
| `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). |
| `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` f `` | 擷取 | 同步遠端異動 |
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
| `` 0 `` | 聚焦主檢視 | |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
| `` 0 `` | Focus main view | |
| `` / `` | 搜尋 | |
## 次要
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` <esc> `` | 退出回到側邊面板 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 搜尋 | |
## 狀態
@@ -366,9 +373,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 |
| `` u `` | 檢查更新 | |
| `` <enter> `` | 切換到最近使用的版本庫 | |
| `` a `` | 顯示/迴圈所有分支日誌 | |
| `` A `` | 顯示/迴圈所有分支日誌(反向) | |
| `` 0 `` | 聚焦主檢視 | |
| `` a `` | Show/cycle all branch logs | |
| `` A `` | Show/cycle all branch logs (reverse) | |
| `` 0 `` | Focus main view | |
## 確認面板
@@ -378,23 +385,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <esc> `` | 關閉/取消 | |
| `` <ctrl+o> `` | 複製到剪貼簿 | |
## 輸入提示
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 確認 | |
| `` <esc> `` | 關閉/取消 | |
## 遠端
| Key | Action | Info |
|-----|--------|-------------|
| `` <enter> `` | 檢視分支 | |
| `` <enter> `` | View branches | |
| `` n `` | 新增遠端 | |
| `` d `` | 刪除 | 刪除選中的遠端。從遠端跟蹤遠端分支的任何本地分支都不會受到影響。 |
| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. |
| `` e `` | 編輯 | 編輯遠端 |
| `` f `` | 擷取 | 擷取遠端 |
| `` F `` | 新增復刻遠端倉庫 | 透過替換 origin URL 中的所有者來快速新增復刻遠端倉庫,並可選擇從新遠端倉庫檢出分支。 |
| `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. |
| `` / `` | 搜尋 | |
## 遠端分支
@@ -402,16 +402,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <ctrl+o> `` | 複製分支名稱到剪貼簿 | |
| `` <space> `` | 檢出 | 基於目前選中的遠端分支檢出一個新的本地分支,或者將遠端分支作分離的HEAD。 |
| `` <space> `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. |
| `` n `` | 新分支 | |
| `` w `` | 新建工作樹 | |
| `` M `` | 合併到當前檢出的分支 | 檢視將選中項合併到目前分支的選項(正常合併,壓縮合並) |
| `` r `` | 將已檢出的分支變基至此分支 | 將檢出的分支變基到所選的分支上。 |
| `` d `` | 刪除 | 從遠端刪除遠端分支。 |
| `` w `` | New worktree | |
| `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. |
| `` d `` | 刪除 | Delete the remote branch from the remote. |
| `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 |
| `` s `` | 排序規則 | |
| `` g `` | 檢視重設選項 | 檢視重置選項 (soft/mixed/hard) 用於重置到選擇項。 |
| `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. |
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` 0 `` | 聚焦主檢視 | |
| `` 0 `` | Focus main view | |
| `` <enter> `` | 檢視提交 | |
| `` / `` | 搜尋 | |
-1
View File
@@ -97,7 +97,6 @@
# Go toolchain
go
gotools
gopls
# Development tools
git
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -5,9 +5,6 @@ go 1.25.0
// This is necessary to ignore test files when executing gofumpt.
ignore ./test
// Likewise for worktrees that are nested in the main tree.
ignore ./.worktrees
require (
dario.cat/mergo v1.0.2
github.com/adrg/xdg v0.5.3
@@ -16,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.5.0
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
@@ -24,8 +21,8 @@ require (
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3
github.com/kyokomi/emoji/v2 v2.2.14
github.com/lucasb-eyer/go-colorful v1.4.1
github.com/kyokomi/emoji/v2 v2.2.13
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mgutz/str v1.2.0
github.com/mitchellh/go-ps v1.0.0
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
@@ -34,12 +31,12 @@ require (
github.com/samber/lo v1.53.0
github.com/sanity-io/litter v1.5.8
github.com/sasha-s/go-deadlock v0.3.9
github.com/sirupsen/logrus v1.10.2
github.com/sirupsen/logrus v1.9.4
github.com/spf13/afero v1.15.0
github.com/spkg/bom v1.0.1
github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304
github.com/stretchr/testify v1.12.1
github.com/xo/terminfo v1.0.0
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.22.0
golang.org/x/sys v0.47.0
@@ -53,9 +50,11 @@ require (
github.com/cli/safeexec v1.0.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.9.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-logfmt/logfmt v0.5.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hpcloud/tail v1.0.0 // indirect
github.com/invopop/jsonschema v0.10.0 // indirect
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect
@@ -64,16 +63,16 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/onsi/ginkgo v1.10.3 // indirect
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
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.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
mvdan.cc/gofumpt v0.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
)
tool mvdan.cc/gofumpt
+29 -28
View File
@@ -25,20 +25,22 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
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.5.0 h1:SCp9czLv2K2aPORgD6+4fjV0xNAzKIkiezCkp6bHLe4=
github.com/gdamore/tcell/v3 v3.5.0/go.mod h1:Oe5U3S3jm3NzypswDNUhe+LUnF5CoFq2b4sepD++QHo=
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=
github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
@@ -71,10 +73,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -98,11 +100,12 @@ github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeD
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE=
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
@@ -111,8 +114,8 @@ github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rY
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spkg/bom v1.0.1 h1:tl8kQ2sufL/wDEJa9me1jnQYEpDB7LqYGNkwCVR5GLs=
@@ -122,30 +125,28 @@ github.com/stefanhaller/git-todo-parser v0.0.7-0.20250905083220-c50528f08304/go.
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
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=
@@ -174,14 +175,14 @@ 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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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=
@@ -195,5 +196,5 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc=
mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo=
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
+4 -3
View File
@@ -61,9 +61,10 @@ func Run(
}
}
func NewCommon(config config.AppConfigurer, log *logrus.Entry) (*common.Common, error) {
func NewCommon(config config.AppConfigurer) (*common.Common, error) {
userConfig := config.GetUserConfig()
appState := config.GetAppState()
log := newLogger(config)
// Initialize with English for the time being; the real translation set for
// the configured language will be read after reading the user config
tr := i18n.EnglishTranslationSet()
@@ -79,8 +80,8 @@ func NewCommon(config config.AppConfigurer, log *logrus.Entry) (*common.Common,
return cmn, nil
}
func NewLogger(debug bool) *logrus.Entry {
if debug {
func newLogger(cfg config.AppConfigurer) *logrus.Entry {
if cfg.GetDebug() {
logPath, err := config.LogPath()
if err != nil {
log.Fatal(err)
+22 -21
View File
@@ -3,13 +3,14 @@ package daemon
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strconv"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
// Sometimes lazygit will be invoked in daemon mode from a parent lazygit process.
@@ -65,14 +66,14 @@ func getInstruction() Instruction {
return mapping[getDaemonKind()](jsonData)
}
func Handle(log *logrus.Entry) {
func Handle(common *common.Common) {
if !InDaemonMode() {
return
}
instruction := getInstruction()
if err := instruction.run(log); err != nil {
if err := instruction.run(common); err != nil {
log.Fatal(err)
}
}
@@ -106,7 +107,7 @@ type Instruction interface {
SerializedInstructions() string
// runs the instruction
run(log *logrus.Entry) error
run(common *common.Common) error
}
func serializeInstruction[T any](instruction T) string {
@@ -146,7 +147,7 @@ func (self *ExitImmediatelyInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *ExitImmediatelyInstruction) run(log *logrus.Entry) error {
func (self *ExitImmediatelyInstruction) run(common *common.Common) error {
return nil
}
@@ -164,8 +165,8 @@ func (self *RemoveUpdateRefsForCopiedBranchInstruction) SerializedInstructions()
return serializeInstruction(self)
}
func (self *RemoveUpdateRefsForCopiedBranchInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
func (self *RemoveUpdateRefsForCopiedBranchInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
return nil
})
}
@@ -192,8 +193,8 @@ func (self *ChangeTodoActionsInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *ChangeTodoActionsInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
func (self *ChangeTodoActionsInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
changes := lo.Map(self.Changes, func(c ChangeTodoAction, _ int) utils.TodoChange {
return utils.TodoChange{
Hash: c.Hash,
@@ -224,8 +225,8 @@ func (self *DropMergeCommitInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *DropMergeCommitInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
func (self *DropMergeCommitInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
return utils.DropMergeCommit(path, self.Hash, getCommentChar())
})
}
@@ -255,8 +256,8 @@ func (self *MoveFixupCommitDownInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *MoveFixupCommitDownInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
return utils.MoveFixupCommitDown(path, self.OriginalHash, self.FixupHash, self.ChangeToFixup, getCommentChar())
})
}
@@ -281,14 +282,14 @@ func (self *MoveTodosUpInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *MoveTodosUpInstruction) run(log *logrus.Entry) error {
func (self *MoveTodosUpInstruction) run(common *common.Common) error {
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
return utils.Todo{
Hash: hash,
}
})
return handleInteractiveRebase(log, func(path string) error {
return handleInteractiveRebase(common, func(path string) error {
return utils.MoveTodos(path, todosToMove, false, -self.Distance, getCommentChar())
})
}
@@ -313,14 +314,14 @@ func (self *MoveTodosDownInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *MoveTodosDownInstruction) run(log *logrus.Entry) error {
func (self *MoveTodosDownInstruction) run(common *common.Common) error {
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
return utils.Todo{
Hash: hash,
}
})
return handleInteractiveRebase(log, func(path string) error {
return handleInteractiveRebase(common, func(path string) error {
return utils.MoveTodos(path, todosToMove, false, self.Distance, getCommentChar())
})
}
@@ -339,8 +340,8 @@ func (self *InsertBreakInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *InsertBreakInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
func (self *InsertBreakInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
return utils.PrependStrToTodoFile(path, []byte("break\n"))
})
}
@@ -363,8 +364,8 @@ func (self *WriteRebaseTodoInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *WriteRebaseTodoInstruction) run(log *logrus.Entry) error {
return handleInteractiveRebase(log, func(path string) error {
func (self *WriteRebaseTodoInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
return os.WriteFile(path, self.TodosFileContent, 0o644)
})
}
+5 -5
View File
@@ -5,9 +5,9 @@ import (
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sirupsen/logrus"
"github.com/stefanhaller/git-todo-parser/todo"
)
@@ -17,9 +17,9 @@ type ChangeTodoAction struct {
Flag string
}
func handleInteractiveRebase(log *logrus.Entry, f func(path string) error) error {
log.Info("Lazygit invoked as interactive rebase demon")
log.Info("args: ", os.Args)
func handleInteractiveRebase(common *common.Common, f func(path string) error) error {
common.Log.Info("Lazygit invoked as interactive rebase demon")
common.Log.Info("args: ", os.Args)
path := os.Args[1]
if strings.HasSuffix(path, "git-rebase-todo") {
@@ -32,7 +32,7 @@ func handleInteractiveRebase(log *logrus.Entry, f func(path string) error) error
// if we are rebasing and squashing, we'll see a COMMIT_EDITMSG
// but in this case we don't need to edit it, so we'll just return
} else {
log.Info("Lazygit demon did not match on any use cases")
common.Log.Info("Lazygit demon did not match on any use cases")
}
return nil
+9 -10
View File
@@ -93,15 +93,6 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
env.SetGitDirEnv(cliArgs.GitDir)
}
// The log file lives in the config dir, so this must come after setting the
// CONFIG_DIR env var above.
logger := NewLogger(cliArgs.Debug)
if daemon.InDaemonMode() {
daemon.Handle(logger)
return
}
if cliArgs.PrintVersionInfo {
gitVersion := getGitVersionInfo()
fmt.Printf("commit=%s, build date=%s, build source=%s, version=%s, os=%s, arch=%s, git version=%s\n", buildInfo.Commit, buildInfo.Date, buildInfo.BuildSource, buildInfo.Version, runtime.GOOS, runtime.GOARCH, gitVersion)
@@ -152,6 +143,9 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
if integrationTest != nil {
integrationTest.SetupConfig(appConfig)
// Set this to true so that integration tests don't have to explicitly deal with the hunk
// staging hint:
appConfig.GetAppState().DidShowHunkStagingHint = true
// Preserve the changes that the test setup just made to the config, so
// they don't get lost when we reload the config while running the test
@@ -160,11 +154,16 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
appConfig.SaveGlobalUserConfig()
}
common, err := NewCommon(appConfig, logger)
common, err := NewCommon(appConfig)
if err != nil {
log.Fatal(err)
}
if daemon.InDaemonMode() {
daemon.Handle(common)
return
}
if cliArgs.Profile {
go func() {
if err := http.ListenAndServe("localhost:6060", nil); err != nil {
+11 -5
View File
@@ -19,12 +19,12 @@ import (
"strings"
"github.com/jesseduffield/generics/maps"
"github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/app"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
@@ -49,7 +49,7 @@ func CommandToRun() string {
}
func GetKeybindingsDir() string {
return utils.MustFindLazygitRootDirectory() + "/docs-master/keybindings"
return utils.GetLazyRootDirectory() + "/docs-master/keybindings"
}
func generateAtDir(cheatsheetDir string) {
@@ -58,11 +58,10 @@ func generateAtDir(cheatsheetDir string) {
log.Fatal(err)
}
mConfig := config.NewDummyAppConfig()
logger := app.NewLogger(mConfig.GetDebug())
for lang := range translationSetsByLang {
mConfig.GetUserConfig().Gui.Language = lang
common, err := app.NewCommon(mConfig, logger)
common, err := app.NewCommon(mConfig)
if err != nil {
log.Fatal(err)
}
@@ -120,7 +119,9 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
"prompt": tr.PromptTitle,
"information": tr.InformationTitle,
"main": tr.NormalTitle,
"patchBuilding": tr.PatchBuildingTitle,
"mergeConflicts": tr.MergingTitle,
"staging": tr.StagingTitle,
"menu": tr.MenuTitle,
"search": tr.SearchTitle,
"secondary": tr.SecondaryTitle,
@@ -139,7 +140,12 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
}
func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*bindingSection {
excludedViews := []string{"stagingSecondary", "patchBuildingSecondary"}
bindingsToDisplay := lo.Filter(bindings, func(binding *types.Binding, _ int) bool {
if lo.Contains(excludedViews, binding.ViewName) {
return false
}
return (binding.Description != "" || binding.Alternative != "") && len(binding.Keys) > 0
})
@@ -190,7 +196,7 @@ func getHeader(binding *types.Binding, tr *i18n.TranslationSet) header {
func formatSections(tr *i18n.TranslationSet, bindingSections []*bindingSection) string {
var content strings.Builder
fmt.Fprintf(&content, "# Lazygit %s\n", tr.Keybindings)
content.WriteString(fmt.Sprintf("# Lazygit %s\n", tr.Keybindings))
for _, section := range bindingSections {
content.WriteString(formatTitle(section.title))
+4 -8
View File
@@ -135,14 +135,10 @@ func NewGitCommandAux(
rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
patchBuilder := patch.NewPatchBuilder(cmn.Log,
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
// A patch is built from git's own diff: what a diff renderer would make of it
// is a picture of it, not something that can be applied.
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, git_commands.DiffModePlain)
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) {
// Under lazygit's own temp dir, so that it honours the configured location
// and is cleaned up with everything else when we exit.
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
})
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
@@ -152,11 +148,11 @@ func NewGitCommandAux(
gitHubCommands := git_commands.NewGitHubCommands(gitCommon)
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, branchCommands.HasLocalOnlyCommits, configCommands)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon)
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
remoteLoader := git_commands.NewRemoteLoader(gitCommon)
remoteLoader := git_commands.NewRemoteLoader(cmn, cmd)
worktreeLoader := git_commands.NewWorktreeLoader(gitCommon)
stashLoader := git_commands.NewStashLoader(cmn, cmd)
tagLoader := git_commands.NewTagLoader(cmn, cmd)
-103
View File
@@ -1,103 +0,0 @@
package git_commands
import (
"strconv"
"strings"
"github.com/samber/lo"
)
// Holds parsed values from a single %(ahead-behind:<base>) field.
type aheadBehind struct {
ahead, behind int
valid bool
}
type branchAheadBehind struct {
refName string
aheadBehinds []aheadBehind
}
// Parses output produced by:
//
// git for-each-ref --format='%(refname)\x00%(ahead-behind:<base1>)\x00...' refs/heads
//
// Lines whose NUL-split column count doesn't match (1 + numBases) are dropped.
// Blank lines are ignored.
// Individual malformed ahead-behind fields produce {valid: false} entries, so
// that the entries of a line stay aligned with the bases.
func parseAheadBehindForEachRefOutput(
output string,
numBases int, // number of %(ahead-behind:...) tokens
) []branchAheadBehind {
if output == "" {
return nil
}
lines := strings.Split(output, "\n")
result := make([]branchAheadBehind, 0, len(lines))
for _, line := range lines {
cols := strings.Split(line, "\x00")
if len(cols) != numBases+1 {
continue
}
refName := cols[0]
aheadBehinds := lo.Map(cols[1:], func(col string, _ int) aheadBehind {
return parseAheadBehindField(col)
})
entry := branchAheadBehind{
refName: refName,
aheadBehinds: aheadBehinds,
}
result = append(result, entry)
}
return result
}
func parseAheadBehindField(s string) aheadBehind {
parts := strings.Fields(s)
if len(parts) != 2 {
return aheadBehind{}
}
ahead, err1 := strconv.Atoi(parts[0])
behind, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return aheadBehind{}
}
return aheadBehind{ahead: ahead, behind: behind, valid: true}
}
// Picks the "closest" base by smallest ahead value (commits the branch
// has that the base doesn't = roughly "since fork point") and returns
// its behind value.
// Ties are broken by index order
func selectBehindForBranch(aheadBehinds []aheadBehind) int {
validOnes := lo.Filter(aheadBehinds, func(ab aheadBehind, _ int) bool {
return ab.valid
})
return lo.MinBy(validOnes, func(a, b aheadBehind) bool {
return a.ahead < b.ahead
}).behind
}
// Builds a for-each-ref command that reports, for each ref matched by one of
// refPatterns, how far it is ahead and behind each of the bases. A base is a
// ref name or a commit hash. The output format is:
//
// <refname>\x00<ahead> <behind>\x00<ahead> <behind>...\n
//
// with one ahead-behind field per base, in the same order as bases.
//
// Requires git >= 2.41 (when %(ahead-behind:...) was added).
func buildAheadBehindForEachRefArgs(bases []string, refPatterns []string) []string {
formatParts := make([]string, 0, 1+len(bases))
formatParts = append(formatParts, "%(refname)")
for _, base := range bases {
formatParts = append(formatParts, "%(ahead-behind:"+base+")")
}
format := strings.Join(formatParts, "%00")
return NewGitCmd("for-each-ref").
Arg("--format=" + format).
Arg(refPatterns...).
ToArgv()
}
@@ -1,262 +0,0 @@
package git_commands
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseAheadBehindForEachRefOutput(t *testing.T) {
type scenario struct {
testName string
input string
numBases int
expected []branchAheadBehind
}
scenarios := []scenario{
{
testName: "single branch single base",
input: "refs/heads/feat\x002 5\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 2, behind: 5, valid: true}},
},
},
},
{
testName: "multiple branches multiple bases",
input: "refs/heads/feat\x002 5\x0010 1\n" +
"refs/heads/main\x000 0\x000 0\n",
numBases: 2,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{ahead: 2, behind: 5, valid: true},
{ahead: 10, behind: 1, valid: true},
},
},
{
refName: "refs/heads/main",
aheadBehinds: []aheadBehind{
{ahead: 0, behind: 0, valid: true},
{ahead: 0, behind: 0, valid: true},
},
},
},
},
{
testName: "empty ahead-behind field for unreachable base",
input: "refs/heads/feat\x00\x002 5\n",
numBases: 2,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{},
{ahead: 2, behind: 5, valid: true},
},
},
},
},
{
testName: "ref name containing slashes and dashes",
input: "refs/heads/feat/foo-bar\x001 2\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat/foo-bar",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}},
},
},
},
{
testName: "trailing newline and blank lines are ignored",
input: "refs/heads/feat\x001 2\n\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}},
},
},
},
{
testName: "line with wrong column count is skipped",
input: "refs/heads/good\x001 2\n" +
"refs/heads/bad\n" +
"refs/heads/also_good\x003 4\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/good",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}},
},
{
refName: "refs/heads/also_good",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 4, valid: true}},
},
},
},
{
testName: "malformed ahead-behind field becomes invalid but line is kept",
input: "refs/heads/feat\x00not_a_number\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{}},
},
},
},
{
testName: "empty input",
input: "",
numBases: 1,
expected: nil,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := parseAheadBehindForEachRefOutput(s.input, s.numBases)
assert.Equal(t, s.expected, result)
})
}
}
func TestSelectBehindForBranch(t *testing.T) {
type scenario struct {
testName string
aheadBehinds []aheadBehind
expected int
}
scenarios := []scenario{
{
testName: "single base, valid value",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 7, valid: true}},
expected: 7,
},
{
testName: "multi-base, clear winner by ahead",
aheadBehinds: []aheadBehind{
{ahead: 50, behind: 10, valid: true}, // master
{ahead: 5, behind: 2, valid: true}, // develop ← smallest ahead
},
expected: 2,
},
{
testName: "develop forked from master case (ancestor-of-each-other)",
// feat-x has 5 commits since fork from develop.
// develop is 50 commits ahead of master.
// ahead vs master = 5 + 50 = 55; behind vs master = 0
// ahead vs develop = 5; behind vs develop = 5
aheadBehinds: []aheadBehind{
{ahead: 55, behind: 0, valid: true}, // master
{ahead: 5, behind: 5, valid: true}, // develop ← smallest ahead
},
expected: 5,
},
{
testName: "tie on ahead - first base wins (config order)",
aheadBehinds: []aheadBehind{
{ahead: 5, behind: 10, valid: true}, // first
{ahead: 5, behind: 99, valid: true}, // second, same ahead
},
expected: 10,
},
{
testName: "first base invalid, second valid",
aheadBehinds: []aheadBehind{
{},
{ahead: 3, behind: 8, valid: true},
},
expected: 8,
},
{
testName: "all invalid - returns 0",
aheadBehinds: []aheadBehind{{}, {}},
expected: 0,
},
{
testName: "empty - returns 0",
aheadBehinds: nil,
expected: 0,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := selectBehindForBranch(s.aheadBehinds)
assert.Equal(t, s.expected, result)
})
}
}
func TestBuildAheadBehindForEachRefArgs(t *testing.T) {
type scenario struct {
testName string
bases []string
refPatterns []string
expected []string
}
scenarios := []scenario{
{
testName: "single base",
bases: []string{"refs/heads/master"},
refPatterns: []string{"refs/heads"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/master)",
"refs/heads",
},
},
{
testName: "two bases",
bases: []string{"refs/heads/master", "refs/remotes/origin/develop"},
refPatterns: []string{"refs/heads"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/master)%00%(ahead-behind:refs/remotes/origin/develop)",
"refs/heads",
},
},
{
testName: "four bases",
bases: []string{"refs/heads/a", "refs/heads/b", "refs/heads/c", "refs/heads/d"},
refPatterns: []string{"refs/heads"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/a)%00%(ahead-behind:refs/heads/b)%00%(ahead-behind:refs/heads/c)%00%(ahead-behind:refs/heads/d)",
"refs/heads",
},
},
{
testName: "commit hashes as bases, individual refs as patterns",
bases: []string{"1234567", "89abcde"},
refPatterns: []string{"refs/heads/a", "refs/remotes/origin/b"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:1234567)%00%(ahead-behind:89abcde)",
"refs/heads/a",
"refs/remotes/origin/b",
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := buildAheadBehindForEachRefArgs(s.bases, s.refPatterns)
assert.Equal(t, s.expected, result)
})
}
}
+4 -95
View File
@@ -285,26 +285,11 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error
return self.cmd.New(cmdArgs).Run()
}
// Fast-forwards the branch that is checked out in the given worktree to the
// given ref. Fails if that can't be done without a merge commit. Pass empty
// strings for the worktree to use the current one.
func (self *BranchCommands) FastForwardMerge(refName string, worktreeGitDir string, worktreePath string) error {
cmdArgs := NewGitCmd("merge").
Arg("--ff-only").
Arg(refName).
GitDirIf(worktreeGitDir != "", worktreeGitDir).
WorktreePathIf(worktreePath != "", worktreePath).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
// Returns whether the first ref is an ancestor of the second one, which also
// means that the second one can be fast-forward merged into the first one
func (self *BranchCommands) IsAncestor(ancestorRefName string, refName string) bool {
// Returns whether refName can be fast-forward merged into the current branch
func (self *BranchCommands) CanDoFastForwardMerge(refName string) bool {
cmdArgs := NewGitCmd("merge-base").
Arg("--is-ancestor").
Arg(ancestorRefName, refName).
Arg("HEAD", refName).
ToArgv()
err := self.cmd.New(cmdArgs).DontLog().Run()
return err == nil
@@ -368,85 +353,9 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *
return stdout == "", nil
}
// Returns whether the given branch has commits of its own, meaning commits
// that its remote branch never contained. Those are the commits that would be
// lost if we reset the branch to its upstream.
//
// A branch that has diverged from its upstream doesn't necessarily have any
// commits of its own. If somebody else rewrote the remote branch and
// force-pushed it, our branch is still at the commits it had before, and all
// of those were on the remote branch at some point. The reflog of the
// remote-tracking branch records the values it had before it was rewritten, so
// a commit that was ever on the remote branch is contained in one of them.
func (self *BranchCommands) HasLocalOnlyCommits(branch *models.Branch) (bool, error) {
upstreamValues := append(
[]string{branch.FullUpstreamRefName()},
self.previousUpstreamValues(branch.FullUpstreamRefName())...,
)
cmdArgs := NewGitCmd("rev-list").
Arg("--max-count=1").
// A value that the remote-tracking branch had long ago might not be
// available any more, e.g. in a partial clone. Skip it rather than
// failing; it only means we exclude fewer commits.
Arg("--ignore-missing").
Arg(branch.FullRefName()).
Arg(lo.Map(upstreamValues, func(value string, _ int) string {
return "^" + value
})...).
Arg("--").
ToArgv()
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return false, err
}
return stdout != "", nil
}
// Returns the values that the given remote-tracking branch had before its
// current one, as far back as its reflog goes. Returns nothing if the reflog
// is unavailable, for example because core.logAllRefUpdates is false; a branch
// that is strictly behind its upstream is recognized without it.
func (self *BranchCommands) previousUpstreamValues(upstreamRef string) []string {
cmdArgs := NewGitCmd("reflog").
Arg("show").
Arg("--format=%H").
Arg(upstreamRef).
ToArgv()
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return nil
}
// Each entry holds the value that the ref was updated to.
values := utils.SplitLines(stdout)
// The value it had before the oldest entry is that entry's old value, and
// the only way to name it is <ref>@{<number of entries>}. It doesn't exist
// if the oldest entry is the one that created the ref, and asking for it
// then is an error rather than an empty result.
cmdArgs = NewGitCmd("rev-parse").
Arg("-q", "--verify").
Arg(fmt.Sprintf("%s@{%d}", upstreamRef, len(values))).
ToArgv()
if stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs(); err == nil {
values = append(values, strings.TrimSpace(stdout))
}
return values
}
// Moves branches by writing refs directly. The reflog message is what
// `git reflog <branch>` shows for the update; it is the only hint about who
// moved the branch, as no git command shows up in the reflog for this.
func (self *BranchCommands) UpdateBranchRefs(updateCommands string, reflogMessage string) error {
func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error {
cmdArgs := NewGitCmd("update-ref").
Arg("--stdin").
Arg("-m", reflogMessage).
ToArgv()
return self.cmd.New(cmdArgs).SetStdin(updateCommands).Run()
+104 -99
View File
@@ -44,7 +44,6 @@ type BranchLoader struct {
*GitCommon
cmd oscommands.ICmdObjBuilder
getCurrentBranchInfo func() (BranchInfo, error)
hasLocalOnlyCommits func(*models.Branch) (bool, error)
config BranchLoaderConfigCommands
}
@@ -53,7 +52,6 @@ func NewBranchLoader(
gitCommon *GitCommon,
cmd oscommands.ICmdObjBuilder,
getCurrentBranchInfo func() (BranchInfo, error),
hasLocalOnlyCommits func(*models.Branch) (bool, error),
config BranchLoaderConfigCommands,
) *BranchLoader {
return &BranchLoader{
@@ -61,7 +59,6 @@ func NewBranchLoader(
GitCommon: gitCommon,
cmd: cmd,
getCurrentBranchInfo: getCurrentBranchInfo,
hasLocalOnlyCommits: hasLocalOnlyCommits,
config: config,
}
}
@@ -70,21 +67,13 @@ func NewBranchLoader(
func (self *BranchLoader) Load(reflogCommits []*models.Commit,
mainBranches *MainBranches,
oldBranches []*models.Branch,
loadExtraInfo bool,
loadBehindCounts bool,
onWorker func(func() error),
renderFunc func(),
) ([]*models.Branch, error) {
branches, tips := self.obtainBranches()
branches := self.obtainBranches()
switch self.UserConfig().Git.LocalBranchSortOrder {
case "date":
if err := sortRefsWithEqualDatesByAncestry(
self.cmd, self.version, branches, (*models.Branch).FullRefName, tips,
); err != nil {
self.Log.Errorf("Failed to sort branches by ancestry: %v", err)
}
case "recency":
if self.UserConfig().Git.LocalBranchSortOrder == "recency" {
reflogBranches := self.obtainReflogBranches(reflogCommits)
// loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches
branchesWithRecency := make([]*models.Branch, 0)
@@ -138,63 +127,24 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit,
branch.UpstreamBranch = match.Merge
}
// If the branch already existed, take over the values that are
// determined in the background, to reduce flicker
// If the branch already existed, take over its BehindBaseBranch value
// to reduce flicker
if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool {
return b.Name == branch.Name
}); found {
branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load())
branch.UpstreamRewritten.Store(oldBranch.UpstreamRewritten.Load())
}
}
if loadExtraInfo {
if self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
onWorker(func() error {
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
})
}
if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
onWorker(func() error {
return self.checkForRewrittenUpstreams(branches, renderFunc)
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
})
}
return branches, nil
}
// For each branch that has diverged from its upstream, determines whether the
// divergence comes from the upstream branch having been rewritten, and stores
// the answer in the branch. A branch that we can't determine it for keeps the
// answer "no", so that we don't offer anything we aren't sure about.
func (self *BranchLoader) checkForRewrittenUpstreams(branches []*models.Branch, renderFunc func()) error {
t := time.Now()
errg := errgroup.Group{}
for _, branch := range branches {
if !branch.IsAheadForPull() || !branch.IsBehindForPull() {
branch.UpstreamRewritten.Store(false)
continue
}
errg.Go(func() error {
hasLocalOnlyCommits, err := self.hasLocalOnlyCommits(branch)
if err != nil {
// Not worth bothering the user about; it only means that we
// don't show this branch differently.
self.Log.Errorf("Failed to check whether branch %s has commits of its own: %v", branch.Name, err)
}
branch.UpstreamRewritten.Store(err == nil && !hasLocalOnlyCommits)
return nil
})
}
err := errg.Wait()
self.Log.Debugf("time to check for rewritten upstreams for all branches: %s", time.Since(t))
renderFunc()
return err
}
func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
branches []*models.Branch,
mainBranches *MainBranches,
@@ -256,6 +206,94 @@ func (self *BranchLoader) getBehindBaseBranchValuesLegacy(
return err
}
// Holds parsed values from a single %(ahead-behind:<base>) field.
type aheadBehind struct {
ahead, behind int
}
type branchAheadBehind struct {
refName string
aheadBehinds []aheadBehind
}
// Parses output produced by:
//
// git for-each-ref --format='%(refname)\x00%(ahead-behind:<base1>)\x00...' refs/heads
//
// Lines whose NUL-split column count doesn't match (1 + numBases) are dropped.
// Blank lines are ignored.
// Individual malformed ahead-behind fields produce {valid: false} entries
func parseAheadBehindForEachRefOutput(
output string,
numBases int, // number of %(ahead-behind:...) tokens
) []branchAheadBehind {
if output == "" {
return nil
}
lines := strings.Split(output, "\n")
result := make([]branchAheadBehind, 0, len(lines))
for _, line := range lines {
cols := strings.Split(line, "\x00")
if len(cols) != numBases+1 {
continue
}
refName := cols[0]
aheadBehinds := lo.FilterMap(cols[1:], func(col string, _ int) (aheadBehind, bool) {
return parseAheadBehindField(col)
})
entry := branchAheadBehind{
refName: refName,
aheadBehinds: aheadBehinds,
}
result = append(result, entry)
}
return result
}
func parseAheadBehindField(s string) (aheadBehind, bool) {
parts := strings.Fields(s)
if len(parts) != 2 {
return aheadBehind{}, false
}
ahead, err1 := strconv.Atoi(parts[0])
behind, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return aheadBehind{}, false
}
return aheadBehind{ahead: ahead, behind: behind}, true
}
// Picks the "closest" base by smallest ahead value (commits the branch
// has that the base doesn't = roughly "since fork point") and returns
// its behind value.
// Ties are broken by index order
func selectBehindForBranch(aheadBehinds []aheadBehind) int {
return lo.MinBy(aheadBehinds, func(a, b aheadBehind) bool {
return a.ahead < b.ahead
}).behind
}
// The output format is:
//
// <refname>\x00<ahead> <behind>\x00<ahead> <behind>...\n
//
// with one ahead-behind field per base, in the same order as mainBranchRefs.
//
// Requires git >= 2.41 (when %(ahead-behind:...) was added).
func buildAheadBehindForEachRefArgs(mainBranchRefs []string) []string {
formatParts := make([]string, 0, 1+len(mainBranchRefs))
formatParts = append(formatParts, "%(refname)")
for _, ref := range mainBranchRefs {
formatParts = append(formatParts, "%(ahead-behind:"+ref+")")
}
format := strings.Join(formatParts, "%00")
return NewGitCmd("for-each-ref").
Arg("--format=" + format).
Arg("refs/heads").
ToArgv()
}
func (self *BranchLoader) getBehindBaseBranchValuesFast(
branches []*models.Branch,
mainBranchRefs []string,
@@ -264,7 +302,7 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast(
t := time.Now()
output, err := self.cmd.New(
buildAheadBehindForEachRefArgs(mainBranchRefs, []string{"refs/heads"}),
buildAheadBehindForEachRefArgs(mainBranchRefs),
).DontLog().RunWithOutput()
if err != nil {
return err
@@ -324,9 +362,7 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai
return split[0], nil
}
// Returns the branches, along with the tip of each of them, keyed by full ref
// name
func (self *BranchLoader) obtainBranches() ([]*models.Branch, map[string]refTip) {
func (self *BranchLoader) obtainBranches() []*models.Branch {
output, err := self.getRawBranches()
if err != nil {
panic(err)
@@ -335,8 +371,7 @@ func (self *BranchLoader) obtainBranches() ([]*models.Branch, map[string]refTip)
trimmedOutput := strings.TrimSpace(output)
outputLines := strings.Split(trimmedOutput, "\n")
tips := make(map[string]refTip, len(outputLines))
branches := lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) {
return lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) {
if line == "" {
return nil, false
}
@@ -350,12 +385,8 @@ func (self *BranchLoader) obtainBranches() ([]*models.Branch, map[string]refTip)
}
storeCommitDateAsRecency := self.UserConfig().Git.LocalBranchSortOrder != "recency"
branch, tip := obtainBranch(split, storeCommitDateAsRecency)
tips[branch.FullRefName()] = tip
return branch, true
return obtainBranch(split, storeCommitDateAsRecency), true
})
return branches, tips
}
func (self *BranchLoader) getRawBranches() (string, error) {
@@ -391,28 +422,25 @@ var branchFields = []string{
"upstream:short",
"upstream:track",
"push:track",
"push",
"subject",
"objectname",
"committerdate:unix",
}
// Obtain branch information from parsed line output of getRawBranches()
func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch, refTip) {
func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch {
headMarker := split[0]
fullName := split[1]
upstreamName := split[2]
track := split[3]
pushTrack := split[4]
pushRef := split[5]
subject := split[6]
commitHash := split[7]
commitDate := split[8]
subject := split[5]
commitHash := split[6]
commitDate := split[7]
name := strings.TrimPrefix(fullName, "heads/")
aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track)
aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack)
pushRemote, pushBranch := parsePushDestination(pushRef)
recency := ""
if storeCommitDateAsRecency {
@@ -421,22 +449,18 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch
}
}
branch := &models.Branch{
return &models.Branch{
Name: name,
Recency: recency,
AheadForPull: aheadForPull,
BehindForPull: behindForPull,
AheadForPush: aheadForPush,
BehindForPush: behindForPush,
PushRemote: pushRemote,
PushBranch: pushBranch,
UpstreamGone: gone,
Head: headMarker == "*",
Subject: subject,
CommitHash: commitHash,
}
return branch, refTip{hash: commitHash, committerDate: commitDate}
}
func parseUpstreamInfo(upstreamName string, track string) (string, string, bool) {
@@ -457,25 +481,6 @@ func parseUpstreamInfo(upstreamName string, track string) (string, string, bool)
return ahead, behind, false
}
// Splits the remote-tracking ref that the %(push) field names, e.g.
// refs/remotes/origin/main, into the remote and the remote branch. Returns
// empty strings if the field is empty because git has no push destination for
// the branch, or if the ref isn't under refs/remotes/.
func parsePushDestination(pushRef string) (string, string) {
remoteAndBranch, ok := strings.CutPrefix(pushRef, "refs/remotes/")
if !ok {
return "", ""
}
// Remote names can't contain slashes, so the first one ends the remote name
remote, branch, ok := strings.Cut(remoteAndBranch, "/")
if !ok {
return "", ""
}
return remote, branch
}
func parseDifference(track string, regexStr string) string {
re := regexp.MustCompile(regexStr)
match := re.FindStringSubmatch(track)
+244 -105
View File
@@ -6,10 +6,8 @@ import (
"testing"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/sasha-s/go-deadlock"
"github.com/stretchr/testify/assert"
)
@@ -28,7 +26,7 @@ func TestObtainBranch(t *testing.T) {
scenarios := []scenario{
{
testName: "TrimHeads",
input: []string{"", "heads/a_branch", "", "", "", "", "subject", "123", timeStamp},
input: []string{"", "heads/a_branch", "", "", "", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -43,7 +41,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "NoUpstream",
input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp},
input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -58,7 +56,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "IsHead",
input: []string{"*", "a_branch", "", "", "", "", "subject", "123", timeStamp},
input: []string{"*", "a_branch", "", "", "", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -73,7 +71,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "IsBehindAndAhead",
input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp},
input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -81,40 +79,6 @@ func TestObtainBranch(t *testing.T) {
BehindForPull: "2",
AheadForPush: "3",
BehindForPush: "2",
PushRemote: "a_remote",
PushBranch: "a_branch",
Head: false,
Subject: "subject",
CommitHash: "123",
},
},
{
testName: "PushDestinationDiffersFromUpstream",
input: []string{"", "a_branch", "a_remote/a_branch", "[ahead 3]", "[ahead 5]", "refs/remotes/my_fork/feature/a_branch", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
AheadForPull: "3",
BehindForPull: "0",
AheadForPush: "5",
BehindForPush: "0",
PushRemote: "my_fork",
PushBranch: "feature/a_branch",
Head: false,
Subject: "subject",
CommitHash: "123",
},
},
{
testName: "PushDestinationNotARemoteTrackingRef",
input: []string{"", "a_branch", "a_remote/a_branch", "", "", "refs/published/a_branch", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
AheadForPull: "0",
BehindForPull: "0",
AheadForPush: "0",
BehindForPush: "0",
Head: false,
Subject: "subject",
CommitHash: "123",
@@ -122,7 +86,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "RemoteBranchIsGone",
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp},
input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "subject", "123", timeStamp},
storeCommitDateAsRecency: false,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -131,8 +95,6 @@ func TestObtainBranch(t *testing.T) {
BehindForPull: "?",
AheadForPush: "?",
BehindForPush: "?",
PushRemote: "a_remote",
PushBranch: "a_branch",
Head: false,
Subject: "subject",
CommitHash: "123",
@@ -140,7 +102,7 @@ func TestObtainBranch(t *testing.T) {
},
{
testName: "WithCommitDateAsRecency",
input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp},
input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
storeCommitDateAsRecency: true,
expectedBranch: &models.Branch{
Name: "a_branch",
@@ -158,9 +120,245 @@ func TestObtainBranch(t *testing.T) {
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
branch, tip := obtainBranch(s.input, s.storeCommitDateAsRecency)
branch := obtainBranch(s.input, s.storeCommitDateAsRecency)
assert.EqualValues(t, s.expectedBranch, branch)
assert.Equal(t, refTip{hash: "123", committerDate: timeStamp}, tip)
})
}
}
func TestParseAheadBehindForEachRefOutput(t *testing.T) {
type scenario struct {
testName string
input string
numBases int
expected []branchAheadBehind
}
scenarios := []scenario{
{
testName: "single branch single base",
input: "refs/heads/feat\x002 5\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 2, behind: 5}},
},
},
},
{
testName: "multiple branches multiple bases",
input: "refs/heads/feat\x002 5\x0010 1\n" +
"refs/heads/main\x000 0\x000 0\n",
numBases: 2,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{ahead: 2, behind: 5},
{ahead: 10, behind: 1},
},
},
{
refName: "refs/heads/main",
aheadBehinds: []aheadBehind{
{ahead: 0, behind: 0},
{ahead: 0, behind: 0},
},
},
},
},
{
testName: "empty ahead-behind field for unreachable base",
input: "refs/heads/feat\x00\x002 5\n",
numBases: 2,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{
{ahead: 2, behind: 5},
},
},
},
},
{
testName: "ref name containing slashes and dashes",
input: "refs/heads/feat/foo-bar\x001 2\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat/foo-bar",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
},
},
},
{
testName: "trailing newline and blank lines are ignored",
input: "refs/heads/feat\x001 2\n\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
},
},
},
{
testName: "line with wrong column count is skipped",
input: "refs/heads/good\x001 2\n" +
"refs/heads/bad\n" +
"refs/heads/also_good\x003 4\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/good",
aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
},
{
refName: "refs/heads/also_good",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 4}},
},
},
},
{
testName: "malformed ahead-behind field becomes invalid but line is kept",
input: "refs/heads/feat\x00not_a_number\n",
numBases: 1,
expected: []branchAheadBehind{
{
refName: "refs/heads/feat",
aheadBehinds: []aheadBehind{},
},
},
},
{
testName: "empty input",
input: "",
numBases: 1,
expected: nil,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := parseAheadBehindForEachRefOutput(s.input, s.numBases)
assert.Equal(t, s.expected, result)
})
}
}
func TestSelectBehindForBranch(t *testing.T) {
type scenario struct {
testName string
aheadBehinds []aheadBehind
expected int
}
scenarios := []scenario{
{
testName: "single base, valid value",
aheadBehinds: []aheadBehind{{ahead: 3, behind: 7}},
expected: 7,
},
{
testName: "multi-base, clear winner by ahead",
aheadBehinds: []aheadBehind{
{ahead: 50, behind: 10}, // master
{ahead: 5, behind: 2}, // develop ← smallest ahead
},
expected: 2,
},
{
testName: "develop forked from master case (ancestor-of-each-other)",
// feat-x has 5 commits since fork from develop.
// develop is 50 commits ahead of master.
// ahead vs master = 5 + 50 = 55; behind vs master = 0
// ahead vs develop = 5; behind vs develop = 5
aheadBehinds: []aheadBehind{
{ahead: 55, behind: 0}, // master
{ahead: 5, behind: 5}, // develop ← smallest ahead
},
expected: 5,
},
{
testName: "tie on ahead - first base wins (config order)",
aheadBehinds: []aheadBehind{
{ahead: 5, behind: 10}, // first
{ahead: 5, behind: 99}, // second, same ahead
},
expected: 10,
},
{
testName: "first base invalid, second valid",
aheadBehinds: []aheadBehind{
{ahead: 3, behind: 8},
},
expected: 8,
},
{
testName: "all invalid - returns 0",
aheadBehinds: []aheadBehind{},
expected: 0,
},
{
testName: "empty - returns 0",
aheadBehinds: nil,
expected: 0,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := selectBehindForBranch(s.aheadBehinds)
assert.Equal(t, s.expected, result)
})
}
}
func TestBuildAheadBehindForEachRefArgs(t *testing.T) {
type scenario struct {
testName string
mainBranchRefs []string
expected []string
}
scenarios := []scenario{
{
testName: "single base",
mainBranchRefs: []string{"refs/heads/master"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/master)",
"refs/heads",
},
},
{
testName: "two bases",
mainBranchRefs: []string{"refs/heads/master", "refs/remotes/origin/develop"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/master)%00%(ahead-behind:refs/remotes/origin/develop)",
"refs/heads",
},
},
{
testName: "four bases",
mainBranchRefs: []string{"refs/heads/a", "refs/heads/b", "refs/heads/c", "refs/heads/d"},
expected: []string{
"git",
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:refs/heads/a)%00%(ahead-behind:refs/heads/b)%00%(ahead-behind:refs/heads/c)%00%(ahead-behind:refs/heads/d)",
"refs/heads",
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := buildAheadBehindForEachRefArgs(s.mainBranchRefs)
assert.Equal(t, s.expected, result)
})
}
}
@@ -293,62 +491,3 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) {
runner.CheckForMissingCalls()
}
func TestCheckForRewrittenUpstreams(t *testing.T) {
branch := func(name string, ahead string, behind string) *models.Branch {
return &models.Branch{
Name: name,
UpstreamRemote: "origin",
UpstreamBranch: name,
AheadForPull: ahead,
BehindForPull: behind,
}
}
notDiverged := branch("not-diverged", "0", "2")
rewritten := branch("rewritten", "3", "5")
ownCommits := branch("own-commits", "3", "5")
failing := branch("failing", "1", "1")
// A branch that is no longer diverged must lose the value it had before
notDiverged.UpstreamRewritten.Store(true)
branches := []*models.Branch{notDiverged, rewritten, ownCommits, failing}
var mutex deadlock.Mutex
queried := []string{}
hasLocalOnlyCommits := func(branch *models.Branch) (bool, error) {
mutex.Lock()
queried = append(queried, branch.Name)
mutex.Unlock()
switch branch.Name {
case "own-commits":
return true, nil
case "failing":
return false, errors.New("error")
default:
return false, nil
}
}
gitCommon := buildGitCommon(commonDeps{})
loader := &BranchLoader{
Common: gitCommon.Common,
GitCommon: gitCommon,
cmd: gitCommon.cmd,
hasLocalOnlyCommits: hasLocalOnlyCommits,
}
rendered := false
err := loader.checkForRewrittenUpstreams(branches, func() { rendered = true })
assert.NoError(t, err)
assert.True(t, rendered, "renderFunc should have been called")
assert.ElementsMatch(t, []string{"rewritten", "own-commits", "failing"}, queried,
"only diverged branches should be looked at")
assert.False(t, notDiverged.UpstreamRewritten.Load())
assert.True(t, rewritten.UpstreamRewritten.Load())
assert.False(t, ownCommits.UpstreamRewritten.Load())
assert.False(t, failing.UpstreamRewritten.Load(), "a failed check should not claim anything")
}
-109
View File
@@ -4,7 +4,6 @@ import (
"testing"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/stretchr/testify/assert"
@@ -318,111 +317,3 @@ func TestBranchCurrentBranchInfo(t *testing.T) {
})
}
}
func TestBranchHasLocalOnlyCommits(t *testing.T) {
type scenario struct {
testName string
runner *oscommands.FakeCmdObjRunner
test func(bool, error)
}
branch := &models.Branch{
Name: "branch",
UpstreamRemote: "origin",
UpstreamBranch: "branch",
}
scenarios := []scenario{
{
"branch is strictly behind its upstream, and there are no reflogs",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch", "--",
}, "", nil),
func(hasLocalOnlyCommits bool, err error) {
assert.NoError(t, err)
assert.False(t, hasLocalOnlyCommits)
},
},
{
"the upstream branch was rewritten, so all our commits were on it before",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
"1111111111111111111111111111111111111111\n2222222222222222222222222222222222222222\n", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{2}"},
"3333333333333333333333333333333333333333\n", nil).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch",
"^1111111111111111111111111111111111111111",
"^2222222222222222222222222222222222222222",
"^3333333333333333333333333333333333333333",
"--",
}, "", nil),
func(hasLocalOnlyCommits bool, err error) {
assert.NoError(t, err)
assert.False(t, hasLocalOnlyCommits)
},
},
{
"the oldest reflog entry is the one that created the ref",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
"1111111111111111111111111111111111111111\n", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"}, "", errors.New("error")).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch",
"^1111111111111111111111111111111111111111",
"--",
}, "", nil),
func(hasLocalOnlyCommits bool, err error) {
assert.NoError(t, err)
assert.False(t, hasLocalOnlyCommits)
},
},
{
"the branch has a commit that was never on the upstream branch",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
"1111111111111111111111111111111111111111\n", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"},
"2222222222222222222222222222222222222222\n", nil).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch",
"^1111111111111111111111111111111111111111",
"^2222222222222222222222222222222222222222",
"--",
}, "4444444444444444444444444444444444444444\n", nil),
func(hasLocalOnlyCommits bool, err error) {
assert.NoError(t, err)
assert.True(t, hasLocalOnlyCommits)
},
},
{
"bubbles up an error from rev-list",
oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
ExpectGitArgs([]string{
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
"^refs/remotes/origin/branch", "--",
}, "", errors.New("error")),
func(hasLocalOnlyCommits bool, err error) {
assert.Error(t, err)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
instance := buildBranchCommands(commonDeps{runner: s.runner})
s.test(instance.HasLocalOnlyCommits(branch))
s.runner.CheckForMissingCalls()
})
}
}
+3 -3
View File
@@ -240,12 +240,12 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
return self.cmd.New(cmdArgs)
}
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string, mode DiffMode) *oscommands.CmdObj {
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string, ignoreExternalDiff bool) *oscommands.CmdObj {
cmdArgs := NewGitCmd("show").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("--submodule").
Arg("--color=" + mode.colorArg(self.diffRendererConfigManager)).
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
Arg("--stat").
Arg("--decorate").
Arg("-p").
+25 -42
View File
@@ -174,7 +174,7 @@ func (self *CommitLoader) MergeRebasingCommits(hashPool *utils.StringPool, commi
}
if workingTreeState.Rebasing {
rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, commits, addConflictedRebasingCommit)
rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, addConflictedRebasingCommit)
if err != nil {
return nil, err
}
@@ -251,8 +251,8 @@ func (self *CommitLoader) extractCommitFromLine(hashPool *utils.StringPool, line
})
}
func (self *CommitLoader) getHydratedRebasingCommits(hashPool *utils.StringPool, existingCommits []*models.Commit, addConflictingCommit bool) ([]*models.Commit, error) {
return self.getHydratedTodoCommits(hashPool, self.getRebasingCommits(hashPool, addConflictingCommit), existingCommits, false)
func (self *CommitLoader) getHydratedRebasingCommits(hashPool *utils.StringPool, addConflictingCommit bool) ([]*models.Commit, error) {
return self.getHydratedTodoCommits(hashPool, self.getRebasingCommits(hashPool, addConflictingCommit), false)
}
func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool, workingTreeState models.WorkingTreeState) ([]*models.Commit, error) {
@@ -271,56 +271,39 @@ func (self *CommitLoader) getHydratedSequencerCommits(hashPool *utils.StringPool
}
}
return self.getHydratedTodoCommits(hashPool, commits, nil, true)
return self.getHydratedTodoCommits(hashPool, commits, true)
}
func (self *CommitLoader) getHydratedTodoCommits(
hashPool *utils.StringPool,
todoCommits []*models.Commit,
existingCommits []*models.Commit,
todoFileHasShortHashes bool,
) ([]*models.Commit, error) {
func (self *CommitLoader) getHydratedTodoCommits(hashPool *utils.StringPool, todoCommits []*models.Commit, todoFileHasShortHashes bool) ([]*models.Commit, error) {
if len(todoCommits) == 0 {
return nil, nil
}
// A refresh of only the rebasing todos should reuse the already loaded todos to avoid
// unnecessary git show calls.
fullCommits := map[string]*models.Commit{}
for _, commit := range existingCommits {
if commit.IsTODO() && commit.Hash() != "" {
// Make a copy of the commit; that's necessary to avoid mutating the original commit
// when we later reuse it in the loop at the end of this function.
fullCommits[commit.Hash()] = lo.ToPtr(*commit)
}
}
commitHashesToFetch := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) {
return commit.Hash(), commit.Hash() != "" && fullCommits[commit.Hash()] == nil
commitHashes := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) {
return commit.Hash(), commit.Hash() != ""
})
if len(commitHashesToFetch) > 0 {
// note that we're not filtering these as we do non-rebasing commits just because
// I suspect that will cause some damage
cmdObj := self.cmd.New(
NewGitCmd("show").
Config("log.showSignature=false").
Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat).
Arg(commitHashesToFetch...).
ToArgv(),
).DontLog()
// note that we're not filtering these as we do non-rebasing commits just because
// I suspect that will cause some damage
cmdObj := self.cmd.New(
NewGitCmd("show").
Config("log.showSignature=false").
Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat).
Arg(commitHashes...).
ToArgv(),
).DontLog()
err := cmdObj.RunAndProcessLines(func(line string) (bool, error) {
if line == "" || line[0] != '+' {
return false, nil
}
commit := self.extractCommitFromLine(hashPool, line[1:], false)
fullCommits[commit.Hash()] = commit
fullCommits := map[string]*models.Commit{}
err := cmdObj.RunAndProcessLines(func(line string) (bool, error) {
if line == "" || line[0] != '+' {
return false, nil
})
if err != nil {
return nil, err
}
commit := self.extractCommitFromLine(hashPool, line[1:], false)
fullCommits[commit.Hash()] = commit
return false, nil
})
if err != nil {
return nil, err
}
findFullCommit := lo.Ternary(todoFileHasShortHashes,
@@ -538,110 +538,6 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
}
}
func TestCommitLoaderGetHydratedTodoCommitsReusesExistingCommit(t *testing.T) {
hashPool := &utils.StringPool{}
runner := oscommands.NewFakeRunner(t)
loader := &CommitLoader{
cmd: oscommands.NewDummyCmdObjBuilder(runner),
}
existingCommit := models.NewCommit(hashPool, models.NewCommitOpts{
Hash: "0123456789012345678901234567890123456789",
Name: "hydrated subject",
AuthorName: "Jane Doe",
AuthorEmail: "jane@example.com",
UnixTimestamp: 1234,
Parents: []string{"1123456789012345678901234567890123456789"},
Status: models.StatusRebasing,
Action: todo.Pick,
})
refreshedTodo := models.NewCommit(hashPool, models.NewCommitOpts{
Hash: existingCommit.Hash(),
Name: "subject from the todo file",
Status: models.StatusConflicted,
Action: todo.Fixup,
ActionFlag: "-C",
})
commits, err := loader.getHydratedTodoCommits(
hashPool,
[]*models.Commit{refreshedTodo},
[]*models.Commit{existingCommit},
false,
)
assert.NoError(t, err)
assert.Equal(t, []*models.Commit{
models.NewCommit(hashPool, models.NewCommitOpts{
Hash: existingCommit.Hash(),
Name: "hydrated subject",
AuthorName: "Jane Doe",
AuthorEmail: "jane@example.com",
UnixTimestamp: 1234,
Parents: []string{"1123456789012345678901234567890123456789"},
Status: models.StatusConflicted,
Action: todo.Fixup,
ActionFlag: "-C",
}),
}, commits)
assert.Equal(t, todo.Pick, existingCommit.Action)
assert.Equal(t, models.StatusRebasing, existingCommit.Status)
runner.CheckForMissingCalls()
}
func TestCommitLoaderGetHydratedTodoCommitsLoadsMissingCommit(t *testing.T) {
hashPool := &utils.StringPool{}
existingHash := "0123456789012345678901234567890123456789"
missingHash := "2123456789012345678901234567890123456789"
missingCommitOutput := strings.ReplaceAll(
`+2123456789012345678901234567890123456789|1235|John Doe|john@example.com||>|tag: new|new subject`,
"|",
"\x00",
)
runner := oscommands.NewFakeRunner(t).ExpectGitArgs(
[]string{
"-c", "log.showSignature=false", "show", "--no-patch", "--oneline", "--abbrev=20",
prettyFormat, missingHash,
},
missingCommitOutput,
nil,
)
loader := &CommitLoader{
cmd: oscommands.NewDummyCmdObjBuilder(runner),
}
existingCommit := models.NewCommit(hashPool, models.NewCommitOpts{
Hash: existingHash,
Name: "existing subject",
Status: models.StatusRebasing,
Action: todo.Pick,
})
refreshedTodos := []*models.Commit{
models.NewCommit(hashPool, models.NewCommitOpts{
Hash: existingHash,
Status: models.StatusRebasing,
Action: todo.Pick,
}),
models.NewCommit(hashPool, models.NewCommitOpts{
Hash: missingHash,
Status: models.StatusRebasing,
Action: todo.Edit,
}),
}
commits, err := loader.getHydratedTodoCommits(
hashPool,
refreshedTodos,
[]*models.Commit{existingCommit},
false,
)
assert.NoError(t, err)
assert.Len(t, commits, 2)
assert.Equal(t, "existing subject", commits[0].Name)
assert.Equal(t, "new subject", commits[1].Name)
assert.Equal(t, todo.Edit, commits[1].Action)
runner.CheckForMissingCalls()
}
func TestCommitLoader_setCommitStatuses(t *testing.T) {
type scenario struct {
testName string
+2 -2
View File
@@ -312,7 +312,7 @@ func TestCommitShowCmdObj(t *testing.T) {
similarityThreshold: 50,
ignoreWhitespace: false,
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
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", "--"},
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",
@@ -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, DiffModeRendered).Run())
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths, false).Run())
runner.CheckForMissingCalls()
})
}
+75 -93
View File
@@ -6,69 +6,61 @@ import (
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/mgutz/str"
)
// metadataHandshake is the record a diff renderer that speaks the OSC 1717 protocol
// emits before anything else, to announce that it does: a version-only record, with
// none of the fields a line's record has. See ProbeDiffRendererEmitsMetadata, and
// gocui's escape interpreter for how it is kept off the screen on a real render.
type DiffCommands struct {
*GitCommon
}
func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
return &DiffCommands{
GitCommon: gitCommon,
}
}
// 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"
// probeWidth is the width a probe asks the renderer to lay out to. A probe has
// nothing to render, so any width does, as long as it is one a renderer will
// accept: a renderer that lays its rendering out in columns may well refuse a
// width of zero, and would then never get as far as announcing itself.
const probeWidth = 80
// ProbeDiffRendererEmitsMetadata reports whether the configured diff renderer states
// which line of which file it is rendering, by running it on empty input and looking
// for the handshake. The answer decides whether a diff the renderer produced can be
// acted on at all, or has to be replaced by git's own when the user wants to act on it
// (see DiffLineHelper.MainViewDiffMode).
// 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.
//
// Asking rather than watching a real render: the handshake is the renderer's first
// output whatever the diff, so the answer is a property of the renderer, known before
// we render anything — where watching would have to see a diff go by first, and would
// be fooled by a diff with no lines to describe.
// 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.
//
// No terminal is needed. git only invokes a stdin filter when it thinks it is talking
// to one, but the renderer itself doesn't care: it announces itself whenever OSC1717 is
// set, so it can be run directly with empty input.
func (self *DiffCommands) ProbeDiffRendererEmitsMetadata() bool {
manager := self.diffRendererConfigManager
values := config.DiffRendererValues{Width: probeWidth, DiffContext: 3}
switch manager.GetDiffRendererType() {
case config.DiffRendererType_StdinFilter:
if command, err := manager.GetStdinFilterCommand(values); err == nil && command != "" {
return self.probeEmitsMetadata(self.cmd.NewShell(command, ""))
}
case config.DiffRendererType_ExtDiff:
// An empty command means git's own diff.external config, which picks a driver
// per file through .gitattributes: there is no one renderer to ask, and a single
// diff can be produced by several, so we take it that it says nothing.
if command, err := manager.GetExternalDiffCommand(values); err == nil && command != "" {
return self.externalDiffEmitsMetadata(command)
}
case config.DiffRendererType_RawGit:
// git describes only the formats whose output can't be read back as a diff, and
// asked with the renderer's own arguments it answers for exactly the format
// those select: a handshake for a word diff, silence for a unified one. With no
// arguments there is nothing to fall back to anyway, since this already is git's
// own diff.
if args := manager.GetRawGitArgs(); len(args) > 0 {
return self.rawGitEmitsMetadata(args)
}
// 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 asks git itself, run with the diff renderer's own arguments.
// 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 := self.probeFiles()
oldPath, newPath, cleanup, ok := probeFiles()
if !ok {
return false
}
@@ -83,34 +75,33 @@ func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool {
))
}
// externalDiffEmitsMetadata asks an external diff command, invoking it the way git
// invokes one — with the seven positional arguments of git's diff.external convention —
// over two empty files, so that it announces itself without having a diff to render.
func (self *DiffCommands) externalDiffEmitsMetadata(externalDiffCommand string) bool {
oldPath, newPath, cleanup, ok := self.probeFiles()
// 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(externalDiffCommand),
args := append(str.ToArgv(extDiffCmd),
"probe", oldPath, "0000000", "100644", newPath, "0000000", "100644")
return self.probeEmitsMetadata(self.cmd.New(args))
}
// probeFiles makes the two empty files a probe stands a diff up from, and the cleanup
// that removes them. Empty, because what the probe wants is for the renderer to announce
// itself, not for it to have anything to say.
func (self *DiffCommands) probeFiles() (string, string, func(), bool) {
tempDir := self.os.GetTempDir()
oldFile, err := os.CreateTemp(tempDir, "lazygit-probe-old-*")
// 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(tempDir, "lazygit-probe-new-*")
newFile, err := os.CreateTemp("", "lazygit-probe-new-*")
if err != nil {
os.Remove(oldFile.Name())
return "", "", nil, false
@@ -125,53 +116,44 @@ func (self *DiffCommands) probeFiles() (string, string, func(), bool) {
func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool {
cmdObj.AddEnvVars("OSC1717=V1")
// A renderer may well object to being handed nothing to render. We want to know
// whatever it said before objecting, and that is captured either way.
// 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)
}
type DiffCommands struct {
*GitCommon
}
func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
return &DiffCommands{
GitCommon: gitCommon,
}
}
// 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.
func (self *DiffCommands) DiffCmdObj(diffArgs []string, mode DiffMode) *oscommands.CmdObj {
// 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").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(diffArgs...).
Dir(self.repoPaths.worktreePath).
ToArgv(),
)
}
// CustomPatchDiffCmdObj is the command that renders the custom patch being built: a diff
// of the two trees the patch was materialized into (PatchCommands.WriteCustomPatchDiffTrees),
// under the directory holding them. It goes through the same wiring as any other diff we
// show, so the patch is rendered by whatever renders the rest of them, and git works out
// how much context to give it.
//
// git's own path prefixes are suppressed because the trees are named a and b themselves,
// which leaves the paths reading like an ordinary diff's over the repo's own paths.
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, mode DiffMode) *oscommands.CmdObj {
// 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(), mode).
NoLineEndingConversion().
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("--no-index").
Arg("--no-prefix").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg("a", "b").
Dir(dir).
ToArgv(),
-35
View File
@@ -1,35 +0,0 @@
package git_commands
import (
"github.com/jesseduffield/lazygit/pkg/config"
)
// DiffMode says what a diff command's output is for. This decides whether the
// configured diff renderer produces it, and whether it is coloured.
type DiffMode int
const (
// DiffModeRendered is the diff as the user has arranged for it to look: through the
// diff renderer, with the renderer's own arguments and its preference about colour.
DiffModeRendered DiffMode = iota
// DiffModeRaw is git's own coloured diff, for showing a diff whose rendered form
// couldn't be acted on.
DiffModeRaw
// DiffModePlain is git's own uncoloured diff, for building patches from and copying
// text out of rather than for looking at.
DiffModePlain
)
// colorArg returns the value to pass to git's --color for this mode. Rendered output is
// coloured however the renderer wants its input; a raw diff gets git's own colour, which
// is the point of it; a plain one is for reading as text, not for looking at.
func (self DiffMode) colorArg(diffRendererConfigManager *config.DiffRendererConfigManager) string {
switch self {
case DiffModeRendered:
return diffRendererConfigManager.GetColorArg()
case DiffModeRaw:
return "always"
default:
return "never"
}
}
+21
View File
@@ -2,6 +2,7 @@ package git_commands
import (
"fmt"
"path/filepath"
"strconv"
"strings"
@@ -90,6 +91,26 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.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())
for _, file := range files {
for _, worktreePath := range worktreePaths {
absFilePath, err := filepath.Abs(file.Path)
if err != nil {
self.Log.Error(err)
continue
}
if absFilePath == worktreePath {
file.IsWorktree = true
// `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree
// If we include the slash, it will be rendered as a folder with a null file inside.
file.Path = strings.TrimSuffix(file.Path, "/")
break
}
}
}
return files
}
@@ -19,12 +19,12 @@ 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
// 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 {
func forOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar)
}
@@ -123,42 +123,18 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
return self
}
// NoLineEndingConversion keeps git's line-ending machinery away from files that are not
// a working tree's. The trees the custom patch is materialized into hold the bytes git
// states the patch in, so a command over them has to read and write those bytes as they
// are. On a machine that checks files out with CRLF, `git apply` writes the after tree
// in that form while the before tree keeps the LF it was written with. git's own diff
// converts both back, but it warns about a round trip through a working tree these
// files never belong to, and an external diff renderer is handed the two files as they
// stand, one line ending apart in every line.
func (self *GitCommandBuilder) NoLineEndingConversion() *GitCommandBuilder {
return self.
// The setting that converts on most machines, and the one Git for Windows
// installs itself with.
Config("core.autocrlf=false").
// An attributes file outside the repo can still mark the files as text. The
// form to keep them in is then the form they are written in.
Config("core.eol=lf").
// An attribute naming CRLF outright overrides that, and git converts after all.
// The warning it gives is about a checkout these files never have.
Config("core.safecrlf=false")
}
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, mode DiffMode) *GitCommandBuilder {
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
contextSize := userConfig.Git.DiffContextSize
useExtDiff := mode == DiffModeRendered && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
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)).
// Ignoring whitespace is about what the user wants to see, so it holds for a raw
// diff as much as for a rendered one. Patches are built from a plain diff,
// where a diff that leaves changes out would apply to nothing.
ArgIf(mode != DiffModePlain && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)).
// The renderer's own arguments to git — a word diff, say — are part of the
// rendering, so they go with it.
ArgIf(mode == DiffModeRendered, diffRendererConfigManager.GetRawGitArgs()...)
ArgIf(forUI, diffRendererConfigManager.GetRawGitArgs()...)
}
func (self *GitCommandBuilder) ToArgv() []string {
+2 -46
View File
@@ -6,8 +6,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"time"
@@ -162,51 +160,9 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
return queryString, variables
}
// GetAuthToken returns the token to authenticate against the given host with,
// or an empty string if there is none.
//
// The token has to come from gh itself rather than from an in-process lookup
// with go-gh: that reads gh's config file once per process and answers from
// that snapshot ever after, whereas gh rewrites the file whenever the active
// account changes, and keeps the active account's token either there or in the
// system keyring. Under a long-running lazygit the snapshot therefore drifts
// out of date, leaving us with a token for an account that is no longer active,
// or with no token at all.
func (self *GitHubCommands) GetAuthToken(host string) string {
ghExe := ghExecutable()
if ghExe == "" {
// Without gh installed, the environment variables and config file that
// gh would have consulted are still worth a look.
token, _ := auth.TokenFromEnvOrConfig(host)
return token
}
cmdArgs := []string{ghExe, "auth", "token", "--hostname", host}
output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
// Not being logged in to this host is a normal state rather than
// something to report; the runner logs gh's stderr for the rest.
return ""
}
return strings.TrimSpace(output)
}
// ghExecutable returns the path of the gh binary, or an empty string if it
// isn't installed.
func ghExecutable() string {
if ghExe := os.Getenv("GH_PATH"); ghExe != "" {
return ghExe
}
// A gh found in the current directory rather than on PATH comes back as
// exec.ErrDot, which we treat as not having found one at all.
ghExe, err := exec.LookPath("gh")
if err != nil {
return ""
}
return ghExe
token, _ := auth.TokenForHost(host)
return token
}
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo
+48 -45
View File
@@ -11,7 +11,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
)
@@ -24,9 +23,10 @@ type PatchCommands struct {
PatchBuilder *patch.PatchBuilder
// The version of the patch the diff trees were last written for, so that they are
// written again when, and only when, the patch has changed since.
treesWrittenForGeneration int
// 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(
@@ -47,86 +47,89 @@ func NewPatchCommands(
}
}
// EnsureCustomPatchDiffTrees writes the custom patch's diff trees if what is there no
// longer describes the patch. Call it before rendering the patch, which is often — every
// time the panel showing it re-renders — while the patch itself changes rarely.
// 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.treesWrittenForGeneration {
if self.PatchBuilder.Generation() == self.lastBuiltTreeGeneration {
return nil
}
if err := self.WriteCustomPatchDiffTrees(); err != nil {
return err
}
self.treesWrittenForGeneration = self.PatchBuilder.Generation()
self.lastBuiltTreeGeneration = self.PatchBuilder.Generation()
return nil
}
// WriteCustomPatchDiffTrees materializes the custom patch as two file trees under the
// directory the patch builder keeps for it: `a` holds each of the patch's files as it is
// before the patch, `b` as it is after. Diffing those two trees against each other
// (DiffCommands.CustomPatchDiffCmdObj) turns the patch into a diff of real files, which
// can then be rendered exactly as any other diff is — through a diff renderer of any
// kind, and with git's own idea of how much context to show.
// 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).
//
// The trees are named a and b so that the diff's paths, with git's own prefixes
// suppressed, come out reading like the a/ and b/ of an ordinary diff, over the real
// repo-relative paths.
// 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
}
before := filepath.Join(dir, "a")
after := filepath.Join(dir, "b")
for _, tree := range []string{before, after} {
if err := os.RemoveAll(tree); err != 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(tree, 0o700); err != nil {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
for _, file := range self.PatchBuilder.FilesInPatch() {
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, file.ContentPath).RunWithOutput()
// A file the patch adds has no content on the before side, so git has nothing to
// show for it.
added := err != nil
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
// The before side holds an added file as an empty file rather than not at all, so
// that the diff pairs the two sides up and states the file's real path, instead of
// reporting a file that only one of the trees has.
if err := self.os.CreateFileWithContent(filepath.Join(before, file.Path),
lo.Ternary(added, "", content)); err != nil {
// 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
}
// The after side is seeded with the same content, for the patch to change; a file
// the patch adds is left absent, for the patch to create.
// 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(after, file.Path), content); err != nil {
if err := self.os.CreateFileWithContent(filepath.Join(bDir, filename), content); err != nil {
return err
}
}
}
// Write added files as creations rather than as diffs against an empty file: the
// patch is applied in one go, so a file it expects to be there already would make
// the whole of it fail.
// 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) == "" {
// Nothing in the patch, so the two trees are alike and the diff is empty.
// 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").
NoLineEndingConversion().
Arg(patchFilePath).
Dir(after).
ToArgv()).Run()
return self.cmd.New(NewGitCmd("apply").Arg(patchFilePath).Dir(bDir).ToArgv()).Run()
}
type ApplyPatchOpts struct {
-223
View File
@@ -1,223 +0,0 @@
package git_commands
import (
"slices"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
)
// The tip commit of a ref, as far as sorting refs by ancestry needs it. The
// date is the raw %(committerdate:unix) field, and is only ever compared for
// equality.
type refTip struct {
hash string
committerDate string
}
// Determining ancestry costs one %(ahead-behind:<tip>) field per tip, and git
// evaluates each of those for every tip we ask about, so the work grows with
// the square of the number of tips. Branches whose tips came out of one rebase
// are nowhere near this many, so leave the refs in the order git returned them
// once the tips add up to more than this.
const maxTipsForAncestrySorting = 100
// Sorts each group of refs that share a committer date so that a ref comes
// before the refs it is descended from. Refs that are not descended from one
// another keep the order they came in.
//
// git sorts refs with equal committer dates by name, and a stack of branches
// that gets rebased in one go ends up with the same committer date on all of
// its tips. Without this, such a stack appears in alphabetical order.
//
// refs must be sorted by committer date already, and tips must have an entry
// for each of them.
func sortRefsWithEqualDatesByAncestry[T any](
cmd oscommands.ICmdObjBuilder,
version *GitVersion,
refs []T,
fullRefName func(T) string,
tips map[string]refTip,
) error {
// %(ahead-behind:...) was added in git 2.41
if !version.IsAtLeast(2, 41, 0) {
return nil
}
groups := lo.Filter(groupRefsWithEqualDates(refs, fullRefName, tips),
func(group []T, _ int) bool {
return pointAtMoreThanOneCommit(group, fullRefName, tips)
})
if len(groups) == 0 {
return nil
}
// Ancestry is a property of the tip commits, so ask git about each of them
// once, however many refs point at it. A repository with several remotes
// has the same branches under each of them, and they all share a date.
tipHashes := []string{}
refNames := []string{}
seenTips := set.New[string]()
for _, ref := range lo.Flatten(groups) {
refName := fullRefName(ref)
if hash := tips[refName].hash; !seenTips.Includes(hash) {
seenTips.Add(hash)
tipHashes = append(tipHashes, hash)
refNames = append(refNames, refName)
}
}
if len(tipHashes) > maxTipsForAncestrySorting {
return nil
}
containedTips, err := loadContainedTips(cmd, tipHashes, refNames)
if err != nil {
return err
}
for _, group := range groups {
sortGroupByAncestry(group, fullRefName, tips, containedTips)
}
return nil
}
// Refs that all point at the same commit have no order to be put in, so
// there is nothing to ask git about them
func pointAtMoreThanOneCommit[T any](
refs []T,
fullRefName func(T) string,
tips map[string]refTip,
) bool {
firstHash := tips[fullRefName(refs[0])].hash
return lo.SomeBy(refs, func(ref T) bool {
return tips[fullRefName(ref)].hash != firstHash
})
}
// Returns the runs of consecutive refs that share a committer date, for runs of
// more than one ref. The returned slices share their backing array with refs,
// so sorting a run sorts that part of refs.
func groupRefsWithEqualDates[T any](
refs []T,
fullRefName func(T) string,
tips map[string]refTip,
) [][]T {
committerDate := func(ref T) string {
return tips[fullRefName(ref)].committerDate
}
groups := [][]T{}
start := 0
for i := 1; i <= len(refs); i++ {
if i < len(refs) && committerDate(refs[i]) == committerDate(refs[start]) {
continue
}
if i-start > 1 {
groups = append(groups, refs[start:i])
}
start = i
}
return groups
}
// For each of the given tips, which of the tips its history contains. Keyed by
// tip hash, with an entry for every tip passed in. refNames names, for each
// tip, a ref that points at it; git reports the values by ref name.
func loadContainedTips(
cmd oscommands.ICmdObjBuilder,
tipHashes []string,
refNames []string,
) (map[string]*set.Set[string], error) {
output, err := cmd.New(
buildAheadBehindForEachRefArgs(tipHashes, refNames),
).DontLog().RunWithOutput()
if err != nil {
return nil, err
}
containedTips := make(map[string]*set.Set[string], len(tipHashes))
for _, hash := range tipHashes {
containedTips[hash] = set.New[string]()
}
tipByRefName := make(map[string]string, len(refNames))
for i, refName := range refNames {
tipByRefName[refName] = tipHashes[i]
}
for _, entry := range parseAheadBehindForEachRefOutput(output, len(tipHashes)) {
hash, ok := tipByRefName[entry.refName]
if !ok {
continue
}
contained := containedTips[hash]
for i, ab := range entry.aheadBehinds {
// The tip's history contains the other tip if it has commits the
// other one doesn't have, and the other one has none it doesn't
// have.
if ab.valid && ab.ahead > 0 && ab.behind == 0 {
contained.Add(tipHashes[i])
}
}
}
return containedTips, nil
}
func sortGroupByAncestry[T any](
group []T,
fullRefName func(T) string,
tips map[string]refTip,
containedTips map[string]*set.Set[string],
) {
hashes := lo.Map(group, func(ref T, _ int) string {
return tips[fullRefName(ref)].hash
})
contained := lo.Map(hashes, func(hash string, _ int) *set.Set[string] {
if contained, ok := containedTips[hash]; ok {
return contained
}
return set.New[string]()
})
isDescendedFrom := func(descendant int, ancestor int) bool {
return contained[descendant].Includes(hashes[ancestor])
}
// Indices into group, holding the refs we haven't placed yet
remaining := lo.Range(len(group))
// A ref can only go in once every ref that is descended from it is in
canBePlaced := func(i int) bool {
return !lo.SomeBy(remaining, func(j int) bool {
return isDescendedFrom(j, i)
})
}
sorted := make([]T, 0, len(group))
lastPlaced := -1
for len(remaining) > 0 {
next := -1
if lastPlaced != -1 {
// Walk from the ref we placed last down to the refs it is based on,
// so that the branches of a stack come out as one run even when an
// unrelated branch sorts into the middle of them by name
next = slices.IndexFunc(remaining, func(i int) bool {
return isDescendedFrom(lastPlaced, i) && canBePlaced(i)
})
}
if next == -1 {
next = slices.IndexFunc(remaining, canBePlaced)
}
if next == -1 {
// Commits can't descend from each other in a circle; only take the
// first ref so that we don't spin if the values ever say otherwise.
next = 0
}
lastPlaced = remaining[next]
sorted = append(sorted, group[lastPlaced])
remaining = slices.Delete(remaining, next, next+1)
}
copy(group, sorted)
}
@@ -1,331 +0,0 @@
package git_commands
import (
"errors"
"fmt"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
// A branch as the tests below describe it: a name, the hash of its tip, and the
// committer date of its tip
type testRef struct {
name string
hash string
date string
}
func buildTestRefs(refs []testRef) ([]*models.Branch, map[string]refTip) {
branches := lo.Map(refs, func(ref testRef, _ int) *models.Branch {
return &models.Branch{Name: ref.name, CommitHash: ref.hash}
})
tips := make(map[string]refTip, len(refs))
for _, ref := range refs {
tips["refs/heads/"+ref.name] = refTip{hash: ref.hash, committerDate: ref.date}
}
return branches, tips
}
func branchNames(branches []*models.Branch) []string {
return lo.Map(branches, func(branch *models.Branch, _ int) string {
return branch.Name
})
}
func TestGroupRefsWithEqualDates(t *testing.T) {
scenarios := []struct {
testName string
refs []testRef
expected [][]string
}{
{
testName: "no refs",
refs: []testRef{},
expected: [][]string{},
},
{
testName: "all dates distinct",
refs: []testRef{
{name: "a", hash: "1", date: "300"},
{name: "b", hash: "2", date: "200"},
{name: "c", hash: "3", date: "100"},
},
expected: [][]string{},
},
{
testName: "all dates equal",
refs: []testRef{
{name: "a", hash: "1", date: "100"},
{name: "b", hash: "2", date: "100"},
{name: "c", hash: "3", date: "100"},
},
expected: [][]string{{"a", "b", "c"}},
},
{
testName: "two groups, with a lone ref between and after them",
refs: []testRef{
{name: "a", hash: "1", date: "300"},
{name: "b", hash: "2", date: "300"},
{name: "c", hash: "3", date: "200"},
{name: "d", hash: "4", date: "100"},
{name: "e", hash: "5", date: "100"},
{name: "f", hash: "6", date: "50"},
},
expected: [][]string{{"a", "b"}, {"d", "e"}},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
branches, tips := buildTestRefs(s.refs)
groups := groupRefsWithEqualDates(branches, (*models.Branch).FullRefName, tips)
assert.Equal(t, s.expected, lo.Map(groups,
func(group []*models.Branch, _ int) []string {
return branchNames(group)
}))
})
}
}
func TestSortRefsWithEqualDatesByAncestry(t *testing.T) {
// A stack of three branches, all committed within the same second, as git
// returns them: sorted by name. bottom is the base of middle, which is the
// base of top.
stack := []testRef{
{name: "middle", hash: "m", date: "100"},
{name: "top", hash: "t", date: "100"},
{name: "bottom", hash: "b", date: "100"},
}
// One %(ahead-behind:<hash>) field per tip, in the order the tips appear in
// the branch list
stackOutput := "refs/heads/middle\x000 0\x000 1\x001 0\n" +
"refs/heads/top\x001 0\x000 0\x002 0\n" +
"refs/heads/bottom\x000 1\x000 2\x000 0\n"
stackArgs := []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:t)%00%(ahead-behind:b)",
"refs/heads/middle", "refs/heads/top", "refs/heads/bottom",
}
scenarios := []struct {
testName string
refs []testRef
gitVersion *GitVersion
expectedArgs []string
output string
outputErr error
expectedOrder []string
expectedErr string
}{
{
testName: "a stack of branches is sorted from the top down",
refs: stack,
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: stackArgs,
output: stackOutput,
expectedOrder: []string{"top", "middle", "bottom"},
},
{
testName: "git too old for %(ahead-behind:...), so nothing to do",
refs: stack,
gitVersion: &GitVersion{2, 40, 0, ""},
expectedOrder: []string{"middle", "top", "bottom"},
},
{
testName: "no two refs share a date, so nothing to do",
refs: []testRef{
{name: "middle", hash: "m", date: "300"},
{name: "top", hash: "t", date: "200"},
{name: "bottom", hash: "b", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedOrder: []string{"middle", "top", "bottom"},
},
{
testName: "the command fails",
refs: stack,
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: stackArgs,
outputErr: errors.New("fatal: failed to find 'm'"),
expectedOrder: []string{"middle", "top", "bottom"},
expectedErr: "fatal: failed to find 'm'",
},
{
testName: "a branch unrelated to the stack is placed by name",
refs: []testRef{
{name: "middle", hash: "m", date: "100"},
{name: "other", hash: "o", date: "100"},
{name: "top", hash: "t", date: "100"},
{name: "bottom", hash: "b", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:o)%00%(ahead-behind:t)%00%(ahead-behind:b)",
"refs/heads/middle", "refs/heads/other", "refs/heads/top", "refs/heads/bottom",
},
output: "refs/heads/middle\x000 0\x002 1\x000 1\x001 0\n" +
"refs/heads/other\x001 2\x000 0\x001 3\x001 1\n" +
"refs/heads/top\x001 0\x003 1\x000 0\x002 0\n" +
"refs/heads/bottom\x000 1\x001 1\x000 2\x000 0\n",
expectedOrder: []string{"other", "top", "middle", "bottom"},
},
{
testName: "a stack stays together when a branch sorts into the middle of it",
refs: []testRef{
{name: "add-tests", hash: "t", date: "100"},
{name: "cleanup", hash: "c", date: "100"},
{name: "fix-parser", hash: "p", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:t)%00%(ahead-behind:c)%00%(ahead-behind:p)",
"refs/heads/add-tests", "refs/heads/cleanup", "refs/heads/fix-parser",
},
// add-tests is based on fix-parser, and cleanup is on a line of its
// own, but its name sorts between the two
output: "refs/heads/add-tests\x000 0\x002 1\x001 0\n" +
"refs/heads/cleanup\x001 2\x000 0\x001 1\n" +
"refs/heads/fix-parser\x000 1\x001 1\x000 0\n",
expectedOrder: []string{"add-tests", "fix-parser", "cleanup"},
},
{
testName: "each group is sorted on its own",
refs: []testRef{
{name: "middle", hash: "m", date: "200"},
{name: "top", hash: "t", date: "200"},
{name: "lone", hash: "l", date: "150"},
{name: "base", hash: "a", date: "100"},
{name: "derived", hash: "d", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:t)%00%(ahead-behind:a)%00%(ahead-behind:d)",
"refs/heads/middle", "refs/heads/top", "refs/heads/base", "refs/heads/derived",
},
output: "refs/heads/middle\x000 0\x000 1\x002 0\x001 0\n" +
"refs/heads/top\x001 0\x000 0\x003 0\x002 0\n" +
"refs/heads/base\x000 2\x000 3\x000 0\x000 1\n" +
"refs/heads/derived\x000 1\x000 2\x001 0\x000 0\n",
expectedOrder: []string{"top", "middle", "lone", "derived", "base"},
},
{
testName: "a group whose branches are all on one commit is left alone",
refs: []testRef{
{name: "a", hash: "x", date: "100"},
{name: "b", hash: "x", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedOrder: []string{"a", "b"},
},
{
testName: "branches on the same commit are asked about once",
refs: []testRef{
{name: "mirror-1", hash: "s", date: "100"},
{name: "mirror-2", hash: "s", date: "100"},
{name: "stack-bottom", hash: "u", date: "100"},
{name: "stack-top", hash: "t", date: "100"},
},
gitVersion: &GitVersion{2, 41, 0, ""},
expectedArgs: []string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:s)%00%(ahead-behind:u)%00%(ahead-behind:t)",
"refs/heads/mirror-1", "refs/heads/stack-bottom", "refs/heads/stack-top",
},
output: "refs/heads/mirror-1\x000 0\x001 1\x001 2\n" +
"refs/heads/stack-bottom\x001 1\x000 0\x000 1\n" +
"refs/heads/stack-top\x002 1\x001 0\x000 0\n",
expectedOrder: []string{"mirror-1", "mirror-2", "stack-top", "stack-bottom"},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
runner := oscommands.NewFakeRunner(t)
if s.expectedArgs != nil {
runner.ExpectGitArgs(s.expectedArgs, s.output, s.outputErr)
}
gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: s.gitVersion})
branches, tips := buildTestRefs(s.refs)
err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version,
branches, (*models.Branch).FullRefName, tips)
if s.expectedErr == "" {
assert.NoError(t, err)
} else {
assert.ErrorContains(t, err, s.expectedErr)
}
assert.Equal(t, s.expectedOrder, branchNames(branches))
runner.CheckForMissingCalls()
})
}
}
func TestSortRefsWithEqualDatesByAncestry_TooManyTips(t *testing.T) {
refs := lo.Map(lo.Range(maxTipsForAncestrySorting+1), func(i int, _ int) testRef {
return testRef{
name: fmt.Sprintf("branch-%03d", i),
hash: fmt.Sprintf("hash-%03d", i),
date: "100",
}
})
// The runner fails the test if the command runs at all
runner := oscommands.NewFakeRunner(t)
gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: &GitVersion{2, 41, 0, ""}})
branches, tips := buildTestRefs(refs)
err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version,
branches, (*models.Branch).FullRefName, tips)
assert.NoError(t, err)
assert.Equal(t, lo.Map(refs, func(ref testRef, _ int) string { return ref.name }),
branchNames(branches))
runner.CheckForMissingCalls()
}
// A repository with many remotes has the same branches under each of them, so
// a group can hold far more refs than the commits they point at
func TestSortRefsWithEqualDatesByAncestry_ManyRefsOnTwoTips(t *testing.T) {
refs := lo.Map(lo.Range(4*maxTipsForAncestrySorting), func(i int, _ int) testRef {
return testRef{
name: fmt.Sprintf("branch-%03d", i),
hash: lo.Ternary(i%2 == 0, "bottom", "top"),
date: "100",
}
})
runner := oscommands.NewFakeRunner(t).ExpectGitArgs([]string{
"for-each-ref",
"--format=%(refname)%00%(ahead-behind:bottom)%00%(ahead-behind:top)",
"refs/heads/branch-000", "refs/heads/branch-001",
},
"refs/heads/branch-000\x000 0\x000 1\n"+
"refs/heads/branch-001\x001 0\x000 0\n", nil)
gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: &GitVersion{2, 41, 0, ""}})
branches, tips := buildTestRefs(refs)
err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version,
branches, (*models.Branch).FullRefName, tips)
assert.NoError(t, err)
// the branches on the top commit first, each half still ordered by name
expected := append(
lo.FilterMap(refs, func(ref testRef, _ int) (string, bool) {
return ref.name, ref.hash == "top"
}),
lo.FilterMap(refs, func(ref testRef, _ int) (string, bool) {
return ref.name, ref.hash == "bottom"
})...)
assert.Equal(t, expected, branchNames(branches))
runner.CheckForMissingCalls()
}
+2 -3
View File
@@ -59,10 +59,9 @@ func (self *RemoteCommands) DeleteRemoteBranch(task gocui.Task, remoteName strin
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
}
func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagNames []string) error {
func (self *RemoteCommands) DeleteRemoteTag(task gocui.Task, remoteName string, tagName string) error {
cmdArgs := NewGitCmd("push").
Arg(remoteName, "--delete").
Arg(lo.Map(tagNames, func(t string, _ int) string { return "refs/tags/" + t })...).
Arg(remoteName, "--delete", "refs/tags/"+tagName).
ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
+56 -52
View File
@@ -5,25 +5,53 @@ import (
"maps"
"slices"
"strings"
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/samber/lo"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type RemoteLoader struct {
*GitCommon
*common.Common
cmd oscommands.ICmdObjBuilder
}
func NewRemoteLoader(gitCommon *GitCommon) *RemoteLoader {
return &RemoteLoader{GitCommon: gitCommon}
func NewRemoteLoader(
common *common.Common,
cmd oscommands.ICmdObjBuilder,
) *RemoteLoader {
return &RemoteLoader{
Common: common,
cmd: cmd,
}
}
// GetRemotes returns the repo's remotes, without their branches; those are
// loaded separately with GetRemoteBranchesByRemoteName, which takes a lot longer
// in a repo with many remote branches.
func (self *RemoteLoader) GetRemotes() []*models.Remote {
func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) {
wg := sync.WaitGroup{}
wg.Add(1)
var remoteBranchesByRemoteName map[string][]*models.RemoteBranch
var remoteBranchesErr error
go utils.Safe(func() {
defer wg.Done()
remoteBranchesByRemoteName, remoteBranchesErr = self.getRemoteBranchesByRemoteName()
})
remotes := self.getRemotesFromConfig()
wg.Wait()
if remoteBranchesErr != nil {
return nil, remoteBranchesErr
}
for _, remote := range remotes {
remote.Branches = remoteBranchesByRemoteName[remote.Name]
}
// now lets sort our remotes by name alphabetically
slices.SortFunc(remotes, func(a, b *models.Remote) int {
// we want origin at the top because we'll be most likely to want it
@@ -36,7 +64,7 @@ func (self *RemoteLoader) GetRemotes() []*models.Remote {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return remotes
return remotes, nil
}
func (self *RemoteLoader) getRemotesFromConfig() []*models.Remote {
@@ -83,47 +111,29 @@ func (self *RemoteLoader) getRemotesFromConfig() []*models.Remote {
return slices.Collect(maps.Values(remotesByName))
}
// GetRemoteBranchesByRemoteName returns all remote branches, keyed by the name
// of the remote they belong to.
func (self *RemoteLoader) GetRemoteBranchesByRemoteName() (map[string][]*models.RemoteBranch, error) {
remoteBranches, err := self.getRemoteBranches()
if err != nil {
return nil, err
}
func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models.RemoteBranch, error) {
remoteBranchesByRemoteName := make(map[string][]*models.RemoteBranch)
return lo.GroupBy(remoteBranches, func(branch *models.RemoteBranch) string {
return branch.RemoteName
}), nil
}
// Returns all remote branches, sorted the way the config asks for
func (self *RemoteLoader) getRemoteBranches() ([]*models.RemoteBranch, error) {
sortByDate := strings.ToLower(self.UserConfig().Git.RemoteBranchSortOrder) == "date"
sortOrder := "refname"
if sortByDate {
var sortOrder string
switch strings.ToLower(self.UserConfig().Git.RemoteBranchSortOrder) {
case "alphabetical":
sortOrder = "refname"
case "date":
sortOrder = "-committerdate"
}
// Asking for the tip of a branch makes git read its commit, so only do it
// when we are going to sort by ancestry below
format := "%(refname)"
if sortByDate {
format += "%00%(objectname)%00%(committerdate:unix)"
default:
sortOrder = "refname"
}
cmdArgs := NewGitCmd("for-each-ref").
Arg(fmt.Sprintf("--sort=%s", sortOrder)).
Arg(fmt.Sprintf("--format=%s", format)).
Arg("--format=%(refname)").
Arg("refs/remotes").
ToArgv()
remoteBranches := []*models.RemoteBranch{}
tips := map[string]refTip{}
err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line string) (bool, error) {
fields := strings.Split(strings.TrimSpace(line), "\x00")
refName := fields[0]
line = strings.TrimSpace(line)
split := strings.SplitN(refName, "/", 4)
split := strings.SplitN(line, "/", 4)
if len(split) != 4 {
return false, nil
}
@@ -134,27 +144,21 @@ func (self *RemoteLoader) getRemoteBranches() ([]*models.RemoteBranch, error) {
return false, nil
}
remoteBranches = append(remoteBranches,
_, ok := remoteBranchesByRemoteName[remoteName]
if !ok {
remoteBranchesByRemoteName[remoteName] = []*models.RemoteBranch{}
}
remoteBranchesByRemoteName[remoteName] = append(remoteBranchesByRemoteName[remoteName],
&models.RemoteBranch{
Name: name,
RemoteName: remoteName,
})
if len(fields) == 3 {
tips[refName] = refTip{hash: fields[1], committerDate: fields[2]}
}
return false, nil
})
if err != nil {
return nil, err
}
if sortByDate {
if err := sortRefsWithEqualDatesByAncestry(
self.cmd, self.version, remoteBranches, (*models.RemoteBranch).FullRefName, tips,
); err != nil {
self.Log.Errorf("Failed to sort remote branches by ancestry: %v", err)
}
}
return remoteBranches, nil
return remoteBranchesByRemoteName, nil
}
@@ -6,6 +6,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/stretchr/testify/assert"
)
@@ -94,7 +95,8 @@ func TestGetRemotesFromConfig(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.testName, func(t *testing.T) {
loader := &RemoteLoader{
GitCommon: buildGitCommon(commonDeps{runner: scenario.runner}),
Common: common.NewDummyCommon(),
cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner),
}
// map iteration order is non-deterministic, so compare unordered
+42 -2
View File
@@ -1,6 +1,7 @@
package git_commands
import (
ioFs "io/fs"
"os"
"path/filepath"
"strings"
@@ -9,6 +10,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spf13/afero"
)
type RepoPaths struct {
@@ -271,13 +273,13 @@ func callGitRevParseWithDir(
return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...))
}
// Asks git about a repo that isn't the one we have open; see ForOtherRepo.
// 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...)))
return runGitRevParse(forOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...)))
}
func newGitRevParseCmd(
@@ -300,3 +302,41 @@ func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
}
return strings.TrimSpace(res), nil
}
// Returns the paths of linked worktrees
func linkedWortkreePaths(fs afero.Fs, repoGitDirPath string) []string {
result := []string{}
// For each directory in this path we're going to cat the `gitdir` file and append its contents to our result
// That file points us to the `.git` file in the worktree.
worktreeGitDirsPath := filepath.Join(repoGitDirPath, "worktrees")
// ensure the directory exists
_, err := fs.Stat(worktreeGitDirsPath)
if err != nil {
return result
}
_ = afero.Walk(fs, worktreeGitDirsPath, func(currPath string, info ioFs.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
return nil
}
gitDirPath := filepath.Join(currPath, "gitdir")
gitDirBytes, err := afero.ReadFile(fs, gitDirPath)
if err != nil {
// ignoring error
return nil
}
trimmedGitDir := strings.TrimSpace(string(gitDirBytes))
// removing the .git part
worktreeDir := filepath.Dir(trimmedGitDir)
result = append(result, worktreeDir)
return nil
})
return result
}
+3 -3
View File
@@ -80,14 +80,14 @@ func (self *StashCommands) Hash(index int) (string, error) {
return strings.Trim(hash, "\r\n"), err
}
func (self *StashCommands) ShowStashEntryCmdObj(index int, mode DiffMode) *oscommands.CmdObj {
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(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
Arg("-p").
Arg("--stat").
Arg("-u").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(fmt.Sprintf("refs/stash@{%d}", index)).
Dir(self.repoPaths.worktreePath).
ToArgv()
+2 -2
View File
@@ -139,7 +139,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
similarityThreshold: 50,
ignoreWhitespace: false,
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
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",
@@ -174,7 +174,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
}
instance := buildStashCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
cmdStr := instance.ShowStashEntryCmdObj(s.index, DiffModeRendered).Args()
cmdStr := instance.ShowStashEntryCmdObj(s.index, false).Args()
assert.Equal(t, s.expected, cmdStr)
})
}
+5 -5
View File
@@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
Config("log.showsignature=false").
ToArgv()
summary, err := ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
return strings.TrimSpace(summary), err
}
@@ -167,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 ForOtherRepo(self.cmd.New(cmdArgs)).Run()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
}
// ConflictSideLog returns a oneline log, run inside the submodule, of the commits
@@ -179,7 +179,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi
Arg("--oneline", "--color=always", otherSide+".."+side).
ToArgv()
return ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
}
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
@@ -195,7 +195,7 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
Arg("--include-untracked").
ToArgv()
return ForOtherRepo(self.cmd.New(cmdArgs)).Run()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
@@ -229,7 +229,7 @@ func (self *SubmoduleCommands) UpdateAll() error {
// need not be.
func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error {
if submodule.ParentModule != nil {
ForOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
}
return cmdObj.Run()
}
+11 -25
View File
@@ -6,7 +6,6 @@ import (
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/samber/lo"
)
type SyncCommands struct {
@@ -19,21 +18,18 @@ func NewSyncCommands(gitCommon *GitCommon) *SyncCommands {
}
}
// Push pushes to a branch
type PushOpts struct {
Force bool
ForceWithLease bool
CurrentBranch string
UpstreamRemote string
UpstreamBranch string
SetUpstream bool
// The remote to push to. If empty, git picks it from its configuration,
// and Refspecs must be empty too.
Remote string
// What to push, each in the form "refs/heads/<local branch>:<remote ref>".
// If empty, git decides what to push based on push.default and
// remote.<name>.push.
Refspecs []string
}
func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommands.CmdObj, error) {
if len(opts.Refspecs) > 0 && opts.Remote == "" {
if opts.UpstreamBranch != "" && opts.UpstreamRemote == "" {
return nil, errors.New(self.Tr.MustSpecifyOriginError)
}
@@ -41,8 +37,8 @@ func (self *SyncCommands) PushCmdObj(task gocui.Task, opts PushOpts) (*oscommand
ArgIf(opts.Force, "--force").
ArgIf(opts.ForceWithLease, "--force-with-lease").
ArgIf(opts.SetUpstream, "--set-upstream").
ArgIf(opts.Remote != "", opts.Remote).
Arg(opts.Refspecs...).
ArgIf(opts.UpstreamRemote != "", opts.UpstreamRemote).
ArgIf(opts.UpstreamBranch != "", fmt.Sprintf("refs/heads/%s:%s", opts.CurrentBranch, opts.UpstreamBranch)).
ToArgv()
cmdObj := self.cmd.New(cmdArgs).PromptOnCredentialRequest(task)
@@ -114,25 +110,15 @@ func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error {
return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run()
}
// Fetches the given branches of the given remote, updating their
// remote-tracking branches. Local branches are left alone, including the ones
// that track them.
func (self *SyncCommands) FetchRemoteBranches(
func (self *SyncCommands) FastForward(
task gocui.Task,
branchName string,
remoteName string,
remoteBranchNames []string,
remoteBranchName string,
) error {
// The explicit destinations and the leading + make sure that the
// remote-tracking branches are updated even when the remote branches were
// rewritten, whatever the remote's fetch refspec says
refspecs := lo.Map(remoteBranchNames, func(remoteBranchName string, _ int) string {
return fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
remoteBranchName, remoteName, remoteBranchName)
})
cmdArgs := self.fetchCommandBuilder(false).
Arg(remoteName).
Arg(refspecs...).
Arg("refs/heads/" + remoteBranchName + ":" + branchName).
ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
+13 -22
View File
@@ -41,11 +41,12 @@ func TestSyncPush(t *testing.T) {
},
},
{
testName: "Push with force disabled, refspec supplied",
testName: "Push with force disabled, upstream supplied",
opts: PushOpts{
ForceWithLease: false,
Remote: "origin",
Refspecs: []string{"refs/heads/master:master"},
CurrentBranch: "master",
UpstreamRemote: "origin",
UpstreamBranch: "master",
},
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "origin", "refs/heads/master:master"})
@@ -56,8 +57,9 @@ func TestSyncPush(t *testing.T) {
testName: "Push with force disabled, setting upstream",
opts: PushOpts{
ForceWithLease: false,
Remote: "origin",
Refspecs: []string{"refs/heads/master-local:master"},
CurrentBranch: "master-local",
UpstreamRemote: "origin",
UpstreamBranch: "master",
SetUpstream: true,
},
test: func(cmdObj *oscommands.CmdObj, err error) {
@@ -69,8 +71,9 @@ func TestSyncPush(t *testing.T) {
testName: "Push with force-with-lease enabled, setting upstream",
opts: PushOpts{
ForceWithLease: true,
Remote: "origin",
Refspecs: []string{"refs/heads/master:master"},
CurrentBranch: "master",
UpstreamRemote: "origin",
UpstreamBranch: "master",
SetUpstream: true,
},
test: func(cmdObj *oscommands.CmdObj, err error) {
@@ -79,23 +82,11 @@ func TestSyncPush(t *testing.T) {
},
},
{
testName: "Push several refspecs",
testName: "Push with remote branch but no origin",
opts: PushOpts{
ForceWithLease: true,
Remote: "origin",
Refspecs: []string{"refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"},
},
test: func(cmdObj *oscommands.CmdObj, err error) {
assert.Equal(t, cmdObj.Args(), []string{"git", "push", "--force-with-lease", "origin", "refs/heads/a:refs/heads/a", "refs/heads/b:refs/heads/b"})
assert.NoError(t, err)
},
},
{
testName: "Push with refspec but no remote",
opts: PushOpts{
ForceWithLease: true,
Remote: "",
Refspecs: []string{"refs/heads/master:master"},
UpstreamRemote: "",
UpstreamBranch: "master",
SetUpstream: true,
},
test: func(cmdObj *oscommands.CmdObj, err error) {
+4 -6
View File
@@ -5,7 +5,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/samber/lo"
)
type TagCommands struct {
@@ -47,16 +46,15 @@ func (self *TagCommands) HasTag(tagName string) bool {
return self.cmd.New(cmdArgs).DontLog().Run() == nil
}
func (self *TagCommands) LocalDelete(tagNames []string) error {
cmdArgs := NewGitCmd("tag").Arg("-d").Arg(tagNames...).
func (self *TagCommands) LocalDelete(tagName string) error {
cmdArgs := NewGitCmd("tag").Arg("-d", tagName).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *TagCommands) Push(task gocui.Task, remoteName string, tagNames []string) error {
cmdArgs := NewGitCmd("push").Arg(remoteName).
Arg(lo.FlatMap(tagNames, func(t string, _ int) []string { return []string{"tag", t} })...).
func (self *TagCommands) Push(task gocui.Task, remoteName string, tagName string) error {
cmdArgs := NewGitCmd("push").Arg(remoteName, "tag", tagName).
ToArgv()
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
+33 -50
View File
@@ -383,28 +383,42 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
}
// WorktreeFileDiff returns the diff of a file
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, mode DiffMode, cached bool) string {
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, mode, cached, file.Names()).RunWithOutput()
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, false, nil).RunWithOutput()
return s
}
// WorktreeFileDiffCmdObj returns a command object for diffing the given paths
// in the working tree. node is the item they belong to; all it decides is
// whether git has to compare against /dev/null, which is the case for a file
// that isn't in the index yet.
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode DiffMode, cached bool, paths []string) *oscommands.CmdObj {
// 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).
// 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"
}
prevPath := node.GetPreviousPath()
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
paths := pathOverrides
if len(paths) == 0 {
paths = []string{node.GetPath()}
}
cmdArgs := NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("--color=%s", colorArg)).
ArgIf(cached, "--cached").
ArgIf(noIndex, "--no-index").
Arg("--").
ArgIf(noIndex, "/dev/null").
Arg(paths...).
ArgIf(prevPath != "", prevPath).
Dir(self.repoPaths.worktreePath).
ToArgv()
@@ -415,20 +429,25 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode
// but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode.
// For a renamed file, previousPath is the path it was renamed from (empty otherwise);
// both paths must be passed to git for the rename to be detected.
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, mode DiffMode) (string, error) {
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, plain bool) (string, error) {
fileNames := []string{fileName}
if previousPath != "" {
fileNames = append(fileNames, previousPath)
}
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, mode).RunWithOutput()
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain, false).RunWithOutput()
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, mode DiffMode) *oscommands.CmdObj {
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"
}
cmdArgs := NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("--color=%s", colorArg)).
Arg(from).
Arg(to).
ArgIf(reverse, "-R").
@@ -514,42 +533,6 @@ func (self *WorkingTreeCommands) ResetSoft(ref string) error {
return self.cmd.New(cmdArgs).Run()
}
// ResetKeep runs `git reset --keep` in the given worktree, which moves the
// checked out branch to the given ref while keeping local modifications. It
// fails rather than overwriting a file that differs between the two commits.
// Pass empty strings for the worktree to use the current one.
func (self *WorkingTreeCommands) ResetKeep(ref string, worktreeGitDir string, worktreePath string) error {
cmdArgs := NewGitCmd("reset").Arg("--keep", ref).
GitDirIf(worktreeGitDir != "", worktreeGitDir).
WorktreePathIf(worktreePath != "", worktreePath).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
// Returns whether the given worktree has changes to tracked files, either in
// its working tree or in its index. Untracked files don't count, and neither do
// submodules. A submodule that is checked out at a different commit than the
// one recorded, or that has changes of its own, doesn't get in the way of
// moving the branch, because moving it leaves the submodules alone. Pass empty
// strings for the worktree to use the current one.
func (self *WorkingTreeCommands) HasChangesToTrackedFiles(worktreeGitDir string, worktreePath string) (bool, error) {
cmdArgs := NewGitCmd("status").
Arg("--porcelain").
Arg("--untracked-files=no").
Arg("--ignore-submodules").
GitDirIf(worktreeGitDir != "", worktreeGitDir).
WorktreePathIf(worktreePath != "", worktreePath).
ToArgv()
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return false, err
}
return stdout != "", nil
}
func (self *WorkingTreeCommands) ResetMixed(ref string) error {
cmdArgs := NewGitCmd("reset").Arg("--mixed", ref).
ToArgv()
+15 -15
View File
@@ -197,7 +197,7 @@ func TestWorkingTreeDiff(t *testing.T) {
type scenario struct {
testName string
file *models.File
mode DiffMode
plain bool
cached bool
ignoreWhitespace bool
contextSize uint64
@@ -215,7 +215,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -230,7 +230,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: true,
ignoreWhitespace: false,
contextSize: 3,
@@ -245,7 +245,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModePlain,
plain: true,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -260,7 +260,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: false,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -275,7 +275,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: true,
contextSize: 3,
@@ -290,7 +290,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: false,
contextSize: 17,
@@ -305,7 +305,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
mode: DiffModeRendered,
plain: false,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -326,7 +326,7 @@ func TestWorkingTreeDiff(t *testing.T) {
}
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
result := instance.WorktreeFileDiff(s.file, s.mode, s.cached)
result := instance.WorktreeFileDiff(s.file, s.plain, s.cached)
assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls()
})
@@ -341,7 +341,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
reverse bool
fileName string
previousPath string
mode DiffMode
plain bool
ignoreWhitespace bool
contextSize uint64
runner *oscommands.FakeCmdObjRunner
@@ -356,7 +356,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
mode: DiffModeRendered,
plain: false,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -368,7 +368,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
mode: DiffModeRendered,
plain: false,
ignoreWhitespace: false,
contextSize: 123,
runner: oscommands.NewFakeRunner(t).
@@ -380,7 +380,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
mode: DiffModeRendered,
plain: false,
ignoreWhitespace: true,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -393,7 +393,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
reverse: false,
fileName: "new.txt",
previousPath: "old.txt",
mode: DiffModeRendered,
plain: false,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -412,7 +412,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.mode)
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.plain)
assert.NoError(t, err)
assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls()
+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 ForOtherRepo(self.cmd.New(cmdArgs)).Run()
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
}
func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) {
@@ -140,14 +140,12 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
rebasedBranch, ok := self.rebasedBranch(worktree)
if ok {
worktree.Branch = rebasedBranch
worktree.IsRebasingOrBisecting = true
continue
}
bisectedBranch, ok := self.bisectedBranch(worktree)
if ok {
worktree.Branch = bisectedBranch
worktree.IsRebasingOrBisecting = true
continue
}
}
-21
View File
@@ -31,13 +31,6 @@ type Branch struct {
// 'git@github.com:tiwood/lazygit.git'
UpstreamRemote string
UpstreamBranch string
// The remote and the remote branch that `git push` would push this branch
// to, as git determines them from push.default, remote.pushDefault and
// branch.<name>.pushRemote. In a triangular workflow these differ from the
// upstream. Both are empty if git has no push destination for the branch,
// e.g. because push.default is "upstream" and the branch has no upstream.
PushRemote string
PushBranch string
// subject line in commit message
Subject string
// commit hash
@@ -47,13 +40,6 @@ type Branch struct {
// determined yet, or up to date with base branch. (We don't need to
// distinguish the two, as we don't draw anything in both cases.)
BehindBaseBranch atomic.Int32
// Whether the branch has diverged from its upstream because the upstream
// branch was rewritten, and not because the branch has commits of its own.
// Such a branch can be reset to its upstream without losing anything.
// False for branches that haven't diverged, and for those we haven't
// determined it for yet.
UpstreamRewritten atomic.Bool
}
func (b *Branch) FullRefName() string {
@@ -133,13 +119,6 @@ func (b *Branch) IsBehindForPush() bool {
return b.RemoteBranchStoredLocally() && b.BehindForPush != "0"
}
// Whether the branch has commits that its push destination doesn't have. False
// if the remote branch it would be pushed to isn't stored locally, in which
// case the count is "?".
func (b *Branch) IsAheadForPush() bool {
return b.RemoteBranchStoredLocally() && b.AheadForPush != "0" && b.AheadForPush != "?"
}
// for when we're in a detached head state
func (b *Branch) IsRealBranch() bool {
return b.AheadForPull != "" && b.BehindForPull != ""
-3
View File
@@ -19,9 +19,6 @@ type Worktree struct {
// * the worktree is mid-rebase on the branch
// * the worktree is mid-bisect on the branch
Branch string
// If true, the worktree is mid-rebase or mid-bisect on Branch, so its HEAD
// is detached rather than pointing at the branch
IsRebasingOrBisecting bool
// The HEAD sha of the worktree. Always populated (even when Branch is set).
// Used for display when Branch is empty (detached HEAD state).
Head string
-8
View File
@@ -108,14 +108,6 @@ func (self *CmdObj) GetEnvVars() []string {
return self.cmd.Env
}
// SetEnviron replaces the command's whole environment, for a command that has
// to run in the same one as another command rather than in this process's.
func (self *CmdObj) SetEnviron(env []string) *CmdObj {
self.cmd.Env = env
return self
}
// sets the working directory
func (self *CmdObj) SetWd(wd string) *CmdObj {
self.cmd.Dir = wd
@@ -33,16 +33,6 @@ func (self *CmdObjBuilder) New(args []string) *CmdObj {
return cmdObj
}
// NewFromCmd wraps a command that has already been built, for a caller that
// holds an *exec.Cmd and needs it as a CmdObj. The command itself is shared,
// not copied, so whatever was set on it still applies.
func (self *CmdObjBuilder) NewFromCmd(cmd *exec.Cmd) *CmdObj {
return &CmdObj{
cmd: cmd,
runner: self.runner,
}
}
// A command with explicit environment from env
func (self *CmdObjBuilder) NewWithEnviron(args []string, env []string) *CmdObj {
cmd := exec.Command(args[0], args[1:]...)
+43 -5
View File
@@ -4,10 +4,12 @@ import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/go-errors/errors"
"github.com/samber/lo"
"github.com/atotto/clipboard"
"github.com/jesseduffield/lazygit/pkg/common"
@@ -201,25 +203,61 @@ func (c *OSCommand) FileExists(path string) (bool, error) {
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
c.logPipeline(cmdObjs)
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
return cmdObj.GetCmd()
})
cmds, parentEnds, err := wirePipeline(cmdObjs)
if err != nil {
return err
logCmdStr := strings.Join(
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
return cmdObj.ToString()
}),
" | ",
)
c.LogCommand(logCmdStr, true)
for i := range len(cmds) - 1 {
stdout, err := cmds[i].StdoutPipe()
if err != nil {
return err
}
cmds[i+1].Stdin = stdout
}
// keeping this here in case I adapt this code for some other purpose in the future
// cmds[len(cmds)-1].Stdout = os.Stdout
stderrs := make([]bytes.Buffer, len(cmds))
for i := range cmds {
cmds[i].Stderr = &stderrs[i]
}
started, startErr := startPipeline(cmds, parentEnds)
// 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] {
-180
View File
@@ -1,180 +0,0 @@
package oscommands
import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/samber/lo"
)
// Pipeline is a chain of running commands, each one's output feeding the next
// one's input, with the last one's output going somewhere the caller reads. Its
// method set is the one a render task expects of a command (see tasks.Cmd), so
// a pipeline can render a view just as a single command can.
type Pipeline struct {
cmds []*exec.Cmd
cmdStr string
}
// StartPipeline starts the given commands wired A | B | C and returns the
// pipeline together with the reader for its output.
//
// Every command's stderr goes to that same output, so whatever a command
// complains about is part of what the caller reads. A diff renderer's error
// message belongs on screen with the diff it failed to render.
//
// Closing the reader is how a pipeline is brought down. The last command's next
// write fails, so it exits, and the failure travels back up the chain as each
// command in turn writes into a pipe whose reader is gone.
func (c *OSCommand) StartPipeline(cmdObjs ...*CmdObj) (*Pipeline, io.ReadCloser, error) {
c.logPipeline(cmdObjs)
cmds, parentEnds, err := wirePipeline(cmdObjs)
if err != nil {
return nil, nil, err
}
reader, writer, err := os.Pipe()
if err != nil {
closeAll(parentEnds)
return nil, nil, err
}
for _, cmd := range cmds {
cmd.Stderr = writer
}
cmds[len(cmds)-1].Stdout = writer
parentEnds = append(parentEnds, writer)
started, err := startPipeline(cmds, parentEnds)
if err != nil {
for _, cmd := range cmds[:started] {
_ = cmd.Wait()
}
_ = reader.Close()
return nil, nil, err
}
return &Pipeline{cmds: cmds, cmdStr: pipelineString(cmdObjs)}, reader, nil
}
func (self *Pipeline) String() string {
return self.cmdStr
}
// Wait waits for every command to exit and reports the failure nearest the end
// of the pipeline. A command that fails leaves the ones before it writing into
// a pipe nobody reads, so their own broken-pipe failures are consequences of it
// rather than the cause worth reporting, while a command that fails early
// leaves the ones after it with nothing to read and no reason to fail at all.
func (self *Pipeline) Wait() error {
var lastErr error
for _, cmd := range self.cmds {
if err := cmd.Wait(); err != nil {
lastErr = fmt.Errorf("%s: %w", cmd.String(), err)
}
}
return lastErr
}
// Terminate asks every command to stop, without waiting for any of them. On
// platforms where that does nothing, the pipeline comes down when its output
// reader is closed; see StartPipeline.
func (self *Pipeline) Terminate() error {
var firstErr error
for _, cmd := range self.cmds {
if err := TerminateProcessGracefully(cmd.Process); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// logPipeline enters a chain of commands into the command log, unless the first
// command was marked not to be logged; it speaks for the pipeline. A render runs
// its pipeline again on every selection change, so a caller has to be able to
// keep it out of the log.
func (c *OSCommand) logPipeline(cmdObjs []*CmdObj) {
if cmdObjs[0].ShouldLog() {
c.LogCommand(pipelineString(cmdObjs), true)
}
}
// pipelineString names a chain of commands the way a shell would write it.
func pipelineString(cmdObjs []*CmdObj) string {
return strings.Join(
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
return cmdObj.ToString()
}),
" | ",
)
}
// wirePipeline connects each command's output to the next one's input, like
// A | B | C, and returns the commands along with the parent's ends of those
// pipes. The last command's output is left for the caller to direct.
//
// The parent's ends have to be closed once the commands are running.
// startPipeline does that; see there for why it matters.
func wirePipeline(cmdObjs []*CmdObj) ([]*exec.Cmd, []io.Closer, error) {
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
return cmdObj.GetCmd()
})
parentEnds := []io.Closer{}
for i := range len(cmds) - 1 {
reader, writer, err := os.Pipe()
if err != nil {
closeAll(parentEnds)
return nil, nil, err
}
cmds[i].Stdout = writer
cmds[i+1].Stdin = reader
parentEnds = append(parentEnds, reader, writer)
}
return cmds, parentEnds, nil
}
// startPipeline starts every command and reports how many it got going. Every
// one is started before any of them is waited for: waiting closes our end of
// the pipe that feeds the next command, and one that hasn't been started by
// then would inherit a closed stdin.
//
// Once they are all running, each of them holds its own ends of the pipes it
// reads and writes, and the parent lets go of its copies. Both directions
// matter. While the parent holds the read end of a link, a command writing
// into it never learns that the command meant to read it is gone, and keeps
// running after the pipeline has been brought down. While the parent holds the
// write end, the command reading it never reaches the end of its input.
//
// When a command fails to start, the ones already running are killed, since
// without the rest of the pipeline to drain them they could block forever
// writing to a full pipe. They still have to be reaped, so the count covers
// them too.
func startPipeline(cmds []*exec.Cmd, parentEnds []io.Closer) (int, error) {
defer closeAll(parentEnds)
for i, cmd := range cmds {
if err := cmd.Start(); err != nil {
for _, started := range cmds[:i] {
_ = started.Process.Kill()
}
return i, err
}
}
return len(cmds), nil
}
func closeAll(closers []io.Closer) {
for _, closer := range closers {
_ = closer.Close()
}
}
-150
View File
@@ -1,150 +0,0 @@
package oscommands
import (
"fmt"
"io"
"os"
"strings"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// The pipeline tests need programs to run, and the test binary is the one
// program every platform we test on is sure to have. pipelineMember builds a
// command that re-runs this binary in the role a member of the pipeline is to
// play; the roles are in TestPipelineMember.
const pipelineRoleEnvVar = "LAZYGIT_TEST_PIPELINE_ROLE"
func pipelineMember(role string) *CmdObj {
return NewDummyOSCommand().Cmd.
New([]string{os.Args[0], "-test.run=^TestPipelineMember$"}).
AddEnvVars(pipelineRoleEnvVar + "=" + role)
}
// TestPipelineMember is the program the pipeline tests run, not a test of its
// own. It exits before the testing package reports anything, so that its output
// is what the role wrote and nothing else.
//
// For the same reason it exits with syscall.Exit, which skips the exit hooks
// that os.Exit runs. In a binary built with -cover, one of these hooks writes
// coverage data to $GOCOVERDIR and prints an error to stderr if that fails. On
// Windows it fails now and then if two members of a pipeline exit at the same
// time, because both of them replace the same file in that directory.
func TestPipelineMember(t *testing.T) {
switch os.Getenv(pipelineRoleEnvVar) {
case "":
t.Skip("not a test; the pipeline tests run this binary in a role")
case "count":
for i := 1; i <= 3; i++ {
fmt.Printf("line %d\n", i)
}
case "upcase":
input, _ := io.ReadAll(os.Stdin)
fmt.Print(strings.ToUpper(string(input)))
case "copy":
_, _ = io.Copy(os.Stdout, os.Stdin)
case "complain":
fmt.Fprintln(os.Stderr, "something went wrong")
syscall.Exit(3)
case "flood":
// A failed write means the reader is gone, and there is no point
// writing to nobody. On platforms that raise a signal for it instead,
// this process is already dead by the time the write returns.
for i := 1; ; i++ {
if _, err := fmt.Printf("line %d\n", i); err != nil {
break
}
}
}
syscall.Exit(0)
}
func TestStartPipelineStreamsTheOutputOfTheLastCommand(t *testing.T) {
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
pipelineMember("count"),
pipelineMember("upcase"),
)
assert.NoError(t, err)
output, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "LINE 1\nLINE 2\nLINE 3\n", string(output))
assert.NoError(t, pipeline.Wait())
assert.NoError(t, reader.Close())
}
func TestStartPipelineReadsWhatTheCommandsComplainAbout(t *testing.T) {
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
pipelineMember("count"),
pipelineMember("complain"),
)
assert.NoError(t, err)
output, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "something went wrong\n", string(output))
// The failure of the command nearest the output is the one reported, even
// though the one feeding it was left writing into a pipe nobody reads.
assert.ErrorContains(t, pipeline.Wait(), "exit status 3")
assert.NoError(t, reader.Close())
}
func TestClosingAPipelinesOutputBringsItDown(t *testing.T) {
pipeline, reader, err := NewDummyOSCommand().StartPipeline(
pipelineMember("flood"),
pipelineMember("copy"),
)
assert.NoError(t, err)
// Read some output first, so that both commands are past their startup and
// really running when the reader goes.
buf := make([]byte, len("line 1\n"))
_, err = io.ReadFull(reader, buf)
assert.NoError(t, err)
assert.Equal(t, "line 1\n", string(buf))
assert.NoError(t, reader.Close())
done := make(chan error, 1)
go func() { done <- pipeline.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the pipeline was still running long after its output was closed")
}
}
func TestStartPipelineReportsACommandItCannotStart(t *testing.T) {
osCommand := NewDummyOSCommand()
_, _, err := osCommand.StartPipeline(
pipelineMember("count"),
osCommand.Cmd.New([]string{"lazygit-no-such-command"}),
)
assert.Error(t, err)
}
func TestPipeCommandsReturnsWhenALaterCommandDiesEarly(t *testing.T) {
done := make(chan error, 1)
go func() {
done <- NewDummyOSCommand().PipeCommands(
pipelineMember("flood"),
pipelineMember("complain"),
)
}()
select {
case err := <-done:
assert.ErrorContains(t, err, "something went wrong")
case <-time.After(10 * time.Second):
t.Fatal("PipeCommands was still waiting for a command whose output nothing reads")
}
}
+4 -3
View File
@@ -144,7 +144,9 @@ func TerminateLivePtys() {
// 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.
// 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
@@ -202,8 +204,7 @@ func (p *winPty) Close() error {
// slave closes on child exit, but ConPTY keeps the pipe alive until we call
// ClosePseudoConsole explicitly. Without doing that on child exit, the
// scanner in pkg/tasks.NewCmdTask would block forever on the next read and
// the render would never reach its end of input, so the new content would
// never be swapped in.
// the post-content view never gets cleared (FlushStaleCells never fires).
func startWaiter(proc *os.Process, p *winPty) func() error {
done := make(chan struct{})
var waitErr error
+4 -5
View File
@@ -16,9 +16,9 @@ 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 header line ('2' and '3' in the above example),
// kept so that we can check the parsed body against them (see
// Patch.IsWellFormed). Only set by Parse.
// 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
@@ -49,8 +49,7 @@ func (self *Hunk) lineCount() int {
// Returns all lines in the hunk, including the header line
func (self *Hunk) allLines() []*PatchLine {
lines := make([]*PatchLine, 1, 1+len(self.bodyLines))
lines[0] = &PatchLine{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}
lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}}
lines = append(lines, self.bodyLines...)
return lines
}
+4 -4
View File
@@ -7,8 +7,8 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
// Captures, in order: the old start, the old length (omitted by git when it is
// 1), the new start, the new length (likewise), and the trailing context.
// 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 {
@@ -57,8 +57,8 @@ func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLe
return oldStart, oldLength, newStart, newLength, headerContext
}
// declaredLength parses a length capture of a hunk header, which git omits when
// it is 1 (e.g. "@@ -0,0 +1 @@").
// 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
+93 -32
View File
@@ -79,37 +79,16 @@ 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 — a diff renderer that puts line numbers in
// a gutter, say, 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. That
// makes this the test for whether a rendered diff can be parsed as a unified diff
// at all, rather than trusting a mis-parse. Only meaningful for patches produced
// by Parse, which is where the declared lengths come from.
// 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 {
return self.isWellFormed(false)
}
// IsWellFormedSoFar is IsWellFormed for a patch parsed from a diff we have only the
// beginning of. Its last hunk holds the first lines of a body that hasn't all arrived,
// so every hunk but the last has to match its header exactly, as before, while the last
// one only has to fit within what its header declares.
//
// The check exists to tell a faithful rendering from a restructured one, and it still
// does that. A rendering that moves the +/- marker off the start of the line makes us
// read a change as context, and a context line counts towards both lengths, so such a
// hunk comes out longer than its header declares rather than shorter.
func (self *Patch) IsWellFormedSoFar() bool {
return self.isWellFormed(true)
}
func (self *Patch) isWellFormed(lastHunkMayBeIncomplete bool) bool {
for i, hunk := range self.hunks {
if lastHunkMayBeIncomplete && i == len(self.hunks)-1 {
return hunk.oldLength() <= hunk.declaredOldLength &&
hunk.newLength() <= hunk.declaredNewLength
}
for _, hunk := range self.hunks {
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
return false
}
@@ -156,8 +135,10 @@ func (self *Patch) LineNumberOfLine(idx int) int {
// 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.
// 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
@@ -183,6 +164,72 @@ func (self *Patch) OldLineNumberOfLine(idx int) int {
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 {
@@ -265,3 +312,17 @@ func (self *Patch) AdjustLineNumber(lineNumber int) int {
return adjustedLineNumber
}
func (self *Patch) IsSingleHunkForWholeFile() bool {
if len(self.hunks) != 1 {
return false
}
// We consider a patch to be a single hunk for the whole file if it has only additions or
// deletions but not both, and no context lines. This not quite correct, because it will also
// return true for a block of added or deleted lines if the diff context size is 0, but in this
// case you wouldn't be able to stage things anyway, so it doesn't matter.
bodyLines := self.hunks[0].bodyLines
return nLinesWithKind(bodyLines, []PatchLineKind{DELETION, CONTEXT}) == 0 ||
nLinesWithKind(bodyLines, []PatchLineKind{ADDITION, CONTEXT}) == 0
}
+104 -159
View File
@@ -6,7 +6,6 @@ import (
"strings"
"github.com/jesseduffield/generics/maps"
"github.com/jesseduffield/generics/set"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
@@ -35,7 +34,7 @@ type fileInfo struct {
}
type (
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string) (string, error)
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error)
)
// PatchBuilder manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit). We also support building patches from things like stashes, for which there is less flexibility
@@ -63,22 +62,21 @@ type PatchBuilder struct {
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
loadFileDiff loadFileDiffFunc
// newTempDir makes a directory for the current patch to be materialized into, as
// two file trees that can be diffed against each other and so rendered like any
// other diff (see PatchCommands.WriteCustomPatchDiffTrees). Its lifetime is the
// patch's: made when one is started, removed when it is given up.
// 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 counts the changes made to the patch, so that whoever materializes it
// can tell whether what they last built still describes it — and rebuild only then,
// rather than on every render of it.
// 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, newTempDir func() (string, error),
) *PatchBuilder {
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error)) *PatchBuilder {
return &PatchBuilder{
Log: log,
loadFileDiff: loadFileDiff,
@@ -91,7 +89,14 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
defer p.mutex.Unlock()
p.generation++
p.makeTempDir()
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
@@ -111,87 +116,35 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
return p.fileInfoMap
}
// TempDir is the directory the patch is materialized into for rendering, and "" when
// there is none — no patch, or a directory we failed to make.
// 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 {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.tempDir
}
// Generation says which version of the patch this is; see the field.
// Generation is bumped each time the patch's contents change; see the field comment.
func (p *PatchBuilder) Generation() int {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.generation
}
// makeTempDir replaces the directory the patch is materialized into with a fresh one.
// Only call this with the lock held.
func (p *PatchBuilder) makeTempDir() {
p.removeTempDir()
if p.newTempDir == nil {
return
}
dir, err := p.newTempDir()
if err != nil {
p.Log.Error(err)
return
}
p.tempDir = dir
}
// removeTempDir takes the patch's materialized form away with the patch. Only call this
// with the lock held.
func (p *PatchBuilder) removeTempDir() {
if p.tempDir == "" {
return
if p.tempDir != "" {
_ = os.RemoveAll(p.tempDir)
p.tempDir = ""
}
if err := os.RemoveAll(p.tempDir); err != nil {
p.Log.Error(err)
}
p.tempDir = ""
}
// PatchFile records what materializing the patch needs to know about one of its files:
// where the patch expects to find it, and where its content before the patch comes from.
type PatchFile struct {
// Path is the name the patch knows the file by: for a renamed file, the name it had
// before where the patch carries the rename, and the name it was renamed to where the
// patch keeps only a content change and leaves the rename behind.
Path string
// ContentPath is where the file's content before the patch is to be found in the
// commit the patch is built from — for a renamed file always the name it had there,
// whatever the patch calls it.
ContentPath string
}
// FilesInPatch says which files the patch touches, in a stable order, and where each of
// them comes from.
func (p *PatchBuilder) FilesInPatch() []PatchFile {
fileInfoMap := p.snapshotFileInfoMap()
filenames := maps.Keys(fileInfoMap)
// 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)
files := make([]PatchFile, 0, len(filenames))
for _, filename := range filenames {
info := fileInfoMap[filename]
if info.mode == UNSELECTED {
continue
}
file := PatchFile{Path: filename, ContentPath: filename}
if info.previousPath != "" {
file.ContentPath = info.previousPath
if info.mode == WHOLE {
file.Path = info.previousPath
}
}
files = append(files, file)
}
return files
return filenames
}
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
@@ -251,6 +204,7 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
}
p.generation++
p.removeFile(info)
return nil
@@ -267,7 +221,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI
return info, nil
}
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath)
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true)
if err != nil {
return nil, err
}
@@ -398,23 +352,31 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus
return info.mode
}
// LineIdentity says which change line of a file is meant — the line number it has on
// the side it belongs to, and whether it is a deletion — without reference to where
// that line sits in the file's parsed diff.
//
// It is how a diff shown in the main view speaks about its lines: what a rendered row
// resolves to is a line of a file, while the index of that line in the diff depends on
// how much of the diff is being shown and in what order a renderer laid it out.
func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return nil, err
}
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 indexes a parsed diff's change lines by their identity. An
// addition is numbered in the new file and a deletion in the old one. Two consecutive
// deletions share the one new-file position between them, and numbering them in the
// old file keeps them apart.
func ChangeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
// 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 {
@@ -427,89 +389,72 @@ func ChangeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
return byIdentity
}
// ChangeLineIndicesForLines maps the given change lines of a parsed diff to their
// indices in it. A line that names no change line of the diff — a context line, or a
// line that isn't in the diff at all — contributes nothing.
func ChangeLineIndicesForLines(parsed *Patch, lines []LineIdentity) []int {
byIdentity := ChangeLineIndexByIdentity(parsed)
// 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
return indices, nil
}
// PatchLineIndicesForLines maps change lines of filename to their indices in that
// file's diff; the patch is built in terms of those indices. everyChange reports
// whether the given lines cover all of the file's changes; this distinguishes acting
// on some of a file's lines from acting on the file itself.
func (p *PatchBuilder) PatchLineIndicesForLines(
filename string, previousPath string, lines []LineIdentity,
) (indices []int, everyChange bool, err error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return nil, false, err
}
parsed := Parse(info.diff)
selected := set.NewFromSlice(lines)
everyChange = lo.EveryBy(maps.Keys(ChangeLineIndexByIdentity(parsed)),
func(identity LineIdentity) bool { return selected.Includes(identity) })
return ChangeLineIndicesForLines(parsed, lines), everyChange, nil
}
// IncludedLineIdentities says which change lines of filename are in the patch, as the
// identities a diff of that file shown anywhere can be compared against. Empty for a
// file that is no part of the patch.
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
info, ok := p.snapshotFileInfoMap()[filename]
// 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
}
included := set.NewFromSlice(info.includedLineIndices)
identities := []LineIdentity{}
for identity, idx := range ChangeLineIndexByIdentity(Parse(info.diff)) {
if included.Includes(idx) {
// 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
}
// IncludedChangeLineIndices says which of filename's change lines are in the patch, as
// their indices in the file's diff and in the order the file has them.
//
// It is how a line of the patch as it is shown names the line of the diff it came from:
// all that can be said about a line of the patch is which of the file's changes it is,
// its line numbers being the patch's own — a patch that leaves an earlier addition out
// numbers everything after it differently from the diff it was built from.
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
info, ok := p.snapshotFileInfoMap()[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
included := set.NewFromSlice(info.includedLineIndices)
indices := []int{}
for idx, line := range Parse(info.diff).Lines() {
if (line.IsAddition() || line.IsDeletion()) && included.Includes(idx) {
indices = append(indices, idx)
}
}
return indices
}
func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return nil, err
}
return info.includedLineIndices, nil
}
// clears the patch
func (p *PatchBuilder) Reset() {
p.mutex.Lock()
+21 -108
View File
@@ -7,134 +7,47 @@ import (
"github.com/stretchr/testify/assert"
)
// newTestPatchBuilder returns a patch builder started for a dummy commit, in which
// every file's diff is the given one.
// newTestPatchBuilder returns a PatchBuilder whose files all resolve to the given
// diff, started for a dummy commit.
func newTestPatchBuilder(diff string) *PatchBuilder {
patchBuilder := NewPatchBuilder(logrus.New().WithField("test", "test"),
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
pb := NewPatchBuilder(logrus.New().WithField("test", "test"),
func(from, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
return diff, nil
},
// Nothing here renders the patch, so it needs no directory to be
// materialized into.
nil)
patchBuilder.Start("from", "to", false, true)
return patchBuilder
pb.Start("from", "to", false, true)
return pb
}
// In simpleDiff the deletion "-orange" is line index 6 of the parsed diff (line 2 of
// the old file) and the addition "+grape" is index 7 (line 2 of the new file).
// 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) {
patchBuilder := newTestPatchBuilder(simpleDiff)
pb := newTestPatchBuilder(simpleDiff)
indices, everyChange, err := patchBuilder.PatchLineIndicesForLines("filename", "", []LineIdentity{
indices, err := pb.PatchLineIndicesForLines("filename", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
{LineNumber: 1, IsDeletion: false}, // " apple", a context line
{LineNumber: 1, IsDeletion: false}, // " apple" — a context line, no change index
})
assert.NoError(t, err)
assert.Equal(t, []int{6, 7}, indices, "the context line names no change line")
assert.True(t, everyChange, "the two changes are all the diff has")
}
// A renamed file's rename header makes its change lines sit further down the diff, and
// its old-file line numbers are of the file under its previous name.
func TestPatchLineIndicesForLinesOfARenamedFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
indices, everyChange, err := patchBuilder.PatchLineIndicesForLines("newname", "oldname", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
})
assert.NoError(t, err)
assert.Equal(t, []int{9, 10}, indices)
assert.True(t, everyChange)
}
// everyChange is about the diff alone: whether anything the file changes was left out
// of the selection. What that then means for the patch is the caller's question.
func TestPatchLineIndicesForLinesEveryChange(t *testing.T) {
patchBuilder := newTestPatchBuilder(newFile)
_, everyChange, err := patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{
{LineNumber: 1},
{LineNumber: 2},
})
assert.NoError(t, err)
assert.False(t, everyChange, "the file's third added line is left out")
_, everyChange, err = patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{
{LineNumber: 1},
{LineNumber: 2},
{LineNumber: 3},
})
assert.NoError(t, err)
assert.True(t, everyChange)
// A line the diff doesn't have doesn't stand in for one it does.
patchBuilder = newTestPatchBuilder(deletedFile)
_, everyChange, err = patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{
{LineNumber: 1, IsDeletion: true},
{LineNumber: 2, IsDeletion: true},
{LineNumber: 4, IsDeletion: true},
})
assert.NoError(t, err)
assert.False(t, everyChange)
assert.Equal(t, []int{6, 7}, indices, "context-line identity is skipped; change lines map to their indices")
}
func TestIncludedLineIdentities(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
pb := newTestPatchBuilder(simpleDiff)
// A file no part of the patch has nothing included.
assert.Empty(t, patchBuilder.IncludedLineIdentities("filename"))
// Nothing included yet.
assert.Empty(t, pb.IncludedLineIdentities("filename"))
// With only the deletion in, only its identity comes back.
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
// 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}},
patchBuilder.IncludedLineIdentities("filename"))
pb.IncludedLineIdentities("filename"))
// With the addition in as well, both do.
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{7}))
// 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}},
patchBuilder.IncludedLineIdentities("filename"))
}
func TestFilesInPatch(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
// A file no part of the patch is no part of what the patch is materialized from.
assert.Empty(t, patchBuilder.FilesInPatch())
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
assert.Equal(t,
[]PatchFile{{Path: "filename", ContentPath: "filename"}},
patchBuilder.FilesInPatch())
}
// A renamed file's content is under the name it had before whatever the patch calls the
// file, and the patch calls it by the name it had before only where it carries the
// rename — a partial selection has the rename stripped and names the file by the new one.
func TestFilesInPatchOfARenamedFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
assert.NoError(t, patchBuilder.AddFileLineRange("newname", "oldname", []int{9}))
assert.Equal(t,
[]PatchFile{{Path: "newname", ContentPath: "oldname"}},
patchBuilder.FilesInPatch())
assert.NoError(t, patchBuilder.AddFileWhole("newname", "oldname"))
assert.Equal(t,
[]PatchFile{{Path: "oldname", ContentPath: "oldname"}},
patchBuilder.FilesInPatch())
}
// A file taken into the patch whole has every one of its change lines in it.
func TestIncludedLineIdentitiesOfAWholeFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
assert.NoError(t, patchBuilder.AddFileWhole("filename", ""))
assert.ElementsMatch(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
patchBuilder.IncludedLineIdentities("filename"))
pb.IncludedLineIdentities("filename"))
}
+117 -109
View File
@@ -122,7 +122,7 @@ index 9320895..6d79956 100644
// 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
// tell them apart.
// distinguish them.
const consecutiveDeletions = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
@@ -696,108 +696,6 @@ func TestLineNumberOfLine(t *testing.T) {
}
}
func TestIsWellFormed(t *testing.T) {
// The body of a diff as rendered with the +/- markers moved out of the text
// and into a gutter: every body line now reads as context, so the lengths no
// longer match the header.
const gutterMangled = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
`
scenarios := []struct {
testName string
patchStr string
expected bool
}{
{"simpleDiff", simpleDiff, true},
{"renameWithModificationDiff", renameWithModificationDiff, true},
{"addNewlineToEndOfFile", addNewlineToEndOfFile, true},
{"twoHunks", twoHunks, true},
{"consecutiveDeletions", consecutiveDeletions, true},
{"newFile", newFile, true},
{"deletedFile", deletedFile, true},
{"addNewlineToPreviouslyEmptyFile", addNewlineToPreviouslyEmptyFile, true},
{"exampleHunk", exampleHunk, true},
{"gutterMangled", gutterMangled, false},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormed())
})
}
}
func TestIsWellFormedSoFar(t *testing.T) {
// A diff read only as far as the middle of its second hunk.
const cutShort = `diff --git a/filename b/filename
index e48a11c..b2ab81b 100644
--- a/filename
+++ b/filename
@@ -1,5 +1,5 @@
apple
-grape
+orange
...
...
...
@@ -8,6 +8,8 @@ grape
...
...
`
// The same diff cut short in its first hunk, so that the second is missing
// entirely rather than short.
const cutShortInTheFirstHunk = `diff --git a/filename b/filename
index e48a11c..b2ab81b 100644
--- a/filename
+++ b/filename
@@ -1,5 +1,5 @@
apple
-grape
`
// A rendering with the +/- markers moved into a gutter, cut short: reading the
// changes as context makes the hunk longer than its header declares, not shorter,
// so it doesn't pass for a diff we only have the beginning of.
const gutterMangledAndCutShort = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
melon
`
scenarios := []struct {
testName string
patchStr string
expected bool
}{
{"simpleDiff", simpleDiff, true},
{"twoHunks", twoHunks, true},
{"cutShort", cutShort, true},
{"cutShortInTheFirstHunk", cutShortInTheFirstHunk, true},
{"gutterMangledAndCutShort", gutterMangledAndCutShort, false},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormedSoFar())
})
}
}
func TestOldLineNumberOfLine(t *testing.T) {
type scenario struct {
testName string
@@ -819,12 +717,6 @@ func TestOldLineNumberOfLine(t *testing.T) {
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
},
{
testName: "renameWithModificationDiff",
patchStr: renameWithModificationDiff,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5},
},
}
for _, s := range scenarios {
@@ -838,6 +730,61 @@ func TestOldLineNumberOfLine(t *testing.T) {
}
}
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
@@ -921,3 +868,64 @@ func TestAdjustLineNumber(t *testing.T) {
})
}
}
func TestIsSingleHunkForWholeFile(t *testing.T) {
scenarios := []struct {
testName string
patchStr string
expectedResult bool
}{
{
testName: "simpleDiff",
patchStr: simpleDiff,
expectedResult: false,
},
{
testName: "addNewlineToEndOfFile",
patchStr: addNewlineToEndOfFile,
expectedResult: false,
},
{
testName: "removeNewlinefromEndOfFile",
patchStr: removeNewlinefromEndOfFile,
expectedResult: false,
},
{
testName: "twoHunks",
patchStr: twoHunks,
expectedResult: false,
},
{
testName: "twoChangesInOneHunk",
patchStr: twoChangesInOneHunk,
expectedResult: false,
},
{
testName: "newFile",
patchStr: newFile,
expectedResult: true,
},
{
testName: "deletedFile",
patchStr: deletedFile,
expectedResult: true,
},
{
testName: "addNewlineToPreviouslyEmptyFile",
patchStr: addNewlineToPreviouslyEmptyFile,
expectedResult: true,
},
{
testName: "exampleHunk",
patchStr: exampleHunk,
expectedResult: false,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
patch := Parse(s.patchStr)
assert.Equal(t, s.expectedResult, patch.IsSingleHunkForWholeFile())
})
}
}
+5 -59
View File
@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"time"
@@ -297,8 +296,6 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
{[]string{"keybinding", "universal", "cyclePagers"}, "cycleDiffRenderers"},
{[]string{"keybinding", "universal", "cyclePagersReverse"}, "cycleDiffRenderersReverse"},
{[]string{"gui", "windowSize"}, "screenMode"},
{[]string{"gui", "wrapLinesInStagingView"}, "wrapLinesInDiffView"},
{[]string{"gui", "useHunkModeInStagingView"}, "useHunkModeInDiffView"},
{[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"},
}
@@ -312,13 +309,6 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
}
}
// This creates gui.branchColorPatterns, so it must run before the move of
// that key into gui.theme below.
err = migrateBranchColors(&rootNode, changes)
if err != nil {
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
}
pathsToMove := []struct {
oldPath []string
newPath []string
@@ -327,14 +317,6 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
[]string{"keybinding", "worktrees", "viewWorktreeOptions"},
[]string{"keybinding", "universal", "newWorktree"},
},
{
[]string{"gui", "authorColors"},
[]string{"gui", "theme", "authorColors"},
},
{
[]string{"gui", "branchColorPatterns"},
[]string{"gui", "theme", "branchColorPatterns"},
},
}
for _, pathToMove := range pathsToMove {
@@ -648,43 +630,6 @@ func migratePagersToDiffRenderers(rootNode *yaml.Node, changes *ChangesSet) erro
})
}
// The deprecated gui.branchColors matched its keys against the part of a branch
// name before the first slash. Turn each key into a pattern that matches the
// same branches. If the file has a non-empty gui.branchColorPatterns,
// gui.branchColors was ignored, so remove it.
func migrateBranchColors(rootNode *yaml.Node, changes *ChangesSet) error {
return yaml_utils.TransformNode(rootNode, []string{"gui"}, func(guiNode *yaml.Node) error {
branchColorsKeyNode, branchColorsValueNode := yaml_utils.LookupKey(guiNode, "branchColors")
if branchColorsKeyNode == nil || branchColorsValueNode.Kind != yaml.MappingNode {
return nil
}
patternsKeyNode, patternsValueNode := yaml_utils.LookupKey(guiNode, "branchColorPatterns")
if patternsKeyNode != nil {
switch {
case patternsValueNode.Kind == yaml.MappingNode && len(patternsValueNode.Content) > 0:
yaml_utils.RemoveKey(guiNode, "branchColors")
changes.Add("Removed 'gui.branchColors'; it had no effect because 'gui.branchColorPatterns' is set")
return nil
case patternsValueNode.Kind == yaml.MappingNode || patternsValueNode.Tag == "!!null":
yaml_utils.RemoveKey(guiNode, "branchColorPatterns")
default:
return nil
}
}
branchColorsKeyNode.Value = "branchColorPatterns"
for i := 0; i < len(branchColorsValueNode.Content)-1; i += 2 {
keyNode := branchColorsValueNode.Content[i]
keyNode.Value = "^" + regexp.QuoteMeta(keyNode.Value) + "(/|$)"
keyNode.Tag = "!!str"
}
changes.Add("Converted 'gui.branchColors' to 'gui.branchColorPatterns'")
return nil
})
}
func hasNonNullScalarValue(node *yaml.Node) bool {
return node != nil && node.Kind == yaml.ScalarNode && node.Tag != "!!null" && node.Value != ""
}
@@ -900,10 +845,11 @@ func (c *AppConfig) SaveGlobalUserConfig() {
// AppState stores data between runs of the app like when the last update check
// was performed and which other repos have been checked out
type AppState struct {
LastUpdateCheck int64
RecentRepos []string
StartupPopupVersion int
LastVersion string // this is the last version the user was using, for the purpose of showing release notes
LastUpdateCheck int64
RecentRepos []string
StartupPopupVersion int
DidShowHunkStagingHint bool
LastVersion string // this is the last version the user was using, for the purpose of showing release notes
// these are for shell commands typed in directly, not for custom commands in the lazygit config.
// For backwards compatibility we keep the old name in yaml files.
-158
View File
@@ -108,22 +108,6 @@ func TestMigrationOfRenamedKeys(t *testing.T) {
"Renamed 'gui.windowSize' to 'screenMode'",
},
},
{
name: "Rename staging view options",
input: `gui:
wrapLinesInStagingView: false
useHunkModeInStagingView: true
`,
expected: `gui:
wrapLinesInDiffView: false
useHunkModeInDiffView: true
`,
expectedDidChange: true,
expectedChanges: []string{
"Renamed 'gui.wrapLinesInStagingView' to 'wrapLinesInDiffView'",
"Renamed 'gui.useHunkModeInStagingView' to 'useHunkModeInDiffView'",
},
},
}
for _, s := range scenarios {
@@ -191,32 +175,6 @@ func TestMigrationOfMovedKeys(t *testing.T) {
expectedDidChange: true,
expectedChanges: []string{"Moved 'keybinding.worktrees.viewWorktreeOptions' to 'keybinding.universal.newWorktree'"},
},
{
name: "Move author and branch colors into the theme",
input: `gui:
authorColors:
John Smith: red
theme:
activeBorderColor:
- green
branchColorPatterns:
^docs/: blue
`,
expected: `gui:
theme:
activeBorderColor:
- green
authorColors:
John Smith: red
branchColorPatterns:
^docs/: blue
`,
expectedDidChange: true,
expectedChanges: []string{
"Moved 'gui.authorColors' to 'gui.theme.authorColors'",
"Moved 'gui.branchColorPatterns' to 'gui.theme.branchColorPatterns'",
},
},
}
for _, s := range scenarios {
@@ -863,119 +821,3 @@ func TestPagerMigration(t *testing.T) {
})
}
}
func TestBranchColorsMigration(t *testing.T) {
moved := "Moved 'gui.branchColorPatterns' to 'gui.theme.branchColorPatterns'"
scenarios := []struct {
name string
input string
expected string
expectedDidChange bool
expectedChanges []string
}{
{
name: "No branchColors",
input: "gui:\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" '^docs/': blue\n",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "branchColors is not an object",
input: "gui:\n" +
" branchColors: 5\n",
expectedDidChange: false,
expectedChanges: []string{},
},
{
name: "branchColors is converted to patterns",
input: "gui:\n" +
" scrollHeight: 2\n" +
" branchColors:\n" +
" feature: green\n" +
" v1.x: '#ff0000'\n" +
" 123: red\n" +
" mouseEvents: false\n",
expected: "gui:\n" +
" scrollHeight: 2\n" +
" mouseEvents: false\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" ^feature(/|$): green\n" +
" ^v1\\.x(/|$): '#ff0000'\n" +
" ^123(/|$): red\n",
expectedDidChange: true,
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
},
{
name: "branchColors is removed if branchColorPatterns is set",
input: "gui:\n" +
" branchColors:\n" +
" feature: green\n" +
" branchColorPatterns:\n" +
" '^docs/': blue\n",
expected: "gui:\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" '^docs/': blue\n",
expectedDidChange: true,
expectedChanges: []string{"Removed 'gui.branchColors'; it had no effect because 'gui.branchColorPatterns' is set", moved},
},
{
name: "branchColors replaces an empty branchColorPatterns",
input: "gui:\n" +
" branchColorPatterns: {}\n" +
" branchColors:\n" +
" feature: green\n",
expected: "gui:\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" ^feature(/|$): green\n",
expectedDidChange: true,
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
},
{
name: "branchColors replaces a null branchColorPatterns",
input: "gui:\n" +
" branchColorPatterns:\n" +
" branchColors:\n" +
" feature: green\n",
expected: "gui:\n" +
" theme:\n" +
" branchColorPatterns:\n" +
" ^feature(/|$): green\n",
expectedDidChange: true,
expectedChanges: []string{"Converted 'gui.branchColors' to 'gui.branchColorPatterns'", moved},
},
{
name: "branchColors is kept if branchColorPatterns is not an object",
input: "gui:\n" +
" branchColorPatterns: 5\n" +
" branchColors:\n" +
" feature: green\n",
expected: "gui:\n" +
" branchColors:\n" +
" feature: green\n" +
" theme:\n" +
" branchColorPatterns: 5\n",
expectedDidChange: true,
expectedChanges: []string{moved},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
changes := NewChangesSet()
actual, didChange, err := computeMigratedConfig("path doesn't matter", []byte(s.input), changes)
assert.NoError(t, err)
assert.Equal(t, s.expectedDidChange, didChange)
if didChange {
assert.Equal(t, s.expected, string(actual))
}
assert.Equal(t, s.expectedChanges, changes.ToSliceFromOldest())
})
}
}
-69
View File
@@ -1,69 +0,0 @@
package config
import (
"slices"
"github.com/karimkhaleel/jsonschema"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
)
// ColorPatterns assigns colors to the names that match regular expressions.
// It's written in YAML as a mapping from pattern to color, and keeps the
// patterns in the order in which they are written, so that the first pattern
// that matches a name can decide its color.
type ColorPatterns []ColorPattern
type ColorPattern struct {
Pattern string
Color string
}
// UnmarshalYAML puts the patterns it reads in front of the ones that are there
// already, which come from config files that were loaded earlier.
func (p *ColorPatterns) UnmarshalYAML(node *yaml.Node) error {
// Decoding into a map reports malformed input the same way as for the
// other maps in the config.
var colors map[string]string
if err := node.Decode(&colors); err != nil {
return err
}
patterns := make(ColorPatterns, 0, len(colors))
for i := 0; i < len(node.Content)-1; i += 2 {
var pattern string
if err := node.Content[i].Decode(&pattern); err != nil {
return err
}
patterns = append(patterns, ColorPattern{Pattern: pattern, Color: colors[pattern]})
}
*p = patterns.over(*p)
return nil
}
func (p ColorPatterns) MarshalYAML() (any, error) {
node := &yaml.Node{Kind: yaml.MappingNode}
for _, pattern := range p {
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Pattern},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: pattern.Color},
)
}
return node, nil
}
// JSONSchema describes the patterns as the mapping they are written as.
func (ColorPatterns) JSONSchema() *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Type: "string"},
}
}
// over returns p followed by the patterns of lower that p doesn't have.
func (p ColorPatterns) over(lower ColorPatterns) ColorPatterns {
return slices.Concat(p, lo.Reject(lower, func(l ColorPattern, _ int) bool {
return slices.ContainsFunc(p, func(c ColorPattern) bool { return c.Pattern == l.Pattern })
}))
}
-64
View File
@@ -1,64 +0,0 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
)
type colorPatternsConfig struct {
Patterns ColorPatterns `yaml:"patterns"`
}
func TestColorPatternsKeepTheirOrder(t *testing.T) {
var config colorPatternsConfig
err := yaml.Unmarshal([]byte("patterns:\n"+
" '^b': red\n"+
" '^a': '#00ff00'\n"+
" '^c': blue\n"), &config)
assert.NoError(t, err)
assert.Equal(t, ColorPatterns{
{Pattern: "^b", Color: "red"},
{Pattern: "^a", Color: "#00ff00"},
{Pattern: "^c", Color: "blue"},
}, config.Patterns)
}
func TestColorPatternsOfALaterFileComeFirst(t *testing.T) {
var config colorPatternsConfig
err := yaml.Unmarshal([]byte("patterns:\n"+
" '^a': red\n"+
" '^b': green\n"), &config)
assert.NoError(t, err)
err = yaml.Unmarshal([]byte("patterns:\n"+
" '^c': blue\n"+
" '^b': yellow\n"), &config)
assert.NoError(t, err)
assert.Equal(t, ColorPatterns{
{Pattern: "^c", Color: "blue"},
{Pattern: "^b", Color: "yellow"},
{Pattern: "^a", Color: "red"},
}, config.Patterns)
}
func TestColorPatternsMustBeAMapping(t *testing.T) {
var config colorPatternsConfig
err := yaml.Unmarshal([]byte("patterns: 5\n"), &config)
assert.ErrorContains(t, err, "cannot unmarshal !!int `5` into map[string]string")
}
func TestColorPatternsSurviveMarshalling(t *testing.T) {
config := colorPatternsConfig{Patterns: ColorPatterns{
{Pattern: "^b", Color: "red"},
{Pattern: "^a", Color: "#00ff00"},
{Pattern: "true", Color: "blue"},
}}
content, err := yaml.Marshal(config)
assert.NoError(t, err)
var roundTripped colorPatternsConfig
err = yaml.Unmarshal(content, &roundTripped)
assert.NoError(t, err)
assert.Equal(t, config, roundTripped)
}
+20 -47
View File
@@ -1,9 +1,8 @@
package config
import (
"fmt"
"strconv"
"strings"
"text/template"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -61,23 +60,18 @@ func (self *DiffRendererConfigManager) GetDiffRendererType() DiffRendererType {
return currentDiffRendererConfig.getType()
}
// DiffRendererValues are what the command of a diff renderer can refer to.
type DiffRendererValues struct {
// The width of the view that the diff is rendered into
Width int
// The number of lines of context around each hunk
DiffContext uint64
// Whether the terminal has a light background
LightBackground bool
}
func (self *DiffRendererConfigManager) GetStdinFilterCommand(values DiffRendererValues) (string, error) {
func (self *DiffRendererConfigManager) GetStdinFilterCommand(width int) string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter {
return "", nil
return ""
}
return currentDiffRendererConfig.resolveCommand(values)
templateValues := map[string]string{
"columnWidth": strconv.Itoa(width/2 - 6),
}
commandTemplate := string(currentDiffRendererConfig.Command)
return utils.ResolvePlaceholderString(commandTemplate, templateValues)
}
func (self *DiffRendererConfigManager) GetColorArg() string {
@@ -93,46 +87,25 @@ func (self *DiffRendererConfigManager) GetColorArg() string {
return colorArg
}
func (self *DiffRendererConfigManager) GetExternalDiffCommand(values DiffRendererValues) (string, error) {
func (self *DiffRendererConfigManager) GetNarrowSelectionHighlight() bool {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
return "", nil
if currentDiffRendererConfig == nil {
return false
}
return currentDiffRendererConfig.resolveCommand(values)
return currentDiffRendererConfig.NarrowSelectionHighlight
}
// resolveCommand resolves the renderer's command, which is a Go template with
// the values it can refer to as its variables. A variable can be written with
// or without the leading dot, as in {{.width}} or {{width}}.
func (self *DiffRendererConfig) resolveCommand(values DiffRendererValues) (string, error) {
colorScheme := "dark"
if values.LightBackground {
colorScheme = "light"
}
variables := map[string]any{
"width": values.Width,
"colorScheme": colorScheme,
}
switch self.getType() {
case DiffRendererType_StdinFilter:
variables["columnWidth"] = values.Width/2 - 6
case DiffRendererType_ExtDiff:
variables["diffContext"] = values.DiffContext
case DiffRendererType_RawGit:
// has no command
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
currentDiffRendererConfig := self.currentDiffRendererConfig()
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
return ""
}
funcs := template.FuncMap{}
for name, value := range variables {
funcs[name] = func() any { return value }
templateValues := map[string]string{
"diffContext": strconv.Itoa(int(diffContext)),
}
command, err := utils.ResolveTemplate(string(self.Command), variables, funcs)
if err != nil {
return "", fmt.Errorf("git.diffRenderers: can't use the command '%s': %w", self.Command, err)
}
return command, nil
return utils.ResolvePlaceholderString(string(currentDiffRendererConfig.Command), templateValues)
}
func (self *DiffRendererConfigManager) GetRawGitArgs() []string {
+14 -152
View File
@@ -63,158 +63,6 @@ func TestCurrentDiffRendererName(t *testing.T) {
}
}
func TestGetStdinFilterCommand(t *testing.T) {
scenarios := []struct {
name string
diffRendererConfig DiffRendererConfig
width int
lightBackground bool
expected string
expectedError string
}{
{
name: "a command without template variables is passed through",
diffRendererConfig: DiffRendererConfig{Command: "delta --paging=never"},
width: 120,
expected: "delta --paging=never",
},
{
name: "the width the diff is rendered at",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{width}}"},
width: 120,
expected: "delta --width=120",
},
{
name: "the width of one side of a side-by-side rendering",
diffRendererConfig: DiffRendererConfig{Command: "ydiff -p cat -w {{columnWidth}}"},
width: 120,
expected: "ydiff -p cat -w 54",
},
{
name: "a template variable can also be written with a leading dot",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{.width}}"},
width: 120,
expected: "delta --width=120",
},
{
name: "the color scheme on a dark background",
diffRendererConfig: DiffRendererConfig{Command: "delta --{{colorScheme}}"},
width: 120,
expected: "delta --dark",
},
{
name: "the color scheme on a light background",
diffRendererConfig: DiffRendererConfig{Command: "delta --{{colorScheme}}"},
width: 120,
lightBackground: true,
expected: "delta --light",
},
{
name: "the command can choose between options by the color scheme",
diffRendererConfig: DiffRendererConfig{Command: `delta --syntax-theme={{if eq .colorScheme "light"}}GitHub{{else}}Dracula{{end}}`},
width: 120,
lightBackground: true,
expected: "delta --syntax-theme=GitHub",
},
{
name: "the command can use template expressions",
diffRendererConfig: DiffRendererConfig{Command: "delta{{if gt .width 100}} --side-by-side{{end}}"},
width: 120,
expected: "delta --side-by-side",
},
{
name: "an unknown template variable is an error",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{.widht}}"},
width: 120,
expectedError: "can't use the command 'delta --width={{.widht}}'",
},
{
name: "an unknown template variable without a leading dot is an error too",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{widht}}"},
width: 120,
expectedError: "can't use the command 'delta --width={{widht}}'",
},
{
name: "nothing is returned for a renderer of another type",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}}"},
width: 120,
expected: "",
},
}
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 })
command, err := config.GetStdinFilterCommand(DiffRendererValues{Width: s.width, LightBackground: s.lightBackground})
if s.expectedError != "" {
assert.ErrorContains(t, err, s.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, s.expected, command)
}
})
}
}
func TestGetExternalDiffCommand(t *testing.T) {
scenarios := []struct {
name string
diffRendererConfig DiffRendererConfig
expected string
expectedError string
}{
{
name: "a command without template variables is passed through",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
expected: "difft --color=always",
},
{
name: "the width the diff is rendered at",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}}"},
expected: "difft --width=120",
},
{
name: "the color scheme",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --background={{colorScheme}}"},
expected: "difft --background=dark",
},
{
name: "the width alongside the diff context size",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{width}} --context={{diffContext}}"},
expected: "difft --width=120 --context=3",
},
{
name: "a variable of stdin filters is an error",
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"},
expectedError: "can't use the command 'difft --width={{columnWidth}}'",
},
{
name: "nothing is returned for a renderer of another type",
diffRendererConfig: DiffRendererConfig{Command: "delta --width={{width}}"},
expected: "",
},
}
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 })
command, err := config.GetExternalDiffCommand(DiffRendererValues{Width: 120, DiffContext: 3})
if s.expectedError != "" {
assert.ErrorContains(t, err, s.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, s.expected, command)
}
})
}
}
func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
@@ -222,6 +70,20 @@ func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
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"}}
+2 -2
View File
@@ -50,13 +50,13 @@ type editPreset struct {
suspend func() bool
}
func returnBool(a bool) func() bool { return func() bool { return a } }
func returnBool(a bool) func() bool { return (func() bool { return a }) }
// IF YOU ADD A PRESET TO THIS FUNCTION YOU MUST UPDATE THE `Supported presets` SECTION OF docs/Config.md
func getPreset(shell string, osConfig *OSConfig, guessDefaultEditor func() string) *editPreset {
var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string
// By default fish doesn't have SHELL variable set, but it does have FISH_VERSION since Nov 2012.
if strings.HasSuffix(shell, "fish") || (os.Getenv("FISH_VERSION") != "") {
if (strings.HasSuffix(shell, "fish")) || (os.Getenv("FISH_VERSION") != "") {
nvimRemoteEditTemplate = `begin; if test -z "$NVIM"; nvim -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; end; end`
nvimRemoteEditAtLineTemplate = `begin; if test -z "$NVIM"; nvim +{{line}} -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; nvim --server "$NVIM" --remote-send ":{{line}}<CR>"; end; end`
nvimRemoteOpenDirInEditorTemplate = `begin; if test -z "$NVIM"; nvim -- {{dir}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{dir}}; end; end`
-105
View File
@@ -1,105 +0,0 @@
package config
import (
"fmt"
"maps"
"math"
"reflect"
"slices"
"strconv"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
// ThemeForBackground returns gui.theme with the overrides for a dark or a light
// background applied, and with the defaults for that background in the fields
// that neither of them sets. backgroundColor is the terminal's background color
// as #rrggbb, or empty if we don't know it.
func (c *GuiConfig) ThemeForBackground(lightBackground bool, backgroundColor string) ThemeConfig {
override := lo.Ternary(lightBackground, c.LightTheme, c.DarkTheme)
return mergeThemes(override, c.Theme, themeDefaults(lightBackground, backgroundColor))
}
// themeDefaults returns the defaults of the theme fields whose default depends
// on the terminal's background. GetDefaultConfig leaves these fields empty in
// gui.theme, so that a value there comes from the user, and wins over these.
//
// Most of them are derived from the background color, so that they keep the
// same distance from it however dark or light it is. If we don't know the
// background color, we assume black or white.
func themeDefaults(lightBackground bool, backgroundColor string) ThemeConfig {
if len(backgroundColor) != 7 || !utils.IsValidHexValue(backgroundColor) {
backgroundColor = lo.Ternary(lightBackground, "#ffffff", "#000000")
}
if lightBackground {
return ThemeConfig{
// The colors of a light palette are dark enough to read as text on
// the background, so none of them works as a background for text.
// Some blue mixed into the background keeps the colored text on the
// selected line as readable as elsewhere.
SelectedLineBgColor: []string{mixHexColors(backgroundColor, "#0064ff", 0.25)},
InactiveViewSelectedLineBgColor: []string{mixHexColors(backgroundColor, "#000000", 0.15)},
}
}
return ThemeConfig{
// Dark palettes make their blue dark enough to work as a background for
// text. Unlike a color derived from a dark background, it doesn't turn
// into black on terminals with only 8 colors.
SelectedLineBgColor: []string{"blue"},
InactiveViewSelectedLineBgColor: []string{mixHexColors(backgroundColor, "#ffffff", 0.3)},
}
}
// mixHexColors mixes two colors given as #rrggbb. amount is how much of b to
// take: 0 gives a, 1 gives b.
func mixHexColors(a, b string, amount float64) string {
channel := func(color string, i int) float64 {
value, _ := strconv.ParseUint(color[1+2*i:3+2*i], 16, 8)
return float64(value)
}
result := "#"
for i := range 3 {
mixed := channel(a, i) + (channel(b, i)-channel(a, i))*amount
result += fmt.Sprintf("%02x", int(math.Round(mixed)))
}
return result
}
// mergeThemes takes each field from the first of the themes that sets it. For
// maps and color patterns it merges the entries instead, and an entry of an
// earlier theme wins over one with the same key in a later theme.
func mergeThemes(themes ...ThemeConfig) ThemeConfig {
var result ThemeConfig
resultValue := reflect.ValueOf(&result).Elem()
for i := range resultValue.NumField() {
values := lo.Map(themes, func(theme ThemeConfig, _ int) any {
return reflect.ValueOf(theme).Field(i).Interface()
})
resultValue.Field(i).Set(reflect.ValueOf(mergeThemeField(values)))
}
return result
}
func mergeThemeField(values []any) any {
switch values[0].(type) {
case []string:
return lo.FindOrElse(lo.Map(values, func(v any, _ int) []string { return v.([]string) }), nil,
func(v []string) bool { return len(v) > 0 })
case map[string]string:
merged := map[string]string{}
for _, v := range slices.Backward(values) {
maps.Copy(merged, v.(map[string]string))
}
return merged
case ColorPatterns:
return lo.Reduce(values, func(merged ColorPatterns, v any, _ int) ColorPatterns {
return merged.over(v.(ColorPatterns))
}, nil)
default:
panic(fmt.Sprintf("don't know how to merge a theme field of type %T", values[0]))
}
}
-127
View File
@@ -1,127 +0,0 @@
package config
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func TestThemeForBackground(t *testing.T) {
gui := GuiConfig{
Theme: ThemeConfig{
ActiveBorderColor: []string{"green"},
InactiveBorderColor: []string{"default"},
AuthorColors: map[string]string{"Alice": "red", "Bob": "blue"},
BranchColorPatterns: ColorPatterns{
{Pattern: "^docs/", Color: "cyan"},
{Pattern: "^feature/", Color: "green"},
},
},
DarkTheme: ThemeConfig{
ActiveBorderColor: []string{"yellow"},
},
LightTheme: ThemeConfig{
InactiveBorderColor: []string{"#777777"},
AuthorColors: map[string]string{"Bob": "#000080"},
BranchColorPatterns: ColorPatterns{
{Pattern: "ISSUE", Color: "red"},
{Pattern: "^docs/", Color: "#008080"},
},
},
}
dark := gui.ThemeForBackground(false, "")
assert.Equal(t, []string{"yellow"}, dark.ActiveBorderColor)
assert.Equal(t, []string{"default"}, dark.InactiveBorderColor)
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "blue"}, dark.AuthorColors)
assert.Equal(t, gui.Theme.BranchColorPatterns, dark.BranchColorPatterns)
light := gui.ThemeForBackground(true, "")
assert.Equal(t, []string{"green"}, light.ActiveBorderColor)
assert.Equal(t, []string{"#777777"}, light.InactiveBorderColor)
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "#000080"}, light.AuthorColors)
assert.Equal(t, ColorPatterns{
{Pattern: "ISSUE", Color: "red"},
{Pattern: "^docs/", Color: "#008080"},
{Pattern: "^feature/", Color: "green"},
}, light.BranchColorPatterns)
assert.Equal(t, map[string]string{"Alice": "red", "Bob": "blue"}, gui.Theme.AuthorColors,
"merging must leave the themes it merges alone")
}
func TestEveryThemeFieldCanBeOverridden(t *testing.T) {
var override ThemeConfig
overrideValue := reflect.ValueOf(&override).Elem()
for i := range overrideValue.NumField() {
field := overrideValue.Field(i)
switch field.Interface().(type) {
case []string:
field.Set(reflect.ValueOf([]string{"#123456"}))
case map[string]string:
field.Set(reflect.ValueOf(map[string]string{"key": "#123456"}))
case ColorPatterns:
field.Set(reflect.ValueOf(ColorPatterns{{Pattern: "key", Color: "#123456"}}))
default:
t.Fatalf("no test value for theme field %s", overrideValue.Type().Field(i).Name)
}
}
gui := GetDefaultConfig().Gui
gui.DarkTheme = override
assert.Equal(t, override, gui.ThemeForBackground(false, ""))
}
func TestThemeForBackgroundFallsBackToTheDefaultsForTheBackground(t *testing.T) {
gui := GetDefaultConfig().Gui
assert.Equal(t, []string{"#4d4d4d"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"#d9d9d9"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
gui.Theme.InactiveViewSelectedLineBgColor = []string{"bold"}
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
gui.LightTheme.InactiveViewSelectedLineBgColor = []string{"white"}
assert.Equal(t, []string{"bold"}, gui.ThemeForBackground(false, "").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"white"}, gui.ThemeForBackground(true, "").InactiveViewSelectedLineBgColor)
}
func TestBackgroundDefaultsAreDerivedFromTheBackgroundColor(t *testing.T) {
gui := GetDefaultConfig().Gui
assert.Equal(t, []string{"#626262"}, gui.ThemeForBackground(false, "#1e1e1e").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"#d7d1c1"}, gui.ThemeForBackground(true, "#fdf6e3").InactiveViewSelectedLineBgColor)
assert.Equal(t, []string{"blue"}, gui.ThemeForBackground(false, "#1e1e1e").SelectedLineBgColor)
assert.Equal(t, []string{"#bed2ea"}, gui.ThemeForBackground(true, "#fdf6e3").SelectedLineBgColor)
assert.Equal(t, []string{"#bfd8ff"}, gui.ThemeForBackground(true, "").SelectedLineBgColor)
}
// If gui.theme had a default for a field that also has a default for the
// terminal's background, the former would always win.
func TestFieldsWithBackgroundDefaultsHaveNoDefaultInGuiTheme(t *testing.T) {
genericDefaults := setThemeFields(GetDefaultConfig().Gui.Theme)
for _, field := range setThemeFields(themeDefaults(false, "")) {
assert.NotContains(t, genericDefaults, field)
}
for _, field := range setThemeFields(themeDefaults(true, "")) {
assert.NotContains(t, genericDefaults, field)
}
}
// If only one of the backgrounds had a default for a field, the field would
// have no value at all with the other.
func TestDarkAndLightDefaultsSetTheSameFields(t *testing.T) {
assert.Equal(t, setThemeFields(themeDefaults(false, "")), setThemeFields(themeDefaults(true, "")))
}
func setThemeFields(theme ThemeConfig) []string {
var fields []string
themeValue := reflect.ValueOf(theme)
for i := range themeValue.NumField() {
if themeValue.Field(i).Len() > 0 {
fields = append(fields, themeValue.Type().Field(i).Name)
}
}
return fields
}
+35 -49
View File
@@ -68,6 +68,13 @@ func (c *RefresherConfig) ExternalChangeCheckIntervalDuration() time.Duration {
}
type GuiConfig struct {
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
AuthorColors map[string]string `yaml:"authorColors"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
// Deprecated: use branchColorPatterns instead
BranchColors map[string]string `yaml:"branchColors" jsonschema:"deprecated"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
BranchColorPatterns map[string]string `yaml:"branchColorPatterns"`
// Custom icons for filenames and file extensions
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color
CustomIcons CustomIconsConfig `yaml:"customIcons"`
@@ -87,7 +94,7 @@ type GuiConfig struct {
MouseEvents bool `yaml:"mouseEvents"`
// If true, do not show a warning when amending a commit.
SkipAmendWarning bool `yaml:"skipAmendWarning"`
// If true, do not show a warning when discarding changes from a focused diff.
// If true, do not show a warning when discarding changes in the staging view.
SkipDiscardChangeWarning bool `yaml:"skipDiscardChangeWarning"`
// If true, do not show warning when applying/popping the stash
SkipStashWarning bool `yaml:"skipStashWarning"`
@@ -122,10 +129,10 @@ type GuiConfig struct {
// - '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 string `yaml:"enlargedSideViewLocation"`
// If true, wrap lines in focused diffs 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.
WrapLinesInDiffView bool `yaml:"wrapLinesInDiffView"`
// If true, hunk selection mode will be enabled by default when focusing a diff.
UseHunkModeInDiffView bool `yaml:"useHunkModeInDiffView"`
// 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 bool `yaml:"wrapLinesInStagingView"`
// If true, hunk selection mode will be enabled by default when entering the staging view.
UseHunkModeInStagingView bool `yaml:"useHunkModeInStagingView"`
// One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' | 'pt'
Language string `yaml:"language" jsonschema:"enum=auto,enum=en,enum=zh-TW,enum=zh-CN,enum=pl,enum=nl,enum=ja,enum=ko,enum=ru"`
// Format used when displaying time e.g. commit time.
@@ -134,19 +141,9 @@ type GuiConfig struct {
// 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 string `yaml:"shortTimeFormat"`
// Whether the terminal has a dark or a light background. This decides whether 'darkTheme' or 'lightTheme' applies, and the colors of authors are picked to stand out against it.
// One of: 'auto' (default) | 'dark' | 'light'
// With 'auto', lazygit asks the terminal, and assumes a dark background if the terminal doesn't tell.
ColorScheme string `yaml:"colorScheme" jsonschema:"enum=auto,enum=dark,enum=light"`
// Config relating to colors and styles.
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
Theme ThemeConfig `yaml:"theme"`
// Colors and styles that override those in 'theme' when the terminal has a dark background. It has the same fields as 'theme'.
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
DarkTheme ThemeConfig `yaml:"darkTheme"`
// Colors and styles that override those in 'theme' when the terminal has a light background. It has the same fields as 'theme'.
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#themes-for-dark-and-light-backgrounds
LightTheme ThemeConfig `yaml:"lightTheme"`
// Config relating to the commit length indicator
CommitLength CommitLengthConfig `yaml:"commitLength"`
// If true, show the '5 of 20' footer at the bottom of list views
@@ -179,11 +176,6 @@ type GuiConfig struct {
NerdFontsVersion string `yaml:"nerdFontsVersion" jsonschema:"enum=2,enum=3,enum="`
// If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.
ShowFileIcons bool `yaml:"showFileIcons"`
// How the commit graph is drawn.
// One of: 'auto' (default) | 'classic' | 'detailed'
// 'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.
// 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.
CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=auto,enum=classic,enum=detailed"`
// Length of author name in (non-expanded) commits view. 2 means show initials only.
CommitAuthorShortLength int `yaml:"commitAuthorShortLength"`
// Length of author name in expanded commits view. 2 means show initials only.
@@ -245,15 +237,10 @@ type ThemeConfig struct {
SearchingActiveBorderColor []string `yaml:"searchingActiveBorderColor" jsonschema:"minItems=1,uniqueItems=true"`
// Color of keybindings help text in the bottom line
OptionsTextColor []string `yaml:"optionsTextColor" jsonschema:"minItems=1,uniqueItems=true"`
// Color and attributes of the text of the selected line. The attributes are added to those of the text, and a color replaces the colors of the text.
// Set it to 'default' to leave the text as it is, e.g. if you don't want the selected line in bold.
SelectedLineFgColor []string `yaml:"selectedLineFgColor" jsonschema:"minItems=1,uniqueItems=true"`
// Background color of selected line.
// Default: 'blue' if the terminal has a dark background, or a suitable RGB blue computed from the background color if it is light.
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
SelectedLineBgColor []string `yaml:"selectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"`
// Background color of selected line when view doesn't have focus.
// Default: a suitable RGB grey computed from the terminal's background color.
InactiveViewSelectedLineBgColor []string `yaml:"inactiveViewSelectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"`
// Foreground color of copied commit
CherryPickedCommitFgColor []string `yaml:"cherryPickedCommitFgColor" jsonschema:"minItems=1,uniqueItems=true"`
@@ -267,10 +254,6 @@ type ThemeConfig struct {
UnstagedChangesColor []string `yaml:"unstagedChangesColor" jsonschema:"minItems=1,uniqueItems=true"`
// Default text color
DefaultFgColor []string `yaml:"defaultFgColor" jsonschema:"minItems=1,uniqueItems=true"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color
AuthorColors map[string]string `yaml:"authorColors"`
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-branch-color
BranchColorPatterns ColorPatterns `yaml:"branchColorPatterns"`
}
type CommitLengthConfig struct {
@@ -298,6 +281,12 @@ type GitConfig struct {
// # renderer command.
// name: ""
//
// # 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'.
@@ -333,7 +322,7 @@ type GitConfig struct {
AutoRefresh bool `yaml:"autoRefresh"`
// 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 bool `yaml:"autoDetectExternalChanges"`
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). A branch that is checked out in another worktree is fast-forwarded there, unless that worktree has changes to tracked files or is in the middle of a rebase or bisect.
// If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).
// Possible values: 'none' | 'onlyMainBranches' | 'allBranches'
AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"`
// If true, pass the --all arg to git fetch
@@ -394,6 +383,8 @@ type DiffRendererConfig struct {
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"`
// 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'.
@@ -677,8 +668,6 @@ type KeybindingCommitFilesConfig struct {
type KeybindingMainConfig struct {
PrevHunk Keybinding `yaml:"prevHunk"`
NextHunk Keybinding `yaml:"nextHunk"`
PrevFile Keybinding `yaml:"prevFile"`
NextFile Keybinding `yaml:"nextFile"`
ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"`
PickBothHunks Keybinding `yaml:"pickBothHunks"`
EditSelectHunk Keybinding `yaml:"editSelectHunk"`
@@ -895,24 +884,24 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
},
MainPanelSplitMode: "flexible",
EnlargedSideViewLocation: "left",
WrapLinesInDiffView: true,
UseHunkModeInDiffView: true,
WrapLinesInStagingView: true,
UseHunkModeInStagingView: true,
Language: "auto",
TimeFormat: "02 Jan 06",
ShortTimeFormat: time.Kitchen,
ColorScheme: "auto",
Theme: ThemeConfig{
ActiveBorderColor: []string{"green", "bold"},
SearchingActiveBorderColor: []string{"cyan", "bold"},
InactiveBorderColor: []string{"default"},
OptionsTextColor: []string{"blue"},
SelectedLineFgColor: []string{"bold"},
CherryPickedCommitBgColor: []string{"cyan"},
CherryPickedCommitFgColor: []string{"blue"},
MarkedBaseCommitBgColor: []string{"yellow"},
MarkedBaseCommitFgColor: []string{"blue"},
UnstagedChangesColor: []string{"red"},
DefaultFgColor: []string{"default"},
ActiveBorderColor: []string{"green", "bold"},
SearchingActiveBorderColor: []string{"cyan", "bold"},
InactiveBorderColor: []string{"default"},
OptionsTextColor: []string{"blue"},
SelectedLineBgColor: []string{"blue"},
InactiveViewSelectedLineBgColor: []string{"bold"},
CherryPickedCommitBgColor: []string{"cyan"},
CherryPickedCommitFgColor: []string{"blue"},
MarkedBaseCommitBgColor: []string{"yellow"},
MarkedBaseCommitFgColor: []string{"blue"},
UnstagedChangesColor: []string{"red"},
DefaultFgColor: []string{"default"},
},
CommitLength: CommitLengthConfig{Show: true},
SkipNoStagedFilesWarning: false,
@@ -929,7 +918,6 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
ShowIcons: false,
NerdFontsVersion: "",
ShowFileIcons: true,
CommitGraphStyle: "auto",
CommitAuthorShortLength: 2,
CommitAuthorLongLength: 17,
CommitHashLength: 8,
@@ -1190,8 +1178,6 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
Main: KeybindingMainConfig{
PrevHunk: Keybinding{"<left>", "h"},
NextHunk: Keybinding{"<right>", "l"},
PrevFile: Keybinding{"N"},
NextFile: Keybinding{"n"},
ToggleSelectHunk: Keybinding{"a"},
PickBothHunks: Keybinding{"b"},
EditSelectHunk: Keybinding{"E"},
-72
View File
@@ -18,10 +18,6 @@ func (config *UserConfig) Validate() error {
[]string{"dashboard", "allBranchesLog"}); err != nil {
return err
}
if err := validateEnum("gui.colorScheme", config.Gui.ColorScheme,
[]string{"auto", "dark", "light"}); err != nil {
return err
}
if err := validateEnum("gui.showDivergenceFromBaseBranch", config.Gui.ShowDivergenceFromBaseBranch,
[]string{"none", "onlyArrow", "arrowAndNumber"}); err != nil {
return err
@@ -30,10 +26,6 @@ func (config *UserConfig) Validate() error {
[]string{"mixed", "filesFirst", "foldersFirst"}); err != nil {
return err
}
if err := validateEnum("gui.commitGraphStyle", config.Gui.CommitGraphStyle,
[]string{"auto", "classic", "detailed"}); err != nil {
return err
}
if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches,
[]string{"none", "onlyMainBranches", "allBranches"}); err != nil {
return err
@@ -128,16 +120,10 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
if len(diffRenderer.Args) > 0 {
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'stdinFilter'.")
}
if err := validateDiffRendererCommand(diffRenderer); err != nil {
return err
}
case "extDiff":
if len(diffRenderer.Args) > 0 {
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'extDiff'.")
}
if err := validateDiffRendererCommand(diffRenderer); err != nil {
return err
}
case "rawGit":
if diffRenderer.Command != "" {
return errors.New("git.diffRenderers: 'command' cannot be used with diff renderer type 'rawGit'.")
@@ -149,14 +135,6 @@ func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
return nil
}
// validateDiffRendererCommand resolves the command with made-up values, so that
// a mistake in it shows up when the config is loaded rather than when a diff
// is rendered.
func validateDiffRendererCommand(diffRenderer DiffRendererConfig) error {
_, err := diffRenderer.resolveCommand(DiffRendererValues{Width: 80, DiffContext: 3})
return err
}
func validateEnum(name string, value string, allowedValues []string) error {
if slices.Contains(allowedValues, value) {
return nil
@@ -213,47 +191,6 @@ func validateCustomCommandKey(key Keybinding) error {
return nil
}
// ValidCustomCommandContexts lists the names a custom command's 'context' may
// use. It mirrors context.AllContextKeys in the gui package, which this package
// can't import; a test over there keeps the two in sync.
var ValidCustomCommandContexts = []string{
"global",
"status",
"files",
"localBranches",
"remotes",
"worktrees",
"remoteBranches",
"tags",
"commits",
"reflogCommits",
"subCommits",
"commitFiles",
"stash",
"normal",
"normalSecondary",
"mergeConflicts",
"menu",
"confirmation",
"prompt",
"search",
"commitMessage",
"submodules",
"suggestions",
"cmdLog",
}
func validateCustomCommandContext(context string) error {
for _, name := range strings.Split(context, ",") {
name = strings.TrimSpace(name)
if !slices.Contains(ValidCustomCommandContexts, name) {
return fmt.Errorf("Unknown context '%s' for custom command. Allowed values: %s",
name, strings.Join(ValidCustomCommandContexts, ", "))
}
}
return nil
}
func validateCustomCommands(customCommands []CustomCommand) error {
for _, customCommand := range customCommands {
if err := validateCustomCommandKey(customCommand.Key); err != nil {
@@ -279,15 +216,6 @@ func validateCustomCommands(customCommands []CustomCommand) error {
return err
}
} else {
// A command in a menu may leave the context out, in which case it is
// offered whatever is focused; a top-level one may not, but that is
// only noticed when the keybindings are built.
if customCommand.Context != "" {
if err := validateCustomCommandContext(customCommand.Context); err != nil {
return err
}
}
for _, prompt := range customCommand.Prompts {
if err := validateCustomCommandPrompt(prompt); err != nil {
return err
-46
View File
@@ -225,47 +225,6 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command context",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Context: value,
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "global", valid: true},
{value: "commits", valid: true},
{value: "commits, subCommits", valid: true},
{value: "commits,subCommits", valid: true},
{value: "invalid_value", valid: false},
{value: "commits, invalid_value", valid: false},
// The staging and patch-building panels are gone, so a config that
// still names their contexts is reported rather than fatal.
{value: "staging", valid: false},
{value: "patchBuilding", valid: false},
},
},
{
name: "Custom command context in a sub menu",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Key: Keybinding{"X"},
CommandMenu: []CustomCommand{
{Key: Keybinding{"1"}, Command: "echo 'hello'", Context: value},
},
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "commits", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) {
@@ -413,14 +372,9 @@ func TestUserConfigValidate_diffRenderers(t *testing.T) {
{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: "stdinFilter with a template", diffRenderer: DiffRendererConfig{Command: "delta --width={{width}}{{if gt .width 100}} --side-by-side{{end}}"}, valid: true},
{name: "stdinFilter with an unknown template variable", diffRenderer: DiffRendererConfig{Command: "delta --width={{.widht}}"}, valid: false},
{name: "stdinFilter with a broken template", diffRenderer: DiffRendererConfig{Command: "delta {{if .width}}"}, 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: "external diff with a template", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --context={{diffContext}}"}, valid: true},
{name: "external diff with a variable of stdin filters", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft --width={{columnWidth}}"}, 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},

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