Skip to main content

strop_engine/editor/
git.rs

1//! Git working surface (M2, reworked 0010/R6/R9): hunks between HEAD
2//! and the live buffer, refreshed through owned worker requests; hunk
3//! nav and the hunk verbs. No native git work runs on the input or
4//! render path — discovery, diffs and index mutations are worker jobs
5//! with terminal, ticket-owned results.
6mod context;
7
8use std::sync::Arc;
9use strop_core::worker::{CancelReason, Load, Outcome};
10use strop_git::Hunk;
11
12use crate::files::FileTarget;
13
14use super::git_memory::{
15    git_failure, repo_or_unavailable, GitJob, GitMutation, HunkData, HunkKey, HunkSet, MutationKey,
16    MutationKind, MutationOp,
17};
18use super::transact::ChangeSet;
19use super::Editor;
20
21/// What a hunk verb (`Space g u`/`g s`) targets from the current view.
22enum HunkTarget {
23    /// Not on a hunk surface: act on the cursor's own buffer.
24    NotASurface,
25    /// A hunk preview whose origin buffer still matches the revision
26    /// it was captured at.
27    Fresh {
28        buffer: strop_core::id::DocumentId,
29        hunk: Arc<Hunk>,
30        /// The origin buffer was untracked when captured: undo refuses.
31        untracked: bool,
32    },
33    /// The origin buffer changed since the preview opened — applying
34    /// the stored region would cut the wrong lines.
35    Stale,
36}
37
38impl Editor {
39    /// Register the next gutter diff. Render-safe (R6): pure checks and
40    /// registration only — the diff itself runs on a worker against an
41    /// immutable text snapshot.
42    pub fn refresh_hunks(&mut self) {
43        if self.docs.is_empty() || self.finishing {
44            return;
45        }
46        let Some(context) = self.git.clone() else {
47            return; // discovery pending (or honestly not a repo)
48        };
49        let doc = self.current();
50        let Some(document) = self.docs.get(doc) else {
51            return;
52        };
53        let revision = document.buf.revision();
54        // the buffer's file identity: a local path or a remote file —
55        // a remote buffer never contributes a local path spelling
56        let file = match &document.source {
57            super::document::DocumentSource::Remote(remote) => {
58                FileTarget::Remote(remote.file.clone().into())
59            }
60            super::document::DocumentSource::File => match document.buf.path.as_deref() {
61                Some(path) => FileTarget::Local(std::path::PathBuf::from(path)),
62                // a scratch buffer has no git identity: no owner, no vectors
63                None => {
64                    self.clear_hunk_view();
65                    return;
66                }
67            },
68            // surfaces and output buffers have no git identity either
69            _ => {
70                self.clear_hunk_view();
71                return;
72            }
73        };
74        // the context must be the buffer's own repository: a remote
75        // file under any other target (local repo, or a different
76        // endpoint) has no gutter here — clear honestly, never diff
77        // against the wrong machine
78        let remote_file = match (&file, &context.repo) {
79            (FileTarget::Remote(remote), strop_git::RepoTarget::Remote { endpoint, .. })
80                if endpoint == remote.endpoint() =>
81            {
82                remote.absolute_file()
83            }
84            (FileTarget::Local(_), strop_git::RepoTarget::Local { .. }) => None,
85            _ => {
86                self.clear_hunk_view();
87                return;
88            }
89        };
90        if remote_file.is_some() && !self.remote_window_complete() {
91            // a partial window's line numbers are window-relative: no
92            // full-file hunk coordinates exist to show (0036)
93            self.clear_hunk_view();
94            return;
95        }
96        let key = HunkKey {
97            document: doc,
98            revision,
99            file: file.clone(),
100            repo: context.repo.clone(),
101            git_view: self.git_view,
102        };
103        if self.hunk_load.covers(&key) {
104            return; // running for this key, or a settled snapshot for it
105        }
106        let snapshot = document.buf.snapshot();
107        // a different key supersedes the old owner synchronously; its
108        // late result is rejected by ticket
109        self.cancel_hunk_owner();
110        let Some(ticket) = self.git_ticket(key) else {
111            return;
112        };
113        self.hunk_load = Load::Running(ticket.clone());
114        // stale signs paint WRONG lines after an edit — clear honestly
115        // for the frames the diff takes, never lie
116        self.hunks = HunkSet::default();
117        self.staged_hunks = HunkSet::default();
118        self.hunks_untracked = false;
119        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
120            serde_json::json!({
121                "service":"git","request":"hunks",
122                "target":if context.repo.is_remote() { "remote" } else { "local" },
123                "document":{"slot":doc.index(),"generation":doc.generation()},
124                "revision":revision.get(),"file":file.to_string(),
125            })
126        });
127        let args = ticket.clone();
128        if let Some(remote) = remote_file {
129            // the remote gutter: bounded HEAD/index blob fetches plus
130            // the shared hunk semantics — never a local repository
131            let endpoint = remote.endpoint().clone();
132            let workdir = context.repo.workdir().to_path_buf();
133            let head_sha = context.head_sha.clone();
134            let rel = context.repo.rel_of(remote.path());
135            self.launch_git_job(
136                "git-hunks-remote",
137                "git.hunks",
138                ticket,
139                &args,
140                GitJob::Hunks,
141                move |cancel| {
142                    if cancel.is_cancelled() {
143                        return Outcome::Cancelled(CancelReason::Superseded);
144                    }
145                    let Some(rel) = rel else {
146                        return Outcome::failed(
147                            strop_core::worker::FailureKind::Exit,
148                            "buffer is outside the remote repository",
149                        );
150                    };
151                    let text = snapshot.to_string();
152                    match strop_git::remote::gutter(
153                        &endpoint,
154                        &workdir,
155                        head_sha.as_deref(),
156                        &rel,
157                        &text,
158                        &cancel,
159                    ) {
160                        Ok((unstaged, staged, untracked)) => Outcome::Success(HunkData {
161                            unstaged: HunkSet::new(unstaged, snapshot.len_lines()),
162                            staged: HunkSet::new(staged, snapshot.len_lines()),
163                            untracked,
164                        }),
165                        Err(error) => Outcome::Failed {
166                            failure: strop_core::worker::Failure::new(
167                                strop_core::worker::FailureKind::Exit,
168                                error.to_string(),
169                            ),
170                            partial: None,
171                        },
172                    }
173                },
174            );
175            return;
176        }
177        let workdir = context.repo.workdir().to_path_buf();
178        let path = match &file {
179            FileTarget::Local(path) => path.clone(),
180            FileTarget::Remote(_) => unreachable!("remote handled above"),
181            // Provably unreachable: container documents clear the hunk
182            // view at the source match above. If the pairing ever
183            // changes, refuse rather than diff against a local path.
184            FileTarget::Container { .. } => {
185                self.clear_hunk_view();
186                return;
187            }
188        };
189        self.launch_git_job(
190            "git-hunks",
191            "git.hunks",
192            ticket,
193            &args,
194            GitJob::Hunks,
195            move |cancel| {
196                if cancel.is_cancelled() {
197                    return Outcome::Cancelled(CancelReason::Superseded);
198                }
199                let text = snapshot.to_string();
200                let repo = match repo_or_unavailable(&workdir) {
201                    Ok(repo) => repo,
202                    Err(failure) => {
203                        return Outcome::Failed {
204                            failure,
205                            partial: None,
206                        }
207                    }
208                };
209                let unstaged = match repo.unstaged_hunks(&path, &text) {
210                    Ok(hunks) => hunks,
211                    Err(error) => {
212                        return Outcome::Failed {
213                            failure: git_failure("diff index↔buffer", error),
214                            partial: None,
215                        }
216                    }
217                };
218                let staged = match repo.staged_hunks(&path) {
219                    Ok(hunks) => hunks,
220                    Err(error) => {
221                        return Outcome::Failed {
222                            failure: git_failure("diff HEAD↔index", error),
223                            partial: None,
224                        }
225                    }
226                };
227                let untracked = match repo.is_untracked(&path) {
228                    Ok(untracked) => untracked,
229                    Err(error) => {
230                        return Outcome::Failed {
231                            failure: git_failure("index lookup", error),
232                            partial: None,
233                        }
234                    }
235                };
236                Outcome::Success(HunkData {
237                    unstaged: HunkSet::new(unstaged, snapshot.len_lines()),
238                    staged: HunkSet::new(staged, snapshot.len_lines()),
239                    untracked,
240                })
241            },
242        );
243    }
244
245    /// Revoke the hunk owner and clear both cached vectors — the
246    /// honest state whenever the current buffer cannot take part in a
247    /// gutter diff (scratch, surface, mismatched repository or partial
248    /// remote window).
249    fn clear_hunk_view(&mut self) {
250        self.cancel_hunk_owner();
251        self.hunks = HunkSet::default();
252        self.staged_hunks = HunkSet::default();
253        self.hunks_untracked = false;
254    }
255
256    /// Revoke the running hunk owner (if any) and return to Idle. The
257    /// worker's late result — success, failure or the synthetic
258    /// `Cancelled` — is rejected: it no longer owns the view.
259    fn cancel_hunk_owner(&mut self) {
260        let running = match &self.hunk_load {
261            Load::Running(ticket) => Some(ticket.request),
262            _ => None,
263        };
264        if let Some(request) = running {
265            self.cancel_git_worker(request, CancelReason::Superseded);
266        }
267        self.hunk_load = Load::Idle;
268    }
269
270    /// Gutter sign for a 1-based buffer line: `+` add, `~` change,
271    /// `-` deletion below (0001 pillar 3.1).
272    pub fn sign_at(&self, line_1based: usize) -> Option<char> {
273        self.hunks.sign(line_1based)
274    }
275
276    /// Staged sign (HEAD↔index edge, 0014 wave 4): the line sits inside
277    /// a staged hunk's new side. Line alignment between index and live
278    /// text is approximate when both sets exist — the gutter's rule:
279    /// unstaged wins, staged marks what's already in the index.
280    pub fn sign_at_staged(&self, line_1based: usize) -> bool {
281        self.staged_hunks.addition(line_1based)
282    }
283
284    /// `]c` / `[c`: jump to the next/previous changed line. An explicit
285    /// command retries a previously failed diff; render never does.
286    pub(crate) fn jump_hunk(&mut self, forward: bool) {
287        // a partial remote window has no full-file coordinates to jump
288        // by — the honest refusal, not "no more hunks"
289        if self.remote_file().is_some() && !self.remote_window_complete() {
290            self.message = "partial remote snapshot — hunks need a full window".into();
291            return;
292        }
293        self.hunk_load.retry_failed();
294        self.refresh_hunks();
295        let cur = self.buf().line_of(self.head()) + 1;
296        let target = self.hunks.next_line(cur, forward);
297        match target {
298            Some(l) => {
299                self.set_head(self.buf().line_start(l - 1));
300                self.clamp_cursor();
301            }
302            None => self.message = "no more hunks".into(),
303        }
304    }
305
306    /// The hunk under the cursor, if any.
307    fn hunk_under_cursor(&mut self) -> Option<Arc<Hunk>> {
308        self.hunk_load.retry_failed();
309        self.refresh_hunks();
310        let line = self.buf().line_of(self.head()) + 1;
311        self.hunks
312            .at_line(line)
313            .map(|index| self.hunks[index].clone())
314    }
315
316    /// Apply `hunk`'s reverse to buffer `idx`: pure deletions reinsert,
317    /// pure additions drop, changes swap old content back — one
318    /// pre-edit Replacement through the gateway. `untracked` names an
319    /// origin with no HEAD content to restore from.
320    fn restore_hunk_in(
321        &mut self,
322        idx: strop_core::id::DocumentId,
323        hunk: &Hunk,
324        untracked: bool,
325    ) -> bool {
326        if self.doc(idx).buf.path.is_none() || untracked {
327            return false; // nothing in HEAD to restore from
328        }
329        let new_first = hunk.changed_region().0;
330        // byte-precise restore text (0020 §7): the hunk's own old-side
331        // lines carry CRLF and missing-final-newline exactly — the
332        // str::lines + LF join it replaces could not
333        let old: String = {
334            let mut bytes = Vec::new();
335            for l in hunk
336                .lines
337                .iter()
338                .filter(|l| l.origin != strop_git::LineOrigin::Addition)
339            {
340                bytes.extend_from_slice(&l.bytes_with_terminator());
341            }
342            match String::from_utf8(bytes) {
343                Ok(text) => text,
344                Err(error) => {
345                    self.message = format!("hunk contains non-UTF-8 text: {error}");
346                    return false;
347                }
348            }
349        };
350
351        // one validated replacement through the gateway (0024) — the
352        // base-revision check refuses a drifted hunk
353        let base = self.doc(idx).buf.revision();
354        // Header extents include context; the restored old side includes the
355        // same context. Mixing changed-only bounds with full text duplicates it.
356        let first = if hunk.new_count == 0 {
357            hunk.new_start
358        } else {
359            hunk.new_start.saturating_sub(1)
360        };
361        let start = self
362            .doc(idx)
363            .buf
364            .line_start(first)
365            .min(self.doc(idx).buf.len_bytes());
366        let end = if hunk.new_count == 0 {
367            start
368        } else {
369            self.doc(idx)
370                .buf
371                .line_start(first.saturating_add(hunk.new_count))
372                .min(self.doc(idx).buf.len_bytes())
373        };
374        let replacement =
375            strop_core::Replacement::new(strop_core::Range::charwise(start, end), old);
376        if let Err(error) = self.apply(
377            idx,
378            base,
379            ChangeSet {
380                edits: vec![replacement],
381                undo_open: false,
382            },
383        ) {
384            self.message = match error {
385                super::transact::ApplyError::Edit(strop_core::EditError::StaleRevision {
386                    ..
387                }) => "buffer changed — reopen the hunk preview".into(),
388                other => format!("hunk reset failed: {other}"),
389            };
390            return false;
391        }
392        // cursor placement: the target's own pane moves to the
393        // restored region
394        let land = self
395            .doc(idx)
396            .buf
397            .line_start((new_first - 1).min(self.doc(idx).buf.len_lines().saturating_sub(1)));
398        if self.current() == idx {
399            self.set_head(land);
400            self.clamp_cursor();
401            self.flash(strop_core::Range::charwise(self.head(), self.head()));
402        } else if let Some(pane) = self.panes.iter_mut().find(|p| p.doc == idx) {
403            pane.sels.collapse_primary(land);
404        }
405        true
406    }
407
408    /// `Space g u`: reset a hunk to HEAD's content. From the hunk
409    /// surface it restores the origin buffer's hunk (0010 §2). A
410    /// remote origin refuses: the snapshot is readonly and the remote
411    /// repository has no writable index to restore from (RW4).
412    pub(crate) fn undo_hunk(&mut self) {
413        if self.remote_endpoint().is_some() {
414            self.message = "remote Git mutations are not supported".into();
415            return;
416        }
417        let remote_refusal = |editor: &Self, idx| {
418            matches!(
419                editor.docs.get(idx).map(|d| &d.source),
420                Some(super::document::DocumentSource::Remote(_))
421            )
422            .then(|| "remote snapshots are read-only — hunk reset refused".to_string())
423        };
424        match self.hunk_surface_target() {
425            HunkTarget::Fresh {
426                buffer,
427                hunk,
428                untracked,
429            } => {
430                if let Some(message) = remote_refusal(self, buffer) {
431                    self.message = message;
432                } else if self.restore_hunk_in(buffer, &hunk, untracked) {
433                    self.message = "hunk reset".into();
434                }
435            }
436            HunkTarget::Stale => self.message = "buffer changed — reopen the hunk preview".into(),
437            HunkTarget::NotASurface => {
438                let Some(hunk) = self.hunk_under_cursor() else {
439                    self.message = "no hunk here".into();
440                    return;
441                };
442                if let Some(message) = remote_refusal(self, self.current()) {
443                    self.message = message;
444                    return;
445                }
446                let untracked = self.hunks_untracked;
447                if self.restore_hunk_in(self.current(), &hunk, untracked) {
448                    self.message = "hunk reset".into();
449                }
450            }
451        }
452    }
453
454    /// `Space g s`: stage a hunk (index ← worktree edge). The index
455    /// write runs on a worker, serialized FIFO with every other
456    /// mutation — the input path only validates and queues. A remote
457    /// repository refuses by capability (RW4): there is no remote
458    /// index write, and nothing ever falls back to a local one.
459    pub(crate) fn stage_hunk(&mut self) {
460        if self.remote_endpoint().is_some() {
461            self.message = "remote Git mutations are not supported".into();
462            return;
463        }
464        match self.hunk_surface_target() {
465            HunkTarget::Fresh { buffer, hunk, .. } => self.stage_hunk_in(buffer, &hunk),
466            HunkTarget::Stale => self.message = "buffer changed — reopen the hunk preview".into(),
467            HunkTarget::NotASurface => {
468                let Some(hunk) = self.hunk_under_cursor() else {
469                    self.message = "no hunk here".into();
470                    return;
471                };
472                self.stage_hunk_in(self.current(), &hunk);
473            }
474        }
475    }
476
477    fn stage_hunk_in(&mut self, idx: strop_core::id::DocumentId, hunk: &Arc<Hunk>) {
478        let Some(path) = self.doc(idx).buf.path.clone() else {
479            return;
480        };
481        // staging reads the *disk* file's hunk: a dirty buffer means the
482        // two disagree, and auto-saving would silently write every
483        // unrelated unsaved edit to the worktree (0014). Refuse loudly.
484        if self.doc(idx).buf.dirty {
485            self.message = "unsaved changes — :w first, then stage".into();
486            return;
487        }
488        let Some(context) = self.git.clone() else {
489            self.message = "not a git repo".into();
490            return;
491        };
492        if context.repo.is_remote() {
493            self.message = "remote repositories are read-only — staging is refused (RW4)".into();
494            return;
495        }
496        let Ok(rel) = std::path::Path::new(&path)
497            .strip_prefix(context.workdir())
498            .map(|p| p.to_path_buf())
499        else {
500            self.message = "buffer not under workdir".into();
501            return;
502        };
503        let key = MutationKey {
504            document: idx,
505            revision: self.doc(idx).buf.revision(),
506            kind: MutationKind::Stage,
507            rel,
508            repo: context.repo.clone(),
509            git_view: self.git_view,
510        };
511        self.git_mutations.push_back(GitMutation {
512            key,
513            op: MutationOp::Stage { hunk: hunk.clone() },
514        });
515        self.pump_git_mutations();
516    }
517
518    /// `Space g S`: unstage the hunk under the cursor — the index→HEAD
519    /// edge. Queued like staging; the index write never blocks input.
520    /// A remote repository refuses by capability (RW4) — no remote
521    /// index exists to write, and the local one is never touched.
522    pub(crate) fn unstage_hunk(&mut self) {
523        if self.remote_endpoint().is_some() {
524            self.message = "remote Git mutations are not supported".into();
525            return;
526        }
527        if self.buf().dirty {
528            self.message = "unsaved changes — :w first".into();
529            return;
530        }
531        let line = self.buf().line_of(self.head()) + 1;
532        let Some(hunk) = self
533            .staged_hunks
534            .at_line(line)
535            .map(|index| self.staged_hunks[index].clone())
536        else {
537            self.message = "no staged hunk here".into();
538            return;
539        };
540        let Some(path) = self.buf().path.clone() else {
541            return;
542        };
543        let Some(context) = self.git.clone() else {
544            self.message = "not a git repo".into();
545            return;
546        };
547        if context.repo.is_remote() {
548            self.message = "remote repositories are read-only — unstaging is refused (RW4)".into();
549            return;
550        }
551        let Ok(rel) = std::path::Path::new(&path)
552            .strip_prefix(context.workdir())
553            .map(|p| p.to_path_buf())
554        else {
555            self.message = "buffer not under workdir".into();
556            return;
557        };
558        let key = MutationKey {
559            document: self.current(),
560            revision: self.buf().revision(),
561            kind: MutationKind::Unstage,
562            rel,
563            repo: context.repo.clone(),
564            git_view: self.git_view,
565        };
566        self.git_mutations.push_back(GitMutation {
567            key,
568            op: MutationOp::Unstage { hunk },
569        });
570        self.pump_git_mutations();
571    }
572
573    /// `Space g p`: preview the hunk under the cursor as a diff surface
574    /// (0010 §2) — a readonly buffer you can move in; `q` closes,
575    /// `Space g u`/`g s` still act on the file.
576    pub(crate) fn preview_hunk(&mut self) {
577        self.hunk_load.retry_failed();
578        self.refresh_hunks();
579        let line = self.buf().line_of(self.head()) + 1;
580        let Some(index) = self.hunks.at_line(line) else {
581            self.message = "no hunk here".into();
582            return;
583        };
584        let origin = super::git_memory::HunkOrigin {
585            buffer: self.current(),
586            revision: self.cur().buf.revision(),
587            untracked: self.hunks_untracked,
588        };
589        let Some(context) = self.git_context() else {
590            return;
591        };
592        let key = super::git_memory::DiveKey {
593            document: self.current(),
594            repo: context.repo.clone(),
595            target: super::git_memory::DiveTarget::HunkPreview { origin, index },
596        };
597        self.message = "loading hunk…".into();
598        self.register_dive(key);
599    }
600
601    /// What a `Space g u`/`g s` from the current buffer should act on:
602    /// the hunk surface's origin when fresh, a refusal when the origin
603    /// buffer has moved on, and the cursor's own hunk otherwise.
604    fn hunk_surface_target(&self) -> HunkTarget {
605        let Some(super::Surface::Diff { hunks, origin, .. }) = self.surface() else {
606            return HunkTarget::NotASurface;
607        };
608        let Some(origin) = origin else {
609            return HunkTarget::NotASurface; // commit delta: nothing to undo
610        };
611        let Some(hunk) = hunks.first() else {
612            return HunkTarget::NotASurface;
613        };
614        match self.docs.get(origin.buffer) {
615            Some(d) if d.buf.revision() == origin.revision => HunkTarget::Fresh {
616                buffer: origin.buffer,
617                hunk: hunk.clone(),
618                untracked: origin.untracked,
619            },
620            _ => HunkTarget::Stale,
621        }
622    }
623}
624
625#[cfg(test)]
626mod tests;