Skip to main content

coding_agent_search/pages/
deploy_github.rs

1//! GitHub Pages deployment module.
2//!
3//! Deploys encrypted archives to GitHub Pages using the gh CLI.
4//! Creates a repository, pushes to gh-pages branch, and enables Pages.
5
6use anyhow::{Context, Result, bail};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::thread;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13/// Maximum number of retry attempts for network operations
14const MAX_RETRIES: u32 = 3;
15
16/// Base delay for exponential backoff (milliseconds)
17const BASE_DELAY_MS: u64 = 1000;
18
19/// Maximum site size for GitHub Pages (1 GB)
20const MAX_SITE_SIZE_BYTES: u64 = 1024 * 1024 * 1024;
21
22/// Warning threshold for file size (50 MiB)
23const FILE_SIZE_WARNING_BYTES: u64 = 50 * 1024 * 1024;
24
25/// Maximum file size for GitHub (100 MiB)
26const MAX_FILE_SIZE_BYTES: u64 = 100 * 1024 * 1024;
27
28/// Prerequisites for GitHub Pages deployment
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Prerequisites {
31    /// gh CLI version if installed
32    pub gh_version: Option<String>,
33    /// Whether gh CLI is authenticated
34    pub gh_authenticated: bool,
35    /// GitHub username if authenticated
36    pub gh_username: Option<String>,
37    /// Git version if installed
38    pub git_version: Option<String>,
39    /// Available disk space in MB
40    pub disk_space_mb: u64,
41    /// Estimated bundle size in MB
42    pub estimated_size_mb: u64,
43}
44
45impl Prerequisites {
46    /// Check if all prerequisites are met
47    pub fn is_ready(&self) -> bool {
48        self.gh_version.is_some() && self.gh_authenticated && self.git_version.is_some()
49    }
50
51    /// Get a list of missing prerequisites
52    pub fn missing(&self) -> Vec<&'static str> {
53        let mut missing = Vec::new();
54        if self.gh_version.is_none() {
55            missing.push("gh CLI not installed (install from https://cli.github.com)");
56        }
57        if !self.gh_authenticated {
58            missing.push("gh CLI not authenticated (run 'gh auth login')");
59        }
60        if self.git_version.is_none() {
61            missing.push("git not installed");
62        }
63        missing
64    }
65}
66
67/// File size check result
68#[derive(Debug, Clone)]
69pub struct SizeCheck {
70    /// Total size of all files in bytes
71    pub total_bytes: u64,
72    /// Number of files
73    pub file_count: usize,
74    /// Files exceeding warning threshold
75    pub large_files: Vec<(String, u64)>,
76    /// Whether total size exceeds limit
77    pub exceeds_limit: bool,
78    /// Whether any file exceeds max file size
79    pub has_oversized_files: bool,
80}
81
82/// Deployment result
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct DeployResult {
85    /// Repository URL
86    pub repo_url: String,
87    /// Pages URL (where the site is accessible)
88    pub pages_url: String,
89    /// Whether Pages was successfully enabled
90    pub pages_enabled: bool,
91    /// Deployment commit SHA
92    pub commit_sha: String,
93}
94
95/// GitHub Pages deployer
96pub struct GitHubDeployer {
97    /// Repository name
98    repo_name: String,
99    /// Repository description
100    description: String,
101    /// Whether to make the repo public
102    public: bool,
103    /// Whether to force overwrite existing repo
104    force: bool,
105}
106
107impl Default for GitHubDeployer {
108    fn default() -> Self {
109        Self::new("cass-archive")
110    }
111}
112
113impl GitHubDeployer {
114    /// Create a new deployer with the given repository name
115    pub fn new(repo_name: impl Into<String>) -> Self {
116        Self {
117            repo_name: repo_name.into(),
118            description: "Encrypted cass archive".to_string(),
119            public: true,
120            force: false,
121        }
122    }
123
124    /// Set the repository description
125    pub fn description(mut self, desc: impl Into<String>) -> Self {
126        self.description = desc.into();
127        self
128    }
129
130    /// Set whether the repository should be public
131    pub fn public(mut self, public: bool) -> Self {
132        self.public = public;
133        self
134    }
135
136    /// Set whether to force overwrite existing repository
137    pub fn force(mut self, force: bool) -> Self {
138        self.force = force;
139        self
140    }
141
142    /// Check deployment prerequisites
143    pub fn check_prerequisites(&self) -> Result<Prerequisites> {
144        // Check gh CLI
145        let gh_version = get_gh_version();
146        let (gh_authenticated, gh_username) = if gh_version.is_some() {
147            check_gh_auth()
148        } else {
149            (false, None)
150        };
151
152        // Check git
153        let git_version = get_git_version();
154
155        // Check disk space (simplified - just get available space)
156        let disk_space_mb = get_available_space_mb().unwrap_or(0);
157
158        Ok(Prerequisites {
159            gh_version,
160            gh_authenticated,
161            gh_username,
162            git_version,
163            disk_space_mb,
164            estimated_size_mb: 0, // Set by caller if known
165        })
166    }
167
168    /// Check size of bundle directory
169    pub fn check_size(&self, bundle_dir: &Path) -> Result<SizeCheck> {
170        let bundle_dir = super::resolve_site_dir(bundle_dir)?;
171        let mut total_bytes = 0u64;
172        let mut file_count = 0usize;
173        let mut large_files = Vec::new();
174        let mut has_oversized = false;
175
176        visit_files(&bundle_dir, &mut |path, size| {
177            total_bytes += size;
178            file_count += 1;
179
180            if size > MAX_FILE_SIZE_BYTES {
181                has_oversized = true;
182                let rel_path = path
183                    .strip_prefix(bundle_dir.as_path())
184                    .unwrap_or(path)
185                    .to_string_lossy()
186                    .to_string();
187                large_files.push((rel_path, size));
188            } else if size > FILE_SIZE_WARNING_BYTES {
189                let rel_path = path
190                    .strip_prefix(bundle_dir.as_path())
191                    .unwrap_or(path)
192                    .to_string_lossy()
193                    .to_string();
194                large_files.push((rel_path, size));
195            }
196        })?;
197
198        Ok(SizeCheck {
199            total_bytes,
200            file_count,
201            large_files,
202            exceeds_limit: total_bytes > MAX_SITE_SIZE_BYTES,
203            has_oversized_files: has_oversized,
204        })
205    }
206
207    /// Deploy bundle to GitHub Pages
208    ///
209    /// # Arguments
210    /// * `bundle_dir` - Path to the site/ directory from bundle builder
211    /// * `progress` - Progress callback (phase, message)
212    pub fn deploy<P: AsRef<Path>>(
213        &self,
214        bundle_dir: P,
215        mut progress: impl FnMut(&str, &str),
216    ) -> Result<DeployResult> {
217        let bundle_dir = super::resolve_site_dir(bundle_dir.as_ref())?;
218
219        // Step 1: Check prerequisites
220        progress("prereq", "Checking prerequisites...");
221        let prereqs = self.check_prerequisites()?;
222
223        if !prereqs.is_ready() {
224            let missing = prereqs.missing();
225            bail!("Prerequisites not met:\n{}", missing.join("\n"));
226        }
227
228        let username = prereqs
229            .gh_username
230            .as_ref()
231            .context("Could not determine GitHub username")?;
232
233        // Step 2: Check size
234        progress("size", "Checking bundle size...");
235        let size_check = self.check_size(&bundle_dir)?;
236
237        if size_check.exceeds_limit {
238            bail!(
239                "Bundle size ({:.1} MB) exceeds GitHub Pages limit ({:.1} MB)",
240                size_check.total_bytes as f64 / (1024.0 * 1024.0),
241                MAX_SITE_SIZE_BYTES as f64 / (1024.0 * 1024.0)
242            );
243        }
244
245        if size_check.has_oversized_files {
246            let oversized: Vec<_> = size_check
247                .large_files
248                .iter()
249                .filter(|(_, size)| *size > MAX_FILE_SIZE_BYTES)
250                .map(|(path, size)| {
251                    format!("  {} ({:.1} MB)", path, *size as f64 / (1024.0 * 1024.0))
252                })
253                .collect();
254            bail!(
255                "Files exceed GitHub's 100 MiB limit:\n{}",
256                oversized.join("\n")
257            );
258        }
259
260        // Warn about large files (above 50 MiB but under 100 MiB)
261        let warning_files: Vec<_> = size_check
262            .large_files
263            .iter()
264            .filter(|(_, size)| *size <= MAX_FILE_SIZE_BYTES && *size > FILE_SIZE_WARNING_BYTES)
265            .collect();
266        if !warning_files.is_empty() {
267            let warnings: Vec<_> = warning_files
268                .iter()
269                .map(|(path, size)| {
270                    format!("{} ({:.1} MB)", path, *size as f64 / (1024.0 * 1024.0))
271                })
272                .collect();
273            progress(
274                "warning",
275                &format!(
276                    "Large files detected (may slow deployment): {}",
277                    warnings.join(", ")
278                ),
279            );
280        }
281
282        // Step 3: Create or verify repository
283        progress("repo", "Creating repository...");
284        let repo_url = self.ensure_repository(username)?;
285
286        // Step 4: Clone to temp directory
287        progress("clone", "Cloning repository...");
288        let temp_dir = create_temp_dir()?;
289        clone_repo(&repo_url, temp_dir.path())?;
290
291        // Step 5: Copy bundle contents
292        progress("copy", "Copying bundle files...");
293        let work_dir = temp_dir.path().join(&self.repo_name);
294        copy_bundle_to_repo(&bundle_dir, &work_dir)?;
295        configure_git_identity(&work_dir, username)?;
296
297        // Step 6: Create orphan branch and push
298        progress("push", "Pushing to gh-pages branch...");
299        let commit_sha = push_gh_pages(&work_dir)?;
300
301        // Step 7: Enable GitHub Pages
302        progress("pages", "Enabling GitHub Pages...");
303        let pages_enabled = enable_github_pages(username, &self.repo_name);
304
305        // Construct URLs
306        let pages_url = format!("https://{}.github.io/{}", username, self.repo_name);
307
308        progress("complete", "Deployment complete!");
309
310        Ok(DeployResult {
311            repo_url,
312            pages_url,
313            pages_enabled,
314            commit_sha,
315        })
316    }
317
318    /// Ensure repository exists, create if needed
319    fn ensure_repository(&self, username: &str) -> Result<String> {
320        let repo_full_name = format!("{}/{}", username, self.repo_name);
321
322        // Check if repo exists
323        let exists = check_repo_exists(&repo_full_name);
324
325        if exists && !self.force {
326            bail!(
327                "Repository {} already exists. Use --force to overwrite.",
328                repo_full_name
329            );
330        }
331
332        if !exists {
333            // Create repository
334            let visibility = if self.public { "--public" } else { "--private" };
335            let output = Command::new("gh")
336                .args([
337                    "repo",
338                    "create",
339                    &self.repo_name,
340                    visibility,
341                    "--description",
342                    &self.description,
343                ])
344                .output()
345                .context("Failed to run gh repo create")?;
346
347            if !output.status.success() {
348                let stderr = String::from_utf8_lossy(&output.stderr);
349                bail!("Failed to create repository: {}", stderr);
350            }
351        }
352
353        Ok(format!("https://github.com/{}", repo_full_name))
354    }
355}
356
357// Helper functions
358
359struct TempDeployDir {
360    path: PathBuf,
361}
362
363impl TempDeployDir {
364    fn path(&self) -> &Path {
365        &self.path
366    }
367}
368
369impl Drop for TempDeployDir {
370    fn drop(&mut self) {
371        if deploy_staging_path_is_real_dir(&self.path).unwrap_or(false) {
372            let _ = std::fs::remove_dir_all(&self.path);
373        }
374    }
375}
376
377/// Create a temporary directory
378fn create_temp_dir() -> Result<TempDeployDir> {
379    let temp_base = std::env::temp_dir();
380    let pid = std::process::id();
381    for attempt in 0..100 {
382        let timestamp = SystemTime::now()
383            .duration_since(UNIX_EPOCH)
384            .map(|d| d.as_nanos())
385            .unwrap_or(0);
386        let dir_name = format!("cass-deploy-{pid}-{timestamp}-{attempt}");
387        let temp_dir = temp_base.join(dir_name);
388        match std::fs::create_dir(&temp_dir) {
389            Ok(()) => return Ok(TempDeployDir { path: temp_dir }),
390            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
391            Err(err) => {
392                return Err(err).with_context(|| {
393                    format!(
394                        "Failed creating GitHub deploy staging directory {}",
395                        temp_dir.display()
396                    )
397                });
398            }
399        }
400    }
401    bail!(
402        "failed to allocate unique GitHub deploy staging directory under {}",
403        temp_base.display()
404    )
405}
406
407fn deploy_staging_path_is_real_dir(path: &Path) -> Result<bool> {
408    match std::fs::symlink_metadata(path) {
409        Ok(metadata) => {
410            let file_type = metadata.file_type();
411            Ok(file_type.is_dir() && !file_type.is_symlink())
412        }
413        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
414        Err(err) => Err(err).with_context(|| {
415            format!(
416                "Failed inspecting GitHub deploy staging directory before cleanup: {}",
417                path.display()
418            )
419        }),
420    }
421}
422
423/// Get gh CLI version
424fn get_gh_version() -> Option<String> {
425    Command::new("gh")
426        .arg("--version")
427        .output()
428        .ok()
429        .and_then(|out| {
430            if out.status.success() {
431                let stdout = String::from_utf8_lossy(&out.stdout);
432                stdout.lines().next().map(|s| s.to_string())
433            } else {
434                None
435            }
436        })
437}
438
439/// Check gh authentication status
440fn check_gh_auth() -> (bool, Option<String>) {
441    let output = Command::new("gh").args(["auth", "status"]).output();
442
443    match output {
444        Ok(out) if out.status.success() => {
445            let stdout = String::from_utf8_lossy(&out.stdout);
446            let stderr = String::from_utf8_lossy(&out.stderr);
447            let combined = format!("{}{}", stdout, stderr);
448
449            // Parse username from output like "Logged in to github.com as username"
450            let username = combined
451                .lines()
452                .find(|line| line.contains("Logged in to"))
453                .and_then(|line| line.split(" as ").nth(1))
454                .map(|s| s.split_whitespace().next().unwrap_or(s).to_string());
455
456            (true, username)
457        }
458        _ => (false, None),
459    }
460}
461
462/// Get git version
463fn get_git_version() -> Option<String> {
464    Command::new("git")
465        .arg("--version")
466        .output()
467        .ok()
468        .and_then(|out| {
469            if out.status.success() {
470                let stdout = String::from_utf8_lossy(&out.stdout);
471                Some(stdout.trim().to_string())
472            } else {
473                None
474            }
475        })
476}
477
478/// Get available disk space in MB
479fn get_available_space_mb() -> Option<u64> {
480    // Use df on Unix, simplified approach
481    #[cfg(unix)]
482    {
483        Command::new("df")
484            .args(["-m", "."])
485            .output()
486            .ok()
487            .and_then(|out| {
488                if out.status.success() {
489                    let stdout = String::from_utf8_lossy(&out.stdout);
490                    // Parse second line, fourth column (available)
491                    stdout
492                        .lines()
493                        .nth(1)
494                        .and_then(|line| line.split_whitespace().nth(3))
495                        .and_then(|s| s.parse().ok())
496                } else {
497                    None
498                }
499            })
500    }
501    #[cfg(not(unix))]
502    {
503        None
504    }
505}
506
507/// Check if repository exists
508fn check_repo_exists(repo_full_name: &str) -> bool {
509    Command::new("gh")
510        .args(["repo", "view", repo_full_name])
511        .output()
512        .map(|out| out.status.success())
513        .unwrap_or(false)
514}
515
516/// Retry a fallible operation with exponential backoff.
517///
518/// Retries the operation up to `MAX_RETRIES` times with exponentially
519/// increasing delays between attempts. Useful for network operations
520/// that may transiently fail.
521fn retry_with_backoff<T, F>(operation_name: &str, mut f: F) -> Result<T>
522where
523    F: FnMut() -> Result<T>,
524{
525    let mut last_error = None;
526
527    for attempt in 0..MAX_RETRIES {
528        match f() {
529            Ok(result) => return Ok(result),
530            Err(e) => {
531                last_error = Some(e);
532                if attempt + 1 < MAX_RETRIES {
533                    let delay_ms = BASE_DELAY_MS * (1 << attempt); // 1s, 2s, 4s
534                    eprintln!(
535                        "[{}] Attempt {} failed, retrying in {}ms...",
536                        operation_name,
537                        attempt + 1,
538                        delay_ms
539                    );
540                    thread::sleep(Duration::from_millis(delay_ms));
541                }
542            }
543        }
544    }
545
546    Err(last_error.unwrap_or_else(|| {
547        anyhow::anyhow!("{} failed after {} attempts", operation_name, MAX_RETRIES)
548    }))
549}
550
551/// Clone repository to directory with retry logic
552fn clone_repo(repo_url: &str, dest: &Path) -> Result<()> {
553    retry_with_backoff("git clone", || {
554        let output = Command::new("git")
555            .args(["clone", repo_url])
556            .current_dir(dest)
557            .output()
558            .context("Failed to run git clone")?;
559
560        if !output.status.success() {
561            let stderr = String::from_utf8_lossy(&output.stderr);
562            // Allow empty repo warning
563            if !stderr.contains("empty repository") {
564                bail!("Failed to clone repository: {}", stderr);
565            }
566        }
567
568        Ok(())
569    })
570}
571
572/// Copy bundle contents to repository directory
573fn copy_bundle_to_repo(bundle_dir: &Path, repo_dir: &Path) -> Result<()> {
574    let bundle_dir = super::resolve_site_dir(bundle_dir)?;
575
576    ensure_deploy_staging_dir(repo_dir)?;
577
578    // Clear existing files (except .git)
579    for entry in std::fs::read_dir(repo_dir)? {
580        let entry = entry?;
581        let path = entry.path();
582        if path.file_name().map(|n| n != ".git").unwrap_or(true) {
583            remove_repo_deploy_entry(&path)?;
584        }
585    }
586
587    // Copy bundle files
588    copy_dir_recursive(&bundle_dir, repo_dir)?;
589
590    // Ensure .nojekyll exists
591    let nojekyll = repo_dir.join(".nojekyll");
592    if !nojekyll.exists() {
593        std::fs::write(&nojekyll, "")?;
594    }
595
596    Ok(())
597}
598
599/// Copy directory recursively
600fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
601    let canonical_base = src.canonicalize().with_context(|| {
602        format!(
603            "Failed to resolve deployment source root {} before copying",
604            src.display()
605        )
606    })?;
607    copy_dir_recursive_inner(src, dst, &canonical_base)
608}
609
610fn copy_dir_recursive_inner(src: &Path, dst: &Path, canonical_base: &Path) -> Result<()> {
611    ensure_deploy_staging_dir(dst)?;
612
613    for entry in std::fs::read_dir(src)? {
614        let entry = entry?;
615        let file_name = entry.file_name();
616        if file_name == std::ffi::OsStr::new(".git") {
617            continue;
618        }
619        let src_path = entry.path();
620        let dst_path = dst.join(file_name);
621        let metadata = std::fs::symlink_metadata(&src_path)?;
622        let file_type = metadata.file_type();
623
624        if file_type.is_symlink() {
625            let canonical_target = src_path.canonicalize().with_context(|| {
626                format!(
627                    "Failed to resolve symlinked deploy entry {}",
628                    src_path.display()
629                )
630            })?;
631            if !canonical_target.starts_with(canonical_base) {
632                bail!(
633                    "Refusing to deploy symlinked site entry outside deployment root: {}",
634                    src_path.display()
635                );
636            }
637
638            let target_meta = std::fs::metadata(&src_path).with_context(|| {
639                format!(
640                    "Failed to inspect symlink target for deploy entry {}",
641                    src_path.display()
642                )
643            })?;
644            if !target_meta.is_file() {
645                bail!(
646                    "Refusing to deploy symlinked site entry that does not point to a regular file: {}",
647                    src_path.display()
648                );
649            }
650
651            ensure_deploy_file_destination(&dst_path)?;
652            std::fs::copy(&canonical_target, &dst_path).with_context(|| {
653                format!(
654                    "Failed copying symlink target {} to {} during deploy staging",
655                    canonical_target.display(),
656                    dst_path.display()
657                )
658            })?;
659            continue;
660        }
661
662        if file_type.is_dir() {
663            copy_dir_recursive_inner(&src_path, &dst_path, canonical_base)?;
664        } else if file_type.is_file() {
665            ensure_deploy_file_destination(&dst_path)?;
666            std::fs::copy(&src_path, &dst_path)?;
667        }
668    }
669
670    Ok(())
671}
672
673fn ensure_deploy_staging_dir(path: &Path) -> Result<()> {
674    match std::fs::symlink_metadata(path) {
675        Ok(metadata) => {
676            let file_type = metadata.file_type();
677            if file_type.is_symlink() {
678                bail!(
679                    "Refusing to use deploy staging directory through symlink: {}",
680                    path.display()
681                );
682            }
683            if !file_type.is_dir() {
684                bail!(
685                    "Refusing to use deploy staging path because it is not a directory: {}",
686                    path.display()
687                );
688            }
689            Ok(())
690        }
691        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
692            std::fs::create_dir_all(path)?;
693            match std::fs::symlink_metadata(path) {
694                Ok(metadata)
695                    if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() =>
696                {
697                    Ok(())
698                }
699                Ok(_) => bail!(
700                    "Refusing to use deploy staging path after create because it is not a real directory: {}",
701                    path.display()
702                ),
703                Err(err) => Err(err).with_context(|| {
704                    format!(
705                        "Failed inspecting deploy staging directory after create: {}",
706                        path.display()
707                    )
708                }),
709            }
710        }
711        Err(err) => Err(err).with_context(|| {
712            format!(
713                "Failed inspecting deploy staging directory before copy: {}",
714                path.display()
715            )
716        }),
717    }
718}
719
720fn ensure_deploy_file_destination(path: &Path) -> Result<()> {
721    match std::fs::symlink_metadata(path) {
722        Ok(metadata) => {
723            let file_type = metadata.file_type();
724            if file_type.is_symlink() {
725                bail!(
726                    "Refusing to write deploy file through symlink: {}",
727                    path.display()
728                );
729            }
730            if !file_type.is_file() {
731                bail!(
732                    "Refusing to write deploy file over non-file path: {}",
733                    path.display()
734                );
735            }
736            Ok(())
737        }
738        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
739        Err(err) => Err(err).with_context(|| {
740            format!(
741                "Failed inspecting deploy file destination before copy: {}",
742                path.display()
743            )
744        }),
745    }
746}
747
748fn remove_repo_deploy_entry(path: &Path) -> Result<()> {
749    let metadata = std::fs::symlink_metadata(path).with_context(|| {
750        format!(
751            "Failed inspecting existing GitHub deploy repository entry {}",
752            path.display()
753        )
754    })?;
755    let file_type = metadata.file_type();
756    if file_type.is_dir() && !file_type.is_symlink() {
757        std::fs::remove_dir_all(path).with_context(|| {
758            format!(
759                "Failed removing existing GitHub deploy directory {}",
760                path.display()
761            )
762        })
763    } else {
764        std::fs::remove_file(path).with_context(|| {
765            format!(
766                "Failed removing existing GitHub deploy file {}",
767                path.display()
768            )
769        })
770    }
771}
772
773/// Configure a local git identity for commits in the temporary deployment clone.
774fn configure_git_identity(repo_dir: &Path, username: &str) -> Result<()> {
775    let email = format!("{username}@users.noreply.github.com");
776
777    for (key, value) in [("user.name", username), ("user.email", email.as_str())] {
778        let output = Command::new("git")
779            .args(["config", key, value])
780            .current_dir(repo_dir)
781            .output()
782            .with_context(|| format!("Failed to set git {key}"))?;
783
784        if !output.status.success() {
785            let stderr = String::from_utf8_lossy(&output.stderr);
786            bail!("Failed to set git {key}: {stderr}");
787        }
788    }
789
790    Ok(())
791}
792
793/// Push to gh-pages branch as orphan
794fn push_gh_pages(repo_dir: &Path) -> Result<String> {
795    // Create orphan branch
796    let output = Command::new("git")
797        .args(["checkout", "--orphan", "gh-pages"])
798        .current_dir(repo_dir)
799        .output()
800        .context("Failed to create orphan branch")?;
801
802    if !output.status.success() {
803        let stderr = String::from_utf8_lossy(&output.stderr);
804        bail!("Failed to create gh-pages branch: {}", stderr);
805    }
806
807    // Add all files
808    let output = Command::new("git")
809        .args(["add", "-A"])
810        .current_dir(repo_dir)
811        .output()
812        .context("Failed to git add")?;
813
814    if !output.status.success() {
815        let stderr = String::from_utf8_lossy(&output.stderr);
816        bail!("Failed to add files: {}", stderr);
817    }
818
819    // Commit
820    let output = Command::new("git")
821        .args(["commit", "-m", "Deploy cass archive"])
822        .current_dir(repo_dir)
823        .output()
824        .context("Failed to git commit")?;
825
826    if !output.status.success() {
827        let stderr = String::from_utf8_lossy(&output.stderr);
828        bail!("Failed to commit: {}", stderr);
829    }
830
831    // Get commit SHA
832    let sha_output = Command::new("git")
833        .args(["rev-parse", "HEAD"])
834        .current_dir(repo_dir)
835        .output()
836        .context("Failed to get commit SHA")?;
837
838    let commit_sha = String::from_utf8_lossy(&sha_output.stdout)
839        .trim()
840        .to_string();
841
842    // Force push to origin with retry for network errors
843    let repo_dir_owned = repo_dir.to_owned();
844    retry_with_backoff("git push", move || {
845        let output = Command::new("git")
846            .args(["push", "-f", "origin", "gh-pages"])
847            .current_dir(&repo_dir_owned)
848            .output()
849            .context("Failed to git push")?;
850
851        if !output.status.success() {
852            let stderr = String::from_utf8_lossy(&output.stderr);
853            bail!("Failed to push: {}", stderr);
854        }
855
856        Ok(())
857    })?;
858
859    Ok(commit_sha)
860}
861
862/// Enable GitHub Pages via API with retry logic
863fn enable_github_pages(username: &str, repo_name: &str) -> bool {
864    let api_path = format!("repos/{}/{}/pages", username, repo_name);
865
866    // Try with retry - may fail if already enabled, which is okay
867    let result = retry_with_backoff("enable Pages", || {
868        let output = Command::new("gh")
869            .args([
870                "api",
871                &api_path,
872                "-X",
873                "POST",
874                "-f",
875                "source[branch]=gh-pages",
876                "-f",
877                "source[path]=/",
878            ])
879            .output()
880            .context("Failed to call GitHub API")?;
881
882        if output.status.success() {
883            Ok(true)
884        } else {
885            let stderr = String::from_utf8_lossy(&output.stderr);
886            // If Pages is already enabled, that's fine
887            if stderr.contains("already exists") || stderr.contains("409") {
888                Ok(true)
889            } else {
890                bail!("Failed to enable Pages: {}", stderr);
891            }
892        }
893    });
894
895    result.unwrap_or(false)
896}
897
898/// Visit all files in a directory recursively
899fn visit_files(dir: &Path, f: &mut impl FnMut(&Path, u64)) -> Result<()> {
900    let canonical_base = dir.canonicalize().with_context(|| {
901        format!(
902            "Failed to resolve deployment source root {} before sizing",
903            dir.display()
904        )
905    })?;
906    visit_files_inner(dir, &canonical_base, f)
907}
908
909fn visit_files_inner(
910    dir: &Path,
911    canonical_base: &Path,
912    f: &mut impl FnMut(&Path, u64),
913) -> Result<()> {
914    for entry in std::fs::read_dir(dir)? {
915        let entry = entry?;
916        let path = entry.path();
917        let metadata = std::fs::symlink_metadata(&path)?;
918        let file_type = metadata.file_type();
919
920        if file_type.is_symlink() {
921            let canonical_target = path.canonicalize().with_context(|| {
922                format!(
923                    "Failed to resolve symlinked deploy entry {}",
924                    path.display()
925                )
926            })?;
927            if !canonical_target.starts_with(canonical_base) {
928                bail!(
929                    "Refusing to deploy symlinked site entry outside deployment root: {}",
930                    path.display()
931                );
932            }
933
934            let target_meta = std::fs::metadata(&path).with_context(|| {
935                format!(
936                    "Failed to inspect symlink target for deploy entry {}",
937                    path.display()
938                )
939            })?;
940            if !target_meta.is_file() {
941                bail!(
942                    "Refusing to deploy symlinked site entry that does not point to a regular file: {}",
943                    path.display()
944                );
945            }
946
947            f(&path, target_meta.len());
948            continue;
949        }
950
951        if file_type.is_dir() {
952            visit_files_inner(&path, canonical_base, f)?;
953        } else if file_type.is_file() {
954            f(&path, metadata.len());
955        }
956    }
957    Ok(())
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963
964    #[test]
965    fn test_prerequisites_is_ready() {
966        let prereqs = Prerequisites {
967            gh_version: Some("gh version 2.0.0".to_string()),
968            gh_authenticated: true,
969            gh_username: Some("testuser".to_string()),
970            git_version: Some("git version 2.30.0".to_string()),
971            disk_space_mb: 1000,
972            estimated_size_mb: 100,
973        };
974
975        assert!(prereqs.is_ready());
976        assert!(prereqs.missing().is_empty());
977    }
978
979    #[test]
980    fn test_prerequisites_not_ready() {
981        let prereqs = Prerequisites {
982            gh_version: None,
983            gh_authenticated: false,
984            gh_username: None,
985            git_version: None,
986            disk_space_mb: 1000,
987            estimated_size_mb: 100,
988        };
989
990        assert!(!prereqs.is_ready());
991        let missing = prereqs.missing();
992        assert_eq!(missing.len(), 3);
993    }
994
995    #[test]
996    fn test_deployer_builder() {
997        let deployer = GitHubDeployer::new("my-archive")
998            .description("My archive")
999            .public(false)
1000            .force(true);
1001
1002        assert_eq!(deployer.repo_name, "my-archive");
1003        assert_eq!(deployer.description, "My archive");
1004        assert!(!deployer.public);
1005        assert!(deployer.force);
1006    }
1007
1008    #[test]
1009    fn test_size_check() {
1010        use tempfile::TempDir;
1011
1012        let temp = TempDir::new().unwrap();
1013        let file1 = temp.path().join("small.txt");
1014        let file2 = temp.path().join("medium.txt");
1015
1016        std::fs::write(&file1, vec![0u8; 1000]).unwrap();
1017        std::fs::write(&file2, vec![0u8; 10000]).unwrap();
1018
1019        let deployer = GitHubDeployer::default();
1020        let check = deployer.check_size(temp.path()).unwrap();
1021
1022        assert_eq!(check.file_count, 2);
1023        assert_eq!(check.total_bytes, 11000);
1024        assert!(!check.exceeds_limit);
1025        assert!(!check.has_oversized_files);
1026    }
1027
1028    #[test]
1029    fn test_size_check_resolves_bundle_root_without_counting_private_artifacts() {
1030        use tempfile::TempDir;
1031
1032        let temp = TempDir::new().unwrap();
1033        let site_dir = temp.path().join("site");
1034        let private_dir = temp.path().join("private");
1035        std::fs::create_dir_all(&site_dir).unwrap();
1036        std::fs::create_dir_all(&private_dir).unwrap();
1037        std::fs::write(site_dir.join("index.html"), "abcd").unwrap();
1038        std::fs::write(private_dir.join("master-key.json"), "secret").unwrap();
1039
1040        let deployer = GitHubDeployer::default();
1041        let check = deployer.check_size(temp.path()).unwrap();
1042
1043        assert_eq!(check.file_count, 1);
1044        assert_eq!(check.total_bytes, 4);
1045    }
1046
1047    #[test]
1048    #[cfg(unix)]
1049    fn test_size_check_counts_in_tree_symlinked_files() {
1050        use std::os::unix::fs::symlink;
1051        use tempfile::TempDir;
1052
1053        let temp = TempDir::new().unwrap();
1054        let site_dir = temp.path().join("site");
1055        std::fs::create_dir_all(&site_dir).unwrap();
1056        std::fs::write(site_dir.join("root.txt"), "root").unwrap();
1057        symlink("root.txt", site_dir.join("linked-file.txt")).unwrap();
1058
1059        let deployer = GitHubDeployer::default();
1060        let check = deployer.check_size(temp.path()).unwrap();
1061
1062        assert_eq!(check.file_count, 2);
1063        assert_eq!(check.total_bytes, 8);
1064    }
1065
1066    #[test]
1067    fn test_resolve_site_dir_accepts_direct_site_directory() {
1068        use tempfile::TempDir;
1069
1070        let temp = TempDir::new().unwrap();
1071        std::fs::write(temp.path().join("index.html"), "<html></html>").unwrap();
1072
1073        let resolved = super::super::resolve_site_dir(temp.path()).unwrap();
1074        assert_eq!(resolved, temp.path());
1075    }
1076
1077    #[test]
1078    #[cfg(unix)]
1079    fn test_resolve_site_dir_rejects_symlinked_site_directory() {
1080        use std::os::unix::fs::symlink;
1081        use tempfile::TempDir;
1082
1083        let bundle_root = TempDir::new().unwrap();
1084        let outside = TempDir::new().unwrap();
1085        let outside_site = outside.path().join("site");
1086        std::fs::create_dir_all(&outside_site).unwrap();
1087        std::fs::write(outside_site.join("index.html"), "<html></html>").unwrap();
1088        symlink(&outside_site, bundle_root.path().join("site")).unwrap();
1089
1090        let err = super::super::resolve_site_dir(bundle_root.path())
1091            .unwrap_err()
1092            .to_string();
1093        assert!(err.contains("must not be a symlink"));
1094
1095        let direct_err = super::super::resolve_site_dir(&bundle_root.path().join("site"))
1096            .unwrap_err()
1097            .to_string();
1098        assert!(direct_err.contains("must not be a symlink"));
1099    }
1100
1101    #[test]
1102    fn test_copy_dir_recursive() {
1103        use tempfile::TempDir;
1104
1105        let src = TempDir::new().unwrap();
1106        let dst = TempDir::new().unwrap();
1107
1108        // Create source structure
1109        std::fs::create_dir_all(src.path().join("subdir")).unwrap();
1110        std::fs::write(src.path().join("root.txt"), "root").unwrap();
1111        std::fs::write(src.path().join("subdir/nested.txt"), "nested").unwrap();
1112
1113        copy_dir_recursive(src.path(), dst.path()).unwrap();
1114
1115        assert!(dst.path().join("root.txt").exists());
1116        assert!(dst.path().join("subdir/nested.txt").exists());
1117    }
1118
1119    #[test]
1120    fn test_copy_bundle_to_repo_resolves_bundle_root_without_copying_private_artifacts() {
1121        use tempfile::TempDir;
1122
1123        let bundle_root = TempDir::new().unwrap();
1124        let repo_dir = TempDir::new().unwrap();
1125        let site_dir = bundle_root.path().join("site");
1126        let private_dir = bundle_root.path().join("private");
1127        std::fs::create_dir_all(&site_dir).unwrap();
1128        std::fs::create_dir_all(&private_dir).unwrap();
1129        std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1130        std::fs::write(site_dir.join("config.json"), "{}").unwrap();
1131        std::fs::write(private_dir.join("master-key.json"), "{\"secret\":true}").unwrap();
1132
1133        copy_bundle_to_repo(bundle_root.path(), repo_dir.path()).unwrap();
1134
1135        assert!(repo_dir.path().join("index.html").exists());
1136        assert!(repo_dir.path().join("config.json").exists());
1137        assert!(repo_dir.path().join(".nojekyll").exists());
1138        assert!(!repo_dir.path().join("private").exists());
1139        assert!(!repo_dir.path().join("site").exists());
1140    }
1141
1142    #[test]
1143    fn test_copy_bundle_to_repo_ignores_bundle_git_directory_without_modifying_repo_git() {
1144        use tempfile::TempDir;
1145
1146        let bundle_root = TempDir::new().unwrap();
1147        let repo_dir = TempDir::new().unwrap();
1148        let site_dir = bundle_root.path().join("site");
1149        let bundle_git_dir = site_dir.join(".git");
1150        let repo_git_dir = repo_dir.path().join(".git");
1151
1152        std::fs::create_dir_all(&bundle_git_dir).unwrap();
1153        std::fs::create_dir_all(&repo_git_dir).unwrap();
1154        std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1155        std::fs::write(bundle_git_dir.join("config"), "bundle git config").unwrap();
1156        std::fs::write(repo_git_dir.join("config"), "repo git config").unwrap();
1157
1158        copy_bundle_to_repo(bundle_root.path(), repo_dir.path()).unwrap();
1159
1160        assert_eq!(
1161            std::fs::read_to_string(repo_git_dir.join("config")).unwrap(),
1162            "repo git config"
1163        );
1164        assert!(repo_dir.path().join("index.html").exists());
1165    }
1166
1167    #[test]
1168    fn test_temp_deploy_dir_removes_real_staging_dir_on_drop() {
1169        let temp = create_temp_dir().unwrap();
1170        let temp_path = temp.path().to_path_buf();
1171        std::fs::write(temp_path.join("marker.txt"), "temporary clone").unwrap();
1172
1173        drop(temp);
1174
1175        assert!(
1176            !temp_path.exists(),
1177            "GitHub deploy temp clone directory should be removed on drop"
1178        );
1179    }
1180
1181    #[test]
1182    #[cfg(unix)]
1183    fn test_temp_deploy_dir_drop_skips_symlinked_staging_path() {
1184        use std::os::unix::fs::symlink;
1185        use tempfile::TempDir;
1186
1187        let parent = TempDir::new().unwrap();
1188        let outside = TempDir::new().unwrap();
1189        let temp_path = parent.path().join("cass-deploy-link");
1190        let sentinel = outside.path().join("sentinel.txt");
1191        std::fs::write(&sentinel, "keep").unwrap();
1192        symlink(outside.path(), &temp_path).unwrap();
1193
1194        drop(TempDeployDir {
1195            path: temp_path.clone(),
1196        });
1197
1198        assert_eq!(std::fs::read_to_string(&sentinel).unwrap(), "keep");
1199        assert!(
1200            std::fs::symlink_metadata(&temp_path)
1201                .unwrap()
1202                .file_type()
1203                .is_symlink(),
1204            "drop must leave symlinked staging paths untouched"
1205        );
1206    }
1207
1208    #[test]
1209    #[cfg(unix)]
1210    fn test_copy_dir_recursive_materializes_in_tree_symlinked_files() {
1211        use std::os::unix::fs::symlink;
1212        use tempfile::TempDir;
1213
1214        let src = TempDir::new().unwrap();
1215        let dst = TempDir::new().unwrap();
1216
1217        std::fs::write(src.path().join("root.txt"), "root").unwrap();
1218        symlink("root.txt", src.path().join("linked-file.txt")).unwrap();
1219
1220        copy_dir_recursive(src.path(), dst.path()).unwrap();
1221
1222        let linked_metadata =
1223            std::fs::symlink_metadata(dst.path().join("linked-file.txt")).unwrap();
1224        assert!(linked_metadata.file_type().is_file());
1225        assert!(!linked_metadata.file_type().is_symlink());
1226        assert_eq!(
1227            std::fs::read_to_string(dst.path().join("linked-file.txt")).unwrap(),
1228            "root"
1229        );
1230    }
1231
1232    #[test]
1233    #[cfg(unix)]
1234    fn test_copy_dir_recursive_rejects_symlinks_outside_root() {
1235        use std::os::unix::fs::symlink;
1236        use tempfile::TempDir;
1237
1238        let src = TempDir::new().unwrap();
1239        let dst = TempDir::new().unwrap();
1240        let outside = TempDir::new().unwrap();
1241
1242        std::fs::write(src.path().join("root.txt"), "root").unwrap();
1243        std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1244        symlink(
1245            outside.path().join("secret.txt"),
1246            src.path().join("linked-file.txt"),
1247        )
1248        .unwrap();
1249
1250        let err = copy_dir_recursive(src.path(), dst.path()).unwrap_err();
1251        assert!(
1252            err.to_string()
1253                .contains("Refusing to deploy symlinked site entry outside deployment root"),
1254            "unexpected error: {err:#}"
1255        );
1256    }
1257
1258    #[test]
1259    #[cfg(unix)]
1260    fn test_copy_dir_recursive_rejects_symlinked_destination_root() {
1261        use std::os::unix::fs::symlink;
1262        use tempfile::TempDir;
1263
1264        let src = TempDir::new().unwrap();
1265        let outside = TempDir::new().unwrap();
1266        let dst_parent = TempDir::new().unwrap();
1267        let dst = dst_parent.path().join("linked-dst");
1268
1269        std::fs::write(src.path().join("root.txt"), "root").unwrap();
1270        symlink(outside.path(), &dst).unwrap();
1271
1272        let err = copy_dir_recursive(src.path(), &dst).unwrap_err();
1273        assert!(
1274            err.to_string()
1275                .contains("deploy staging directory through symlink"),
1276            "unexpected error: {err:#}"
1277        );
1278        assert!(
1279            !outside.path().join("root.txt").exists(),
1280            "deploy staging must not copy through a symlinked destination"
1281        );
1282        assert!(
1283            std::fs::symlink_metadata(&dst)
1284                .unwrap()
1285                .file_type()
1286                .is_symlink(),
1287            "rejected destination symlink should be left untouched"
1288        );
1289    }
1290
1291    #[test]
1292    #[cfg(unix)]
1293    fn test_copy_dir_recursive_rejects_symlinked_file_destination() {
1294        use std::os::unix::fs::symlink;
1295        use tempfile::TempDir;
1296
1297        let src = TempDir::new().unwrap();
1298        let dst = TempDir::new().unwrap();
1299        let outside = TempDir::new().unwrap();
1300        let outside_file = outside.path().join("sentinel.txt");
1301
1302        std::fs::write(src.path().join("root.txt"), "root").unwrap();
1303        std::fs::write(&outside_file, "keep").unwrap();
1304        symlink(&outside_file, dst.path().join("root.txt")).unwrap();
1305
1306        let err = copy_dir_recursive(src.path(), dst.path()).unwrap_err();
1307        assert!(
1308            err.to_string()
1309                .contains("write deploy file through symlink"),
1310            "unexpected error: {err:#}"
1311        );
1312        assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "keep");
1313    }
1314
1315    #[test]
1316    #[cfg(unix)]
1317    fn test_copy_bundle_to_repo_rejects_symlinked_repo_root() {
1318        use std::os::unix::fs::symlink;
1319        use tempfile::TempDir;
1320
1321        let bundle_root = TempDir::new().unwrap();
1322        let site_dir = bundle_root.path().join("site");
1323        let outside = TempDir::new().unwrap();
1324        let repo_parent = TempDir::new().unwrap();
1325        let repo_link = repo_parent.path().join("repo");
1326
1327        std::fs::create_dir_all(&site_dir).unwrap();
1328        std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1329        symlink(outside.path(), &repo_link).unwrap();
1330
1331        let err = copy_bundle_to_repo(bundle_root.path(), &repo_link).unwrap_err();
1332        assert!(
1333            err.to_string()
1334                .contains("deploy staging directory through symlink"),
1335            "unexpected error: {err:#}"
1336        );
1337        assert!(
1338            !outside.path().join("index.html").exists(),
1339            "deploy staging must not copy through a symlinked repo root"
1340        );
1341    }
1342
1343    #[test]
1344    #[cfg(unix)]
1345    fn test_copy_bundle_to_repo_removes_repo_symlink_entry_without_touching_target() {
1346        use std::os::unix::fs::symlink;
1347        use tempfile::TempDir;
1348
1349        let bundle_root = TempDir::new().unwrap();
1350        let repo_dir = TempDir::new().unwrap();
1351        let outside = TempDir::new().unwrap();
1352        let site_dir = bundle_root.path().join("site");
1353        let outside_dir = outside.path().join("old-dir-target");
1354        let outside_file = outside_dir.join("sentinel.txt");
1355
1356        std::fs::create_dir_all(&site_dir).unwrap();
1357        std::fs::write(site_dir.join("index.html"), "<html></html>").unwrap();
1358        std::fs::create_dir_all(&outside_dir).unwrap();
1359        std::fs::write(&outside_file, "keep").unwrap();
1360        symlink(&outside_dir, repo_dir.path().join("old-dir")).unwrap();
1361
1362        copy_bundle_to_repo(bundle_root.path(), repo_dir.path()).unwrap();
1363
1364        assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "keep");
1365        assert!(!repo_dir.path().join("old-dir").exists());
1366        assert!(repo_dir.path().join("index.html").exists());
1367        assert!(repo_dir.path().join(".nojekyll").exists());
1368    }
1369
1370    #[test]
1371    #[cfg(unix)]
1372    fn test_visit_files_counts_in_tree_symlinked_files() {
1373        use std::os::unix::fs::symlink;
1374        use tempfile::TempDir;
1375
1376        let src = TempDir::new().unwrap();
1377
1378        std::fs::write(src.path().join("root.txt"), "root").unwrap();
1379        symlink("root.txt", src.path().join("linked-file.txt")).unwrap();
1380
1381        let mut visited = Vec::new();
1382        visit_files(src.path(), &mut |path, size| {
1383            visited.push((
1384                path.strip_prefix(src.path())
1385                    .unwrap()
1386                    .to_string_lossy()
1387                    .to_string(),
1388                size,
1389            ));
1390        })
1391        .unwrap();
1392
1393        assert!(visited.contains(&("root.txt".to_string(), 4)));
1394        assert!(visited.contains(&("linked-file.txt".to_string(), 4)));
1395    }
1396
1397    #[test]
1398    #[cfg(unix)]
1399    fn test_visit_files_rejects_symlink_paths_outside_root() {
1400        use std::os::unix::fs::symlink;
1401        use tempfile::TempDir;
1402
1403        let src = TempDir::new().unwrap();
1404        let outside = TempDir::new().unwrap();
1405
1406        std::fs::write(src.path().join("root.txt"), "root").unwrap();
1407        std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
1408        std::fs::create_dir_all(outside.path().join("nested")).unwrap();
1409        std::fs::write(outside.path().join("nested/hidden.txt"), "hidden").unwrap();
1410
1411        symlink(
1412            outside.path().join("secret.txt"),
1413            src.path().join("linked-file.txt"),
1414        )
1415        .unwrap();
1416        symlink(outside.path().join("nested"), src.path().join("linked-dir")).unwrap();
1417
1418        let err = visit_files(src.path(), &mut |_path, _size| {}).unwrap_err();
1419        assert!(
1420            err.to_string()
1421                .contains("Refusing to deploy symlinked site entry outside deployment root"),
1422            "unexpected error: {err:#}"
1423        );
1424    }
1425
1426    #[test]
1427    fn test_configure_git_identity_sets_local_commit_metadata() {
1428        use tempfile::TempDir;
1429
1430        let repo = TempDir::new().unwrap();
1431        let init = Command::new("git")
1432            .args(["init"])
1433            .current_dir(repo.path())
1434            .output()
1435            .unwrap();
1436        assert!(
1437            init.status.success(),
1438            "git init failed: {}",
1439            String::from_utf8_lossy(&init.stderr)
1440        );
1441
1442        configure_git_identity(repo.path(), "cass-test").unwrap();
1443
1444        let name = Command::new("git")
1445            .args(["config", "user.name"])
1446            .current_dir(repo.path())
1447            .output()
1448            .unwrap();
1449        assert_eq!(String::from_utf8_lossy(&name.stdout).trim(), "cass-test");
1450
1451        let email = Command::new("git")
1452            .args(["config", "user.email"])
1453            .current_dir(repo.path())
1454            .output()
1455            .unwrap();
1456        assert_eq!(
1457            String::from_utf8_lossy(&email.stdout).trim(),
1458            "cass-test@users.noreply.github.com"
1459        );
1460
1461        std::fs::write(repo.path().join("index.html"), "<html></html>").unwrap();
1462
1463        let add = Command::new("git")
1464            .args(["add", "-A"])
1465            .current_dir(repo.path())
1466            .output()
1467            .unwrap();
1468        assert!(
1469            add.status.success(),
1470            "git add failed: {}",
1471            String::from_utf8_lossy(&add.stderr)
1472        );
1473
1474        let commit = Command::new("git")
1475            .args(["commit", "-m", "Test commit"])
1476            .current_dir(repo.path())
1477            .output()
1478            .unwrap();
1479        assert!(
1480            commit.status.success(),
1481            "git commit failed: {}",
1482            String::from_utf8_lossy(&commit.stderr)
1483        );
1484    }
1485}