1use 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
23enum 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
40pub(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
53pub(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 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 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 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 #[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; }
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 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 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 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 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 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 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 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(¤t.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 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 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; };
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 (_, 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 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}