Skip to main content

zag_agent/providers/
copilot.rs

1// provider-updated: 2026-04-05
2use crate::agent::{Agent, ModelSize};
3use crate::output::AgentOutput;
4use crate::providers::common::CommonAgentState;
5use crate::session_log::{
6    BackfilledSession, HistoricalLogAdapter, LiveLogAdapter, LiveLogContext, LogCompleteness,
7    LogEventKind, LogSourceKind, SessionLogMetadata, SessionLogWriter, ToolKind,
8};
9use anyhow::{Context, Result};
10
11/// Classify a Copilot tool name into a normalized ToolKind.
12fn tool_kind_from_name(name: &str) -> ToolKind {
13    match name {
14        "bash" | "shell" => ToolKind::Shell,
15        "view" | "read" | "cat" => ToolKind::FileRead,
16        "write" => ToolKind::FileWrite,
17        "edit" | "insert" | "replace" => ToolKind::FileEdit,
18        "grep" | "glob" | "find" | "search" => ToolKind::Search,
19        _ => ToolKind::Other,
20    }
21}
22use async_trait::async_trait;
23use log::info;
24use std::collections::HashSet;
25use std::io::{BufRead, BufReader, Seek, SeekFrom};
26use std::path::{Path, PathBuf};
27use tokio::fs;
28use tokio::process::Command;
29
30/// Return the Copilot session-state directory: `~/.copilot/session-state/`.
31pub fn session_state_dir() -> std::path::PathBuf {
32    dirs::home_dir()
33        .unwrap_or_else(|| std::path::PathBuf::from("."))
34        .join(".copilot/session-state")
35}
36
37pub const DEFAULT_MODEL: &str = "claude-sonnet-4.6";
38
39pub const AVAILABLE_MODELS: &[&str] = &[
40    "claude-sonnet-4.6",
41    "claude-haiku-4.5",
42    "claude-opus-4.6",
43    "claude-sonnet-4.5",
44    "claude-opus-4.5",
45    "gpt-5.4",
46    "gpt-5.4-mini",
47    "gpt-5.3-codex",
48    "gpt-5.2-codex",
49    "gpt-5.2",
50    "gpt-5.1-codex-max",
51    "gpt-5.1-codex",
52    "gpt-5.1",
53    "gpt-5",
54    "gpt-5.1-codex-mini",
55    "gpt-5-mini",
56    "gpt-4.1",
57    "gemini-3.1-pro-preview",
58    "gemini-3-pro-preview",
59];
60
61pub struct Copilot {
62    pub common: CommonAgentState,
63}
64
65pub struct CopilotLiveLogAdapter {
66    ctx: LiveLogContext,
67    session_path: Option<PathBuf>,
68    offset: u64,
69    seen_event_ids: HashSet<String>,
70}
71
72pub struct CopilotHistoricalLogAdapter;
73
74impl Copilot {
75    pub fn new() -> Self {
76        Self {
77            common: CommonAgentState::new(DEFAULT_MODEL),
78        }
79    }
80
81    async fn write_instructions_file(&self) -> Result<()> {
82        let base = self.common.get_base_path();
83        log::debug!("Writing Copilot instructions file to {}", base.display());
84        let instructions_dir = base.join(".github/instructions/agent");
85        fs::create_dir_all(&instructions_dir).await?;
86        fs::write(
87            instructions_dir.join("agent.instructions.md"),
88            &self.common.system_prompt,
89        )
90        .await?;
91        Ok(())
92    }
93
94    /// Build the argument list for a run/exec invocation.
95    fn build_run_args(&self, interactive: bool, prompt: Option<&str>) -> Vec<String> {
96        let mut args = Vec::new();
97
98        // In non-interactive mode, --allow-all is required
99        if !interactive || self.common.skip_permissions {
100            args.push("--allow-all".to_string());
101        }
102
103        if !self.common.model.is_empty() {
104            args.extend(["--model".to_string(), self.common.model.clone()]);
105        }
106
107        for dir in &self.common.add_dirs {
108            args.extend(["--add-dir".to_string(), dir.clone()]);
109        }
110
111        if let Some(turns) = self.common.max_turns {
112            args.extend(["--max-turns".to_string(), turns.to_string()]);
113        }
114
115        match (interactive, prompt) {
116            (true, Some(p)) => args.extend(["-i".to_string(), p.to_string()]),
117            (false, Some(p)) => args.extend(["-p".to_string(), p.to_string()]),
118            _ => {}
119        }
120
121        args
122    }
123
124    /// Create a `Command` either directly or wrapped in sandbox.
125    fn make_command(&self, agent_args: Vec<String>) -> Command {
126        self.common.make_command("copilot", agent_args)
127    }
128
129    async fn execute(
130        &self,
131        interactive: bool,
132        prompt: Option<&str>,
133    ) -> Result<Option<AgentOutput>> {
134        // Output format flags are not supported by Copilot
135        if self.common.output_format.is_some() {
136            anyhow::bail!(
137                "Copilot does not support the --output flag. Remove the flag and try again."
138            );
139        }
140
141        if !self.common.system_prompt.is_empty() {
142            log::debug!(
143                "Copilot system prompt (written to instructions): {}",
144                self.common.system_prompt
145            );
146            self.write_instructions_file().await?;
147        }
148
149        let agent_args = self.build_run_args(interactive, prompt);
150        log::debug!("Copilot command: copilot {}", agent_args.join(" "));
151        if let Some(p) = prompt {
152            log::debug!("Copilot user prompt: {p}");
153        }
154        let mut cmd = self.make_command(agent_args);
155
156        if interactive {
157            CommonAgentState::run_interactive_command_with_hook(
158                &mut cmd,
159                "Copilot",
160                self.common.on_spawn_hook.as_ref(),
161            )
162            .await?;
163            Ok(None)
164        } else {
165            self.common
166                .run_non_interactive_simple(&mut cmd, "Copilot")
167                .await
168        }
169    }
170}
171
172#[cfg(test)]
173#[path = "copilot_tests.rs"]
174mod tests;
175
176impl Default for Copilot {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182impl CopilotLiveLogAdapter {
183    pub fn new(ctx: LiveLogContext) -> Self {
184        Self {
185            ctx,
186            session_path: None,
187            offset: 0,
188            seen_event_ids: HashSet::new(),
189        }
190    }
191
192    fn discover_session_path(&self) -> Option<PathBuf> {
193        let base = copilot_session_state_dir();
194        if let Some(session_id) = &self.ctx.provider_session_id {
195            let candidate = base.join(session_id).join("events.jsonl");
196            if candidate.exists() {
197                return Some(candidate);
198            }
199        }
200
201        let started_at = system_time_from_utc(self.ctx.started_at);
202        let workspace = self
203            .ctx
204            .workspace_path
205            .as_deref()
206            .or(self.ctx.root.as_deref());
207        let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
208        let entries = std::fs::read_dir(base).ok()?;
209        for entry in entries.flatten() {
210            let path = entry.path().join("events.jsonl");
211            if !path.exists() {
212                continue;
213            }
214            let modified = entry
215                .metadata()
216                .ok()
217                .and_then(|metadata| metadata.modified().ok())
218                .or_else(|| {
219                    std::fs::metadata(&path)
220                        .ok()
221                        .and_then(|metadata| metadata.modified().ok())
222                })?;
223            if modified < started_at {
224                continue;
225            }
226            if let Some(workspace) = workspace
227                && !copilot_session_matches_workspace(&entry.path(), workspace)
228            {
229                continue;
230            }
231            if best
232                .as_ref()
233                .map(|(current, _)| modified > *current)
234                .unwrap_or(true)
235            {
236                best = Some((modified, path));
237            }
238        }
239        best.map(|(_, path)| path)
240    }
241}
242
243#[async_trait]
244impl LiveLogAdapter for CopilotLiveLogAdapter {
245    async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
246        if self.session_path.is_none() {
247            self.session_path = self.discover_session_path();
248            if let Some(path) = &self.session_path {
249                writer.add_source_path(path.to_string_lossy().to_string())?;
250                let metadata_path = path.with_file_name("vscode.metadata.json");
251                if metadata_path.exists() {
252                    writer.add_source_path(metadata_path.to_string_lossy().to_string())?;
253                }
254                let workspace_path = path.with_file_name("workspace.yaml");
255                if workspace_path.exists() {
256                    writer.add_source_path(workspace_path.to_string_lossy().to_string())?;
257                }
258            }
259        }
260
261        let Some(path) = self.session_path.as_ref() else {
262            return Ok(());
263        };
264
265        let mut file = std::fs::File::open(path)
266            .with_context(|| format!("Failed to open {}", path.display()))?;
267        file.seek(SeekFrom::Start(self.offset))?;
268        let mut reader = BufReader::new(file);
269        let mut line = String::new();
270
271        while reader.read_line(&mut line)? > 0 {
272            self.offset += line.len() as u64;
273            let trimmed = line.trim();
274            if trimmed.is_empty() {
275                line.clear();
276                continue;
277            }
278            let Some(parsed) = parse_copilot_event_line(trimmed, &mut self.seen_event_ids) else {
279                line.clear();
280                continue;
281            };
282            if parsed.parse_failed {
283                writer.emit(
284                    LogSourceKind::ProviderFile,
285                    LogEventKind::ParseWarning {
286                        message: "Failed to parse Copilot event line".to_string(),
287                        raw: Some(trimmed.to_string()),
288                    },
289                )?;
290                line.clear();
291                continue;
292            }
293            if let Some(session_id) = parsed.provider_session_id {
294                writer.set_provider_session_id(Some(session_id))?;
295            }
296            for event in parsed.events {
297                writer.emit(LogSourceKind::ProviderFile, event)?;
298            }
299            line.clear();
300        }
301
302        Ok(())
303    }
304}
305
306impl HistoricalLogAdapter for CopilotHistoricalLogAdapter {
307    fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
308        let base = copilot_session_state_dir();
309        let entries = match std::fs::read_dir(&base) {
310            Ok(entries) => entries,
311            Err(_) => return Ok(Vec::new()),
312        };
313        let mut sessions = Vec::new();
314        for entry in entries.flatten() {
315            let session_dir = entry.path();
316            if !session_dir.is_dir() {
317                continue;
318            }
319            let events_path = session_dir.join("events.jsonl");
320            if !events_path.exists() {
321                continue;
322            }
323            info!("Scanning Copilot history: {}", events_path.display());
324            let file = std::fs::File::open(&events_path)
325                .with_context(|| format!("Failed to open {}", events_path.display()))?;
326            let reader = BufReader::new(file);
327            let mut seen_event_ids = HashSet::new();
328            let mut events = Vec::new();
329            let mut provider_session_id = None;
330            let mut model = None;
331            let mut workspace_path = read_copilot_workspace_path(&session_dir);
332
333            for line in reader.lines() {
334                let line = line?;
335                let trimmed = line.trim();
336                if trimmed.is_empty() {
337                    continue;
338                }
339                let Some(parsed) = parse_copilot_event_line(trimmed, &mut seen_event_ids) else {
340                    continue;
341                };
342                if parsed.parse_failed {
343                    events.push((
344                        LogSourceKind::Backfill,
345                        LogEventKind::ParseWarning {
346                            message: "Failed to parse Copilot event line".to_string(),
347                            raw: Some(trimmed.to_string()),
348                        },
349                    ));
350                    continue;
351                }
352                if provider_session_id.is_none() {
353                    provider_session_id = parsed.provider_session_id;
354                }
355                if model.is_none() {
356                    model = parsed.model;
357                }
358                if workspace_path.is_none() {
359                    workspace_path = parsed.workspace_path;
360                }
361                for event in parsed.events {
362                    events.push((LogSourceKind::Backfill, event));
363                }
364            }
365
366            let session_id = provider_session_id
367                .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
368            let mut source_paths = vec![events_path.to_string_lossy().to_string()];
369            let metadata_path = session_dir.join("vscode.metadata.json");
370            if metadata_path.exists() {
371                source_paths.push(metadata_path.to_string_lossy().to_string());
372            }
373            let workspace_yaml = session_dir.join("workspace.yaml");
374            if workspace_yaml.exists() {
375                source_paths.push(workspace_yaml.to_string_lossy().to_string());
376            }
377            sessions.push(BackfilledSession {
378                metadata: SessionLogMetadata {
379                    provider: "copilot".to_string(),
380                    wrapper_session_id: session_id.clone(),
381                    provider_session_id: Some(session_id),
382                    workspace_path,
383                    command: "backfill".to_string(),
384                    model,
385                    resumed: false,
386                    backfilled: true,
387                },
388                completeness: LogCompleteness::Full,
389                source_paths,
390                events,
391            });
392        }
393        Ok(sessions)
394    }
395}
396
397pub(crate) struct ParsedCopilotEvent {
398    pub(crate) provider_session_id: Option<String>,
399    pub(crate) model: Option<String>,
400    pub(crate) workspace_path: Option<String>,
401    pub(crate) events: Vec<LogEventKind>,
402    pub(crate) parse_failed: bool,
403}
404
405pub(crate) fn parse_copilot_event_line(
406    line: &str,
407    seen_event_ids: &mut HashSet<String>,
408) -> Option<ParsedCopilotEvent> {
409    let value: serde_json::Value = match serde_json::from_str(line) {
410        Ok(value) => value,
411        Err(_) => {
412            return Some(ParsedCopilotEvent {
413                provider_session_id: None,
414                model: None,
415                workspace_path: None,
416                events: Vec::new(),
417                parse_failed: true,
418            });
419        }
420    };
421
422    let event_id = value
423        .get("id")
424        .and_then(|value| value.as_str())
425        .unwrap_or_default();
426    if !event_id.is_empty() && !seen_event_ids.insert(event_id.to_string()) {
427        return None;
428    }
429
430    let event_type = value
431        .get("type")
432        .and_then(|value| value.as_str())
433        .unwrap_or_default();
434    let data = value
435        .get("data")
436        .cloned()
437        .unwrap_or(serde_json::Value::Null);
438    let provider_session_id = value
439        .get("data")
440        .and_then(|value| value.get("sessionId"))
441        .and_then(|value| value.as_str())
442        .map(str::to_string);
443    let model = value
444        .get("data")
445        .and_then(|value| value.get("selectedModel"))
446        .and_then(|value| value.as_str())
447        .map(str::to_string);
448    let workspace_path = value
449        .get("data")
450        .and_then(|value| value.get("context"))
451        .and_then(|value| value.get("cwd").or_else(|| value.get("gitRoot")))
452        .and_then(|value| value.as_str())
453        .map(str::to_string);
454    let mut events = Vec::new();
455
456    match event_type {
457        "session.start" => events.push(LogEventKind::ProviderStatus {
458            message: "Copilot session started".to_string(),
459            data: Some(data),
460        }),
461        "session.model_change" => events.push(LogEventKind::ProviderStatus {
462            message: "Copilot model changed".to_string(),
463            data: Some(data),
464        }),
465        "session.info" => events.push(LogEventKind::ProviderStatus {
466            message: data
467                .get("message")
468                .and_then(|value| value.as_str())
469                .unwrap_or("Copilot session info")
470                .to_string(),
471            data: Some(data),
472        }),
473        "session.truncation" => events.push(LogEventKind::ProviderStatus {
474            message: "Copilot session truncation".to_string(),
475            data: Some(data),
476        }),
477        "user.message" => events.push(LogEventKind::UserMessage {
478            role: "user".to_string(),
479            content: data
480                .get("content")
481                .or_else(|| data.get("transformedContent"))
482                .and_then(|value| value.as_str())
483                .unwrap_or_default()
484                .to_string(),
485            message_id: value
486                .get("id")
487                .and_then(|value| value.as_str())
488                .map(str::to_string),
489        }),
490        "assistant.turn_start" => events.push(LogEventKind::ProviderStatus {
491            message: "Copilot assistant turn started".to_string(),
492            data: Some(data),
493        }),
494        "assistant.turn_end" => events.push(LogEventKind::ProviderStatus {
495            message: "Copilot assistant turn ended".to_string(),
496            data: Some(data),
497        }),
498        "assistant.message" => {
499            let message_id = data
500                .get("messageId")
501                .and_then(|value| value.as_str())
502                .map(str::to_string);
503            let content = data
504                .get("content")
505                .and_then(|value| value.as_str())
506                .unwrap_or_default()
507                .to_string();
508            if !content.is_empty() {
509                events.push(LogEventKind::AssistantMessage {
510                    content,
511                    message_id: message_id.clone(),
512                });
513            }
514            if let Some(tool_requests) = data.get("toolRequests").and_then(|value| value.as_array())
515            {
516                for request in tool_requests {
517                    let name = request
518                        .get("name")
519                        .and_then(|value| value.as_str())
520                        .unwrap_or_default();
521                    events.push(LogEventKind::ToolCall {
522                        tool_kind: Some(tool_kind_from_name(name)),
523                        tool_name: name.to_string(),
524                        tool_id: request
525                            .get("toolCallId")
526                            .and_then(|value| value.as_str())
527                            .map(str::to_string),
528                        input: request.get("arguments").cloned(),
529                    });
530                }
531            }
532        }
533        "assistant.reasoning" => {
534            let content = data
535                .get("content")
536                .and_then(|value| value.as_str())
537                .unwrap_or_default()
538                .to_string();
539            if !content.is_empty() {
540                events.push(LogEventKind::Reasoning {
541                    content,
542                    message_id: data
543                        .get("reasoningId")
544                        .and_then(|value| value.as_str())
545                        .map(str::to_string),
546                });
547            }
548        }
549        "tool.execution_start" => {
550            let name = data
551                .get("toolName")
552                .and_then(|value| value.as_str())
553                .unwrap_or_default();
554            events.push(LogEventKind::ToolCall {
555                tool_kind: Some(tool_kind_from_name(name)),
556                tool_name: name.to_string(),
557                tool_id: data
558                    .get("toolCallId")
559                    .and_then(|value| value.as_str())
560                    .map(str::to_string),
561                input: data.get("arguments").cloned(),
562            });
563        }
564        "tool.execution_complete" => {
565            let name = data.get("toolName").and_then(|value| value.as_str());
566            events.push(LogEventKind::ToolResult {
567                tool_kind: name.map(tool_kind_from_name),
568                tool_name: name.map(str::to_string),
569                tool_id: data
570                    .get("toolCallId")
571                    .and_then(|value| value.as_str())
572                    .map(str::to_string),
573                success: data.get("success").and_then(|value| value.as_bool()),
574                output: data
575                    .get("result")
576                    .and_then(|value| value.get("content"))
577                    .and_then(|value| value.as_str())
578                    .map(str::to_string),
579                error: data
580                    .get("result")
581                    .and_then(|value| value.get("error"))
582                    .and_then(|value| value.as_str())
583                    .map(str::to_string),
584                data: Some(data),
585            });
586        }
587        _ => events.push(LogEventKind::ProviderStatus {
588            message: format!("Copilot event: {event_type}"),
589            data: Some(data),
590        }),
591    }
592
593    Some(ParsedCopilotEvent {
594        provider_session_id,
595        model,
596        workspace_path,
597        events,
598        parse_failed: false,
599    })
600}
601
602fn copilot_session_state_dir() -> PathBuf {
603    session_state_dir()
604}
605
606fn read_copilot_workspace_path(session_dir: &Path) -> Option<String> {
607    let metadata_path = session_dir.join("vscode.metadata.json");
608    if let Ok(content) = std::fs::read_to_string(&metadata_path)
609        && let Ok(value) = serde_json::from_str::<serde_json::Value>(&content)
610    {
611        if let Some(path) = value
612            .get("cwd")
613            .or_else(|| value.get("workspacePath"))
614            .or_else(|| value.get("gitRoot"))
615            .and_then(|value| value.as_str())
616        {
617            return Some(path.to_string());
618        }
619    }
620    let workspace_yaml = session_dir.join("workspace.yaml");
621    if let Ok(content) = std::fs::read_to_string(workspace_yaml) {
622        for line in content.lines() {
623            let trimmed = line.trim();
624            if let Some(rest) = trimmed
625                .strip_prefix("cwd:")
626                .or_else(|| trimmed.strip_prefix("workspace:"))
627                .or_else(|| trimmed.strip_prefix("path:"))
628            {
629                return Some(rest.trim().trim_matches('"').to_string());
630            }
631        }
632    }
633    None
634}
635
636fn copilot_session_matches_workspace(session_dir: &Path, workspace: &str) -> bool {
637    if let Some(candidate) = read_copilot_workspace_path(session_dir) {
638        return candidate == workspace;
639    }
640
641    let events_path = session_dir.join("events.jsonl");
642    let file = match std::fs::File::open(events_path) {
643        Ok(file) => file,
644        Err(_) => return false,
645    };
646    let reader = BufReader::new(file);
647    for line in reader.lines().map_while(Result::ok).take(8) {
648        let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
649            continue;
650        };
651        let Some(data) = value.get("data") else {
652            continue;
653        };
654        let candidate = data
655            .get("context")
656            .and_then(|context| context.get("cwd").or_else(|| context.get("gitRoot")))
657            .and_then(|value| value.as_str());
658        if candidate == Some(workspace) {
659            return true;
660        }
661    }
662    false
663}
664
665fn system_time_from_utc(value: chrono::DateTime<chrono::Utc>) -> std::time::SystemTime {
666    std::time::SystemTime::UNIX_EPOCH
667        + std::time::Duration::from_secs(value.timestamp().max(0) as u64)
668}
669
670#[async_trait]
671impl Agent for Copilot {
672    fn name(&self) -> &str {
673        "copilot"
674    }
675
676    fn default_model() -> &'static str {
677        DEFAULT_MODEL
678    }
679
680    fn model_for_size(size: ModelSize) -> &'static str {
681        match size {
682            ModelSize::Small => "claude-haiku-4.5",
683            ModelSize::Medium => "claude-sonnet-4.6",
684            ModelSize::Large => "claude-opus-4.6",
685        }
686    }
687
688    fn available_models() -> &'static [&'static str] {
689        AVAILABLE_MODELS
690    }
691
692    crate::providers::common::impl_common_agent_setters!();
693
694    fn set_skip_permissions(&mut self, skip: bool) {
695        self.common.skip_permissions = skip;
696    }
697
698    crate::providers::common::impl_as_any!();
699
700    async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
701        self.execute(false, prompt).await
702    }
703
704    async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
705        self.execute(true, prompt).await?;
706        Ok(())
707    }
708
709    async fn run_resume(&self, session_id: Option<&str>, last: bool) -> Result<()> {
710        let mut args = if let Some(session_id) = session_id {
711            vec!["--resume".to_string(), session_id.to_string()]
712        } else if last {
713            vec!["--continue".to_string()]
714        } else {
715            vec!["--resume".to_string()]
716        };
717
718        if self.common.skip_permissions {
719            args.push("--allow-all".to_string());
720        }
721
722        if !self.common.model.is_empty() {
723            args.extend(["--model".to_string(), self.common.model.clone()]);
724        }
725
726        for dir in &self.common.add_dirs {
727            args.extend(["--add-dir".to_string(), dir.clone()]);
728        }
729
730        let mut cmd = self.make_command(args);
731        CommonAgentState::run_interactive_command_with_hook(
732            &mut cmd,
733            "Copilot",
734            self.common.on_spawn_hook.as_ref(),
735        )
736        .await
737    }
738
739    async fn cleanup(&self) -> Result<()> {
740        log::debug!("Cleaning up Copilot agent resources");
741        let base = self.common.get_base_path();
742        let instructions_file = base.join(".github/instructions/agent/agent.instructions.md");
743
744        if instructions_file.exists() {
745            fs::remove_file(&instructions_file).await?;
746        }
747
748        // Clean up empty directories
749        let agent_dir = base.join(".github/instructions/agent");
750        if agent_dir.exists()
751            && fs::read_dir(&agent_dir)
752                .await?
753                .next_entry()
754                .await?
755                .is_none()
756        {
757            fs::remove_dir(&agent_dir).await?;
758        }
759
760        let instructions_dir = base.join(".github/instructions");
761        if instructions_dir.exists()
762            && fs::read_dir(&instructions_dir)
763                .await?
764                .next_entry()
765                .await?
766                .is_none()
767        {
768            fs::remove_dir(&instructions_dir).await?;
769        }
770
771        let github_dir = base.join(".github");
772        if github_dir.exists()
773            && fs::read_dir(&github_dir)
774                .await?
775                .next_entry()
776                .await?
777                .is_none()
778        {
779            fs::remove_dir(&github_dir).await?;
780        }
781
782        Ok(())
783    }
784}