Skip to main content

strop_engine/editor/git_memory/
jobs.rs

1//! Owned git jobs (R9/R6): every native read and mutation carries a
2//! `Ticket` naming the exact view it belongs to. Success, failure,
3//! panic and cancellation are all terminal — the shared worker wrapper
4//! guarantees a completion exists, and handlers validate the ticket
5//! before touching any editor state, so a stale reply can never clear
6//! a newer owner's loading state or publish over it.
7
8use std::path::{Path, PathBuf};
9
10use crate::editor::document::DocumentSource;
11use crate::files::FileTarget;
12use strop_core::id::DocumentId;
13use strop_core::worker::{
14    self, CancelReason, Completion, Failure, FailureKind, Load, Outcome, Ticket, WorkerId,
15};
16
17use super::types::*;
18use super::{CommitFiles, Surface};
19use crate::editor::{trace, Editor};
20use strop_git::memory::{BlameCard, BlameLine, LogRow};
21use strop_git::{GitContext, GitError, RepoTarget};
22
23/// What the dive's origin surface turned out to be at completion
24/// time — copied out of the document before any mutation, so the
25/// surface check and the publish never fight over a borrow.
26enum DiveLanding {
27    LogSurface,
28    HunkPreview(super::HunkOrigin),
29    Files {
30        sha: String,
31        files: super::PreparedFiles,
32    },
33    Delta {
34        cf: CommitFiles,
35        path: PathBuf,
36    },
37    Mismatch,
38}
39
40// ---- shared plumbing -------------------------------------------------------
41
42/// Map a typed repository error onto the shared failure model.
43pub(crate) fn git_failure(op: &str, error: GitError) -> Failure {
44    match error {
45        GitError::OutsideWorkdir => Failure::new(
46            FailureKind::InvalidInput,
47            format!("{op}: path outside the repository workdir"),
48        ),
49        GitError::Native(message) => Failure::new(FailureKind::Exit, format!("{op}: {message}")),
50    }
51}
52
53/// Rediscover the repository inside a worker, or fail typed — never an
54/// anonymous empty result.
55pub(crate) fn repo_or_unavailable(workdir: &Path) -> Result<strop_git::Repo, Failure> {
56    strop_git::Repo::discover(workdir).ok_or_else(|| {
57        Failure::new(
58            FailureKind::Unavailable,
59            format!("git repository unavailable at {}", workdir.display()),
60        )
61    })
62}
63
64impl Editor {
65    /// Allocate a request ticket for a git job. Identity exhaustion is
66    /// reported, never unwrapped.
67    pub(crate) fn git_ticket<K>(&mut self, key: K) -> Option<Ticket<K>> {
68        match self.worker_ids.allocate() {
69            Ok(request) => Some(Ticket { request, key }),
70            Err(failure) => {
71                self.message = format!("git: {}", failure.message);
72                trace::services::rejected("git", "worker request IDs exhausted");
73                None
74            }
75        }
76    }
77
78    /// Cancel one worker and drop its handle; a completion with
79    /// `Cancelled` may still arrive — handlers reject it once the
80    /// owner is gone.
81    pub(crate) fn cancel_git_worker(&mut self, request: WorkerId, reason: CancelReason) {
82        if let Some(handle) = self.worker_handles.remove(&request) {
83            handle.cancel(reason);
84        }
85    }
86
87    /// The launch gate (replay contract): the owner/ticket is already
88    /// installed when this runs. `true` launches native work, `false`
89    /// means replay will supply the result, and a tape error is sticky
90    /// and never launches.
91    fn tape_launch(&mut self, op: &'static str, args: &impl serde::Serialize) -> bool {
92        match self.tape.request(op, args) {
93            Ok(true) => true,
94            Ok(false) => false,
95            Err(error) => {
96                self.message = format!("replay tape error: {error}");
97                trace::services::rejected("git", "replay tape failed");
98                false
99            }
100        }
101    }
102
103    /// Spawn the native half of a registered request. Callers MUST
104    /// have installed the owner (Load/registry entry) first.
105    #[allow(clippy::too_many_arguments)]
106    pub(crate) fn launch_git_job<K, T>(
107        &mut self,
108        name: &'static str,
109        op: &'static str,
110        ticket: Ticket<K>,
111        args: &impl serde::Serialize,
112        make: impl FnOnce(Completion<K, T>) -> GitJob + Send + 'static,
113        work: impl FnOnce(worker::CancelToken) -> Outcome<T> + Send + 'static,
114    ) where
115        K: Clone + Send + 'static,
116        T: Send + 'static,
117    {
118        if !self.tape_launch(op, args) {
119            return; // replay: registration stands, native work withheld
120        }
121        let tx = self.git_tx.clone();
122        let emit_ticket = ticket.clone();
123        let handle = worker::spawn(
124            name,
125            move |outcome| {
126                let _ = tx.send(make(Completion {
127                    ticket: emit_ticket,
128                    outcome,
129                }));
130            },
131            work,
132        );
133        self.worker_handles.insert(ticket.request, handle);
134    }
135
136    /// A pure-validation failure for an already-registered request:
137    /// settle it through the same terminal path as worker failures —
138    /// same channel, same handler, same ownership check. No spawning.
139    pub(crate) fn send_git_failure<K, T>(
140        &self,
141        ticket: Ticket<K>,
142        kind: FailureKind,
143        message: impl Into<String>,
144        make: impl FnOnce(Completion<K, T>) -> GitJob,
145    ) {
146        let completion = Completion {
147            ticket,
148            outcome: Outcome::Failed {
149                failure: Failure::new(kind, message),
150                partial: None,
151            },
152        };
153        let _ = self.git_tx.send(make(completion));
154    }
155
156    /// The index moved under every cached diff: a new git view, the
157    /// old owner cancelled, both hunk vectors cleared honestly for the
158    /// one frame the recompute takes.
159    pub(crate) fn invalidate_git_view(&mut self) {
160        let running = match &self.hunk_load {
161            Load::Running(ticket) => Some(ticket.request),
162            _ => None,
163        };
164        if let Some(request) = running {
165            self.cancel_git_worker(request, CancelReason::Superseded);
166        }
167        self.hunk_load = Load::Idle;
168        self.hunks = super::HunkSet::default();
169        self.staged_hunks = super::HunkSet::default();
170        self.hunks_untracked = false;
171        if let Ok(view) = self.worker_ids.allocate() {
172            self.git_view = view;
173        }
174    }
175
176    /// Revoke every git request a closing document owned (the
177    /// document-close path calls this BEFORE removal): late results
178    /// for a dead or recycled slot cannot publish.
179    pub(crate) fn revoke_git_requests_for(&mut self, doc: DocumentId) {
180        if let Some(ticket) = self.log_requests.remove(&doc) {
181            self.cancel_git_worker(ticket.request, CancelReason::Dismissed);
182        }
183        if let Some(ticket) = self.dive_requests.remove(&doc) {
184            self.cancel_git_worker(ticket.request, CancelReason::Dismissed);
185        }
186    }
187
188    // ---- context (discovery) ------------------------------------------------
189
190    pub(crate) fn handle_context_completion(
191        &mut self,
192        completion: Completion<ContextKey, Option<GitContext>>,
193    ) {
194        if !self.git_discovery.owns(&completion.ticket) {
195            trace::services::rejected("git", "discovery superseded");
196            return;
197        }
198        self.worker_handles.remove(&completion.ticket.request);
199        let key = completion.ticket.key;
200        match completion.outcome {
201            Outcome::Success(context) => {
202                self.git_discovery = Load::Ready(key);
203                // an equal context (same HEAD, branch, remotes) means
204                // the cached view is still valid — no churn, no reload
205                if self.git.as_ref() != context.as_ref() {
206                    self.git = context;
207                    self.invalidate_git_view();
208                }
209            }
210            Outcome::Failed { failure, .. } => {
211                self.message = format!("git discovery failed: {}", failure.message);
212                self.git_discovery = Load::Failed { key, failure };
213            }
214            Outcome::Cancelled(reason) => {
215                self.git_discovery = Load::Cancelled { key, reason };
216            }
217        }
218    }
219
220    // ---- hunks ----------------------------------------------------------------
221
222    pub(crate) fn handle_hunk_completion(&mut self, completion: Completion<HunkKey, HunkData>) {
223        if !self.hunk_load.owns(&completion.ticket) {
224            trace::services::rejected("git", "hunk request no longer owns the view");
225            return;
226        }
227        self.worker_handles.remove(&completion.ticket.request);
228        self.hunk_load = Load::Idle;
229        let key = completion.ticket.key;
230        // the snapshot applies only to the document that asked, at
231        // that revision, in that git view, still showing the same file
232        // identity — local path or remote file — nothing else
233        let valid = !self.docs.is_empty()
234            && self.current() == key.document
235            && self.docs.get(key.document).is_some_and(|d| {
236                d.buf.revision() == key.revision
237                    && match &key.file {
238                        FileTarget::Local(path) => d.buf.path.as_deref() == Some(path.as_path()),
239                        FileTarget::Remote(file) => matches!(
240                            &d.source,
241                            DocumentSource::Remote(current) if file.absolute_file() == Some(&current.file)
242                        ),
243                        FileTarget::Container { container, path } => matches!(
244                            &d.source,
245                            DocumentSource::Container {
246                                container: current_container,
247                                path: current_path
248                            } if current_container == container && current_path == path
249                        ),
250                    }
251                    && self.git.as_ref().is_some_and(|c| c.repo == key.repo)
252            })
253            && self.git_view == key.git_view;
254        if !valid {
255            trace::services::rejected("git", "hunk document, revision or repository changed");
256            return;
257        }
258        match completion.outcome {
259            Outcome::Success(data) => {
260                self.hunks = data.unstaged;
261                self.staged_hunks = data.staged;
262                self.hunks_untracked = data.untracked;
263                self.hunk_load = Load::Ready(key);
264            }
265            Outcome::Failed { failure, .. } => {
266                self.message = format!("git diff failed: {}", failure.message);
267                self.hunk_load = Load::Failed { key, failure };
268            }
269            Outcome::Cancelled(reason) => {
270                self.hunk_load = Load::Cancelled { key, reason };
271            }
272        }
273    }
274
275    // ---- mutations --------------------------------------------------------------
276
277    /// Launch the next queued mutation if none is running. Index
278    /// writes are serialized FIFO; each request still gets its own
279    /// ticket at launch and settles terminally.
280    pub(crate) fn pump_git_mutations(&mut self) {
281        while self.git_mutation.is_none() {
282            let Some(mutation) = self.git_mutations.pop_front() else {
283                return;
284            };
285            // pure re-validation at launch: the buffer, the view the
286            // command targeted, and a LOCAL repository target — remote
287            // repositories are read-only (RW4) and a stale queue entry
288            // for one is refused here, never executed
289            let valid = self.git_view == mutation.key.git_view
290                && matches!(mutation.key.repo, RepoTarget::Local { .. })
291                && self
292                    .docs
293                    .get(mutation.key.document)
294                    .is_some_and(|d| d.buf.revision() == mutation.key.revision);
295            if !valid {
296                if mutation.key.repo.is_remote() {
297                    trace::services::rejected("git", "remote repositories are read-only (RW4)");
298                } else {
299                    trace::services::rejected("git", "mutation superseded before launch");
300                }
301                continue;
302            }
303            let Some(ticket) = self.git_ticket(mutation.key.clone()) else {
304                return; // identity exhausted: later requests cannot fare better
305            };
306            self.git_mutation = Some(ticket.clone());
307            strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
308                serde_json::json!({
309                    "service":"git","request":"mutation","edge":ticket.key.kind.edge(),
310                    "document":{"slot":ticket.key.document.index(),"generation":ticket.key.document.generation()},
311                    "revision":ticket.key.revision.get(),"path":ticket.key.rel.to_string_lossy(),
312                })
313            });
314            let args = (ticket.clone(), mutation.op.clone());
315            let workdir = ticket.key.repo.workdir().to_path_buf();
316            let rel = ticket.key.rel.clone();
317            let kind = ticket.key.kind;
318            let op = mutation.op;
319            self.launch_git_job(
320                "git-mutate",
321                "git.mutation",
322                ticket,
323                &args,
324                GitJob::Mutation,
325                move |cancel| {
326                    if cancel.is_cancelled() {
327                        return Outcome::Cancelled(CancelReason::Superseded);
328                    }
329                    let repo = match repo_or_unavailable(&workdir) {
330                        Ok(repo) => repo,
331                        Err(failure) => {
332                            return Outcome::Failed {
333                                failure,
334                                partial: None,
335                            }
336                        }
337                    };
338                    let result = match &op {
339                        MutationOp::Stage { hunk } => repo.stage_hunk(&rel, hunk),
340                        MutationOp::Unstage { hunk } => repo.unstage_hunk(&rel, hunk),
341                    };
342                    match result {
343                        Ok(()) => Outcome::Success(()),
344                        Err(message) => Outcome::Failed {
345                            failure: Failure::new(
346                                FailureKind::Exit,
347                                format!("{}: {message}", kind.edge()),
348                            ),
349                            partial: None,
350                        },
351                    }
352                },
353            );
354        }
355    }
356
357    pub(crate) fn handle_mutation_completion(&mut self, completion: Completion<MutationKey, ()>) {
358        if self.git_mutation.as_ref() != Some(&completion.ticket) {
359            trace::services::rejected("git", "mutation superseded");
360            return;
361        }
362        self.git_mutation = None;
363        self.worker_handles.remove(&completion.ticket.request);
364        let key = completion.ticket.key;
365        // the index write already happened (or failed) — validation
366        // decides only whether THIS editor view still reports it
367        let current = !self.docs.is_empty()
368            && self.current() == key.document
369            && self
370                .docs
371                .get(key.document)
372                .is_some_and(|d| d.buf.revision() == key.revision)
373            && self.git_view == key.git_view;
374        match completion.outcome {
375            Outcome::Success(()) => {
376                // the index changed under every cached diff: new view
377                self.invalidate_git_view();
378                if current {
379                    self.message = match key.kind {
380                        MutationKind::Stage => "hunk staged".into(),
381                        MutationKind::Unstage => "hunk unstaged".into(),
382                    };
383                }
384            }
385            Outcome::Failed { failure, .. } => {
386                if current {
387                    self.message = match key.kind {
388                        MutationKind::Stage => {
389                            format!("stage failed: {}", failure.message)
390                        }
391                        MutationKind::Unstage => {
392                            format!("unstage failed: {}", failure.message)
393                        }
394                    };
395                }
396            }
397            Outcome::Cancelled(_) => {}
398        }
399        self.pump_git_mutations();
400    }
401
402    // ---- log --------------------------------------------------------------------
403
404    /// Land successful log rows in the surface buffer — the existing
405    /// text/focus/cursor contract, extracted from the old inline
406    /// handler. An empty successful log is a completed empty list.
407    fn publish_log_rows(&mut self, doc: DocumentId, rows: Vec<LogRow>) {
408        let text = rows
409            .iter()
410            .map(|r| r.text.as_str())
411            .collect::<Vec<_>>()
412            .join("\n")
413            + "\n";
414        if let Err(error) = self.replace_system(doc, &text) {
415            trace::services::rejected("git", "log publish failed");
416            self.message = format!("git log publish failed: {error}");
417            return;
418        }
419        let mut focus_row = None;
420        if let Some(Some(Surface::CommitLog {
421            rows: slot, focus, ..
422        })) = self.docs.get_mut(doc).map(|d| d.surface_payload_mut())
423        {
424            focus_row = focus
425                .take()
426                .and_then(|sha| rows.iter().position(|r| r.sha.as_deref() == Some(&sha)));
427            *slot = rows;
428        }
429        if let Some(row) = focus_row {
430            // the blame dive asked for this commit: land on it (only
431            // when the browser is still what's being driven)
432            if self.current() == doc {
433                let at = self.doc(doc).buf.line_start(row);
434                self.set_head(at);
435                self.view_mut().view_top = row;
436            }
437        }
438    }
439
440    /// Replace a log surface's loading text with a terminal status;
441    /// rows stay empty. The surface never shows "loading" forever.
442    fn publish_log_status(&mut self, doc: DocumentId, status: &str) {
443        if let Err(error) = self.replace_system(doc, status) {
444            trace::services::rejected("git", "log status publish failed");
445            self.message = format!("git log publish failed: {error}");
446        }
447    }
448
449    pub(crate) fn handle_log_completion(&mut self, completion: Completion<LogKey, Vec<LogRow>>) {
450        let doc = completion.ticket.key.document;
451        if self.log_requests.get(&doc) != Some(&completion.ticket) {
452            trace::services::rejected("git", "log request superseded");
453            return;
454        }
455        self.log_requests.remove(&doc);
456        self.worker_handles.remove(&completion.ticket.request);
457        // only the still-live CommitLog surface at its request
458        // revision accepts rows — a closed/recycled slot cannot
459        let valid = self.docs.get(doc).is_some_and(|d| {
460            d.buf.revision() == completion.ticket.key.revision
461                && matches!(d.surface_payload(), Some(Surface::CommitLog { .. }))
462        });
463        if !valid {
464            trace::services::rejected("git", "log surface changed or closed");
465            return;
466        }
467        match completion.outcome {
468            Outcome::Success(rows) => self.publish_log_rows(doc, rows),
469            Outcome::Failed { failure, .. } => {
470                self.publish_log_status(doc, &format!("git log failed: {}\n", failure.message));
471                if !self.docs.is_empty() && self.current() == doc {
472                    self.message = failure.message;
473                }
474            }
475            Outcome::Cancelled(_) => self.publish_log_status(doc, "git log cancelled\n"),
476        }
477    }
478
479    // ---- blame -------------------------------------------------------------------
480
481    pub(crate) fn handle_gutter_completion(
482        &mut self,
483        completion: Completion<BlameKey, Vec<BlameLine>>,
484    ) {
485        let path = completion.ticket.key.document;
486        if !self
487            .blame_gutters
488            .get(&path)
489            .is_some_and(|g| g.request.as_ref() == Some(&completion.ticket))
490        {
491            trace::services::rejected("git", "gutter request superseded or toggled off");
492            return;
493        }
494        self.blame_gutters
495            .get_mut(&path)
496            .and_then(|g| g.request.take());
497        self.worker_handles.remove(&completion.ticket.request);
498        let key = completion.ticket.key;
499        let valid = self.docs.get(key.document).is_some_and(|d| {
500            d.buf.revision() == key.revision && d.file_target(&self.cwd).as_ref() == Some(&key.file)
501        });
502        match completion.outcome {
503            Outcome::Success(lines) => {
504                if !valid {
505                    // stale pairing: keep the marker inert (its
506                    // trust gate already refuses mismatches), never
507                    // republish over a changed buffer
508                    trace::services::rejected("git", "gutter document changed");
509                    return;
510                }
511                if let Some(gutter) = self.blame_gutters.get_mut(&path) {
512                    gutter.lines = lines;
513                }
514                // the gutter supersedes the interim card that covered
515                // the load for this buffer
516                if !self.docs.is_empty() && self.current() == key.document {
517                    if let Some(ticket) = self.card_request.take() {
518                        self.cancel_git_worker(ticket.request, CancelReason::Superseded);
519                    }
520                    self.blame_card = None;
521                }
522            }
523            Outcome::Failed { failure, .. } => {
524                // a failed load removes its own loading marker: the
525                // next toggle genuinely starts another request
526                self.blame_gutters.remove(&path);
527                if valid && !self.docs.is_empty() && self.current() == key.document {
528                    self.message = format!("blame failed: {}", failure.message);
529                }
530            }
531            Outcome::Cancelled(_) => {
532                // cancelled while loading: the marker dies with the
533                // request (toggle-off already removed it — this is
534                // the supersedure case)
535                self.blame_gutters.remove(&path);
536            }
537        }
538    }
539
540    pub(crate) fn handle_card_completion(
541        &mut self,
542        completion: Completion<CardKey, Box<BlameCard>>,
543    ) {
544        if self.card_request.as_ref() != Some(&completion.ticket) {
545            trace::services::rejected("git", "card request superseded or dismissed");
546            return;
547        }
548        self.card_request = None;
549        self.worker_handles.remove(&completion.ticket.request);
550        let key = completion.ticket.key;
551        // the card describes the cursor line of the document that
552        // asked, at the revision it asked at
553        let valid = !self.docs.is_empty()
554            && self.current() == key.origin.document
555            && self.buf().revision() == key.origin.revision
556            && self.buf().line_of(self.head()) + 1 == key.line
557            && self.cur().file_target(&self.cwd).as_ref() == Some(&key.origin.file);
558        if !valid {
559            trace::services::rejected("git", "card origin changed");
560            return;
561        }
562        match completion.outcome {
563            Outcome::Success(card) => self.blame_card = Some(*card),
564            Outcome::Failed { failure, .. } => self.message = failure.message,
565            Outcome::Cancelled(_) => {}
566        }
567    }
568
569    // ---- dive --------------------------------------------------------------------
570
571    pub(crate) fn handle_dive_completion(&mut self, completion: Completion<DiveKey, DiveData>) {
572        let doc = completion.ticket.key.document;
573        if self.dive_requests.get(&doc) != Some(&completion.ticket) {
574            trace::services::rejected("git", "dive request superseded");
575            return;
576        }
577        let key = completion.ticket.key;
578        self.dive_requests.remove(&doc);
579        self.worker_handles.remove(&completion.ticket.request);
580        let outcome = completion.outcome;
581        // read the surface NOW, copy out what the landing needs, then
582        // drop the borrow before any editor mutation
583        if self.docs.is_empty()
584            || self.current() != doc
585            || self
586                .git_context()
587                .is_none_or(|context| context.repo != key.repo)
588        {
589            trace::services::rejected("git", "dive surface owner changed");
590            return;
591        }
592        let landing = self
593            .docs
594            .get(doc)
595            .map_or(DiveLanding::Mismatch, |document| {
596                match (&key.target, document.surface_payload()) {
597                    (DiveTarget::HunkPreview { origin, .. }, _)
598                        if origin.buffer == doc && origin.revision == document.buf.revision() =>
599                    {
600                        DiveLanding::HunkPreview(origin.clone())
601                    }
602                    (DiveTarget::CommitFiles { .. }, Some(Surface::CommitLog { .. })) => {
603                        DiveLanding::LogSurface
604                    }
605                    (
606                        DiveTarget::FileDelta { sha, .. },
607                        Some(Surface::ChangedFiles {
608                            sha: surface_sha,
609                            files,
610                            ..
611                        }),
612                    ) if surface_sha == sha => DiveLanding::Files {
613                        sha: sha.clone(),
614                        files: files.clone(),
615                    },
616                    (
617                        DiveTarget::FileDelta { sha, path },
618                        Some(Surface::Diff {
619                            commit: Some(cf), ..
620                        }),
621                    ) if cf.sha == *sha && cf.files.index_of(path).is_some() => {
622                        DiveLanding::Delta {
623                            cf: cf.clone(),
624                            path: path.clone(),
625                        }
626                    }
627                    _ => DiveLanding::Mismatch,
628                }
629            });
630        match (landing, outcome) {
631            (DiveLanding::HunkPreview(origin), Outcome::Success(DiveData::Delta(diff))) => {
632                self.message.clear();
633                self.open_delta("hunk", diff, Some(origin), None);
634            }
635            (DiveLanding::LogSurface, Outcome::Success(DiveData::Files(files))) => {
636                let sha = match &key.target {
637                    DiveTarget::CommitFiles { sha } => sha.clone(),
638                    _ => return,
639                };
640                let text = files.text();
641                self.message.clear();
642                self.push_surface(
643                    Some("commit files"),
644                    text,
645                    Surface::ChangedFiles {
646                        sha,
647                        files,
648                        return_to: None,
649                    },
650                );
651            }
652            (DiveLanding::Files { sha, files }, Outcome::Success(DiveData::Delta(diff))) => {
653                let path = match &key.target {
654                    DiveTarget::FileDelta { path, .. } => path.clone(),
655                    _ => return,
656                };
657                let commit = CommitFiles {
658                    repo: key.repo.clone(),
659                    sha,
660                    files,
661                    current: path.clone(),
662                };
663                self.message.clear();
664                self.open_delta("delta", diff, None, Some(commit));
665            }
666            (DiveLanding::Delta { cf, path }, Outcome::Success(DiveData::Delta(diff))) => {
667                self.load_commit_delta(&cf, &path, diff);
668            }
669            // a payload that does not match its target cannot come
670            // from our producers; reject rather than mis-publish
671            (_, Outcome::Success(_)) => {
672                trace::services::rejected("git", "dive surface changed or payload mismatched")
673            }
674            (_, Outcome::Failed { failure, .. }) => self.message = failure.message,
675            (_, Outcome::Cancelled(_)) => {}
676        }
677    }
678
679    // ---- dispatch ------------------------------------------------------------------
680
681    /// One git job result (TUI events land here directly — 0018; the
682    /// headless drains call this too).
683    pub fn handle_git_job(&mut self, job: GitJob) {
684        trace::services::git(&job);
685        match job {
686            GitJob::Context(c) => self.handle_context_completion(c),
687            GitJob::Hunks(c) => self.handle_hunk_completion(c),
688            GitJob::Mutation(c) => self.handle_mutation_completion(c),
689            GitJob::Log(c) => self.handle_log_completion(c),
690            GitJob::Gutter(c) => self.handle_gutter_completion(c),
691            GitJob::Card(c) => self.handle_card_completion(c),
692            GitJob::Dive(c) => self.handle_dive_completion(c),
693        }
694    }
695}