Skip to main content

synaps_cli/skills/
install.rs

1//! Git-backed plugin install/uninstall/update.
2
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7use sha2::{Digest, Sha256};
8
9/// Streaming `git clone --depth=1 --progress` — forwards every chunk of
10/// stderr (split on `\r`/`\n`) to `on_chunk` as it arrives.
11///
12/// The callback runs synchronously on the calling thread, so it must be
13/// fast (e.g. lock a Mutex, push a parsed snapshot, return). Designed to
14/// be invoked inside `tokio::task::spawn_blocking` from the chatui plugins
15/// modal, where the callback writes into a shared `InstallProgress`.
16///
17/// On failure, `dest` is best-effort removed so a partial clone doesn't
18/// confuse a retry.
19pub fn clone_repo_with_progress(
20    source_url: &str,
21    dest: &Path,
22    mut on_chunk: impl FnMut(&str),
23) -> Result<(), String> {
24    if source_url.starts_with('-') {
25        return Err(format!("refusing suspicious url: {}", source_url));
26    }
27    if let Some(parent) = dest.parent() {
28        std::fs::create_dir_all(parent)
29            .map_err(|e| format!("mkdir {}: {}", parent.display(), e))?;
30    }
31    let mut child = Command::new("git")
32        .args(["clone", "--progress", "--depth=1", "--", source_url])
33        .arg(dest)
34        .stdout(Stdio::null())
35        .stderr(Stdio::piped())
36        .spawn()
37        .map_err(|e| {
38            if e.kind() == std::io::ErrorKind::NotFound {
39                "git not found on PATH".to_string()
40            } else {
41                format!("spawn git: {}", e)
42            }
43        })?;
44
45    let mut stderr = child
46        .stderr
47        .take()
48        .ok_or_else(|| "git stderr was not piped".to_string())?;
49    let mut buf = [0u8; 4096];
50    let mut accum = String::new();
51    let mut last_stderr = String::new();
52    loop {
53        match stderr.read(&mut buf) {
54            Ok(0) => break,
55            Ok(n) => {
56                let s = String::from_utf8_lossy(&buf[..n]);
57                accum.push_str(&s);
58                last_stderr.push_str(&s);
59                // Process complete chunks (split on either CR or LF —
60                // git uses CR to overwrite the same progress line).
61                loop {
62                    let split = accum.find(|c: char| c == '\r' || c == '\n');
63                    let Some(pos) = split else { break };
64                    let chunk: String = accum.drain(..pos).collect();
65                    // Drop the single delimiter character.
66                    if !accum.is_empty() {
67                        accum.drain(..1);
68                    }
69                    if !chunk.is_empty() {
70                        on_chunk(&chunk);
71                    }
72                }
73                // Cap memory: keep last 16 KiB of raw stderr for error reporting.
74                if last_stderr.len() > 16 * 1024 {
75                    let cut = last_stderr.len() - 8 * 1024;
76                    last_stderr.replace_range(..cut, "");
77                }
78            }
79            Err(_) => break,
80        }
81    }
82    // Flush any tail fragment that wasn't terminated.
83    if !accum.is_empty() {
84        on_chunk(&accum);
85    }
86
87    let status = child
88        .wait()
89        .map_err(|e| format!("wait git: {}", e))?;
90    if !status.success() {
91        let _ = std::fs::remove_dir_all(dest);
92        let trimmed = last_stderr.trim();
93        let detail = if trimmed.is_empty() {
94            format!("exit code {:?}", status.code())
95        } else {
96            // Take the last non-empty line as the most relevant error.
97            trimmed
98                .lines()
99                .filter(|l| !l.trim().is_empty())
100                .next_back()
101                .unwrap_or(trimmed)
102                .to_string()
103        };
104        return Err(format!("git clone failed: {}", detail));
105    }
106    Ok(())
107}
108
109/// `git clone --depth=1 <url> <dest>`, then `git rev-parse HEAD`.
110/// `dest` must not already exist.
111pub fn install_plugin(source_url: &str, dest: &Path) -> Result<String, String> {
112    install_plugin_with_progress(source_url, dest, |_| {})
113}
114
115/// Like [`install_plugin`] but streams `git clone --progress` chunks to
116/// `on_chunk`. See [`clone_repo_with_progress`] for callback semantics.
117pub fn install_plugin_with_progress(
118    source_url: &str,
119    dest: &Path,
120    on_chunk: impl FnMut(&str),
121) -> Result<String, String> {
122    if dest.exists() {
123        return Err(format!("{} already exists on disk; uninstall first", dest.display()));
124    }
125    clone_repo_with_progress(source_url, dest, on_chunk)?;
126    rev_parse_head(dest)
127}
128
129/// Shallow-clone `marketplace_url` into a temp dir sibling to `dest`, then
130/// move its `<subdir>` directly into place at `dest`. Returns the HEAD SHA
131/// of the cloned marketplace. Used for Claude-Code-style marketplaces whose
132/// plugins reference `./<subdir>` instead of their own standalone repos.
133///
134/// Guarantees:
135/// - `subdir` must pass [`crate::skills::marketplace::is_safe_plugin_name`]
136///   (no traversal, no path separators).
137/// - `dest` must not exist.
138/// - If the subdir doesn't exist inside the cloned repo, returns `Err` and
139///   does not create `dest`.
140pub fn install_plugin_from_subdir(
141    marketplace_url: &str,
142    subdir: &str,
143    dest: &Path,
144) -> Result<String, String> {
145    install_plugin_from_subdir_with_progress(marketplace_url, subdir, dest, |_| {})
146}
147
148/// Like [`install_plugin_from_subdir`] but streams `git clone --progress`
149/// chunks to `on_chunk`. See [`clone_repo_with_progress`] for callback
150/// semantics.
151pub fn install_plugin_from_subdir_with_progress(
152    marketplace_url: &str,
153    subdir: &str,
154    dest: &Path,
155    on_chunk: impl FnMut(&str),
156) -> Result<String, String> {
157    if !crate::skills::marketplace::is_safe_plugin_name(subdir) {
158        return Err(format!("refusing unsafe subdir name: {}", subdir));
159    }
160    if dest.exists() {
161        return Err(format!("{} already exists on disk; uninstall first", dest.display()));
162    }
163    let parent = dest.parent().ok_or_else(|| "dest has no parent directory".to_string())?;
164    let dest_name = dest.file_name()
165        .and_then(|s| s.to_str())
166        .ok_or_else(|| "dest file name is not utf-8".to_string())?;
167    let tmp = parent.join(format!(".{}-clone-tmp", dest_name));
168    // Clean any stale temp from a prior aborted install.
169    let _ = std::fs::remove_dir_all(&tmp);
170
171    clone_repo_with_progress(marketplace_url, &tmp, on_chunk)?;
172
173    let sha = match rev_parse_head(&tmp) {
174        Ok(s) => s,
175        Err(e) => {
176            let _ = std::fs::remove_dir_all(&tmp);
177            return Err(e);
178        }
179    };
180
181    let src_subdir = tmp.join(subdir);
182    if !src_subdir.is_dir() {
183        let _ = std::fs::remove_dir_all(&tmp);
184        return Err(format!("subdir '{}' not found in marketplace repo", subdir));
185    }
186
187    // Prefer rename (fast, same-filesystem); fall back to recursive copy.
188    if std::fs::rename(&src_subdir, dest).is_err() {
189        copy_dir_all(&src_subdir, dest).map_err(|e| {
190            let _ = std::fs::remove_dir_all(&tmp);
191            format!("copy {} to {}: {}", src_subdir.display(), dest.display(), e)
192        })?;
193    }
194    let _ = std::fs::remove_dir_all(&tmp);
195    Ok(sha)
196}
197
198fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
199    std::fs::create_dir_all(dst)?;
200    for entry in std::fs::read_dir(src)? {
201        let entry = entry?;
202        let ty = entry.file_type()?;
203        let dst_path = dst.join(entry.file_name());
204        if ty.is_dir() {
205            copy_dir_all(&entry.path(), &dst_path)?;
206        } else if ty.is_file() {
207            std::fs::copy(entry.path(), dst_path)?;
208        }
209        // Symlinks and other types are skipped intentionally.
210    }
211    Ok(())
212}
213
214/// `rm -rf <path>`. Missing path is OK.
215pub fn uninstall_plugin(path: &Path) -> Result<(), String> {
216    match std::fs::remove_dir_all(path) {
217        Ok(()) => Ok(()),
218        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
219        Err(e) => Err(format!("remove {}: {}", path.display(), e)),
220    }
221}
222
223/// Compute the plugin package checksum used by v1 plugin indexes.
224///
225/// The digest is sha256 over each regular file below `path` (excluding `.git`),
226/// in lexical relative-path order. Each file contributes its relative path,
227/// a NUL separator, its bytes, and another NUL. This makes the checksum stable
228/// across machines while detecting file rename/content changes. Symlinks and
229/// non-regular files are ignored, matching installer snapshot behavior.
230pub fn plugin_dir_sha256(path: &Path) -> Result<String, String> {
231    if !path.is_dir() {
232        return Err(format!("{} is not a directory", path.display()));
233    }
234    let effective_root = path.join(".synaps-plugin").join("plugin.json");
235    if effective_root.is_file() {
236        hash_regular_files(path)
237    } else {
238        let mut candidates = Vec::new();
239        collect_plugin_roots(path, path, &mut candidates)?;
240        candidates.sort();
241        if candidates.len() == 1 {
242            hash_regular_files(&candidates[0])
243        } else {
244            hash_regular_files(path)
245        }
246    }
247}
248
249fn hash_regular_files(path: &Path) -> Result<String, String> {
250    let mut files = Vec::new();
251    collect_regular_files(path, path, &mut files)?;
252    files.sort();
253
254    let mut hasher = Sha256::new();
255    for rel in files {
256        let full = path.join(&rel);
257        hasher.update(rel.to_string_lossy().as_bytes());
258        hasher.update([0]);
259        let bytes = std::fs::read(&full)
260            .map_err(|e| format!("read {}: {}", full.display(), e))?;
261        hasher.update(bytes);
262        hasher.update([0]);
263    }
264    Ok(format!("{:x}", hasher.finalize()))
265}
266
267pub fn verify_plugin_dir_checksum(path: &Path, algorithm: &str, expected: &str) -> Result<(), String> {
268    if algorithm != "sha256" {
269        return Err(format!("unsupported plugin checksum algorithm: {}", algorithm));
270    }
271    let actual = plugin_dir_sha256(path)?;
272    if actual != expected {
273        return Err(format!(
274            "plugin checksum mismatch: expected sha256:{}, got sha256:{}",
275            expected, actual
276        ));
277    }
278    Ok(())
279}
280
281fn collect_plugin_roots(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
282    for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
283        let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
284        let path = entry.path();
285        if entry.file_name().to_string_lossy() == ".git" {
286            continue;
287        }
288        let ty = entry.file_type().map_err(|e| format!("stat {}: {}", path.display(), e))?;
289        if ty.is_dir() {
290            if path.join(".synaps-plugin").join("plugin.json").is_file() && path != root {
291                out.push(path);
292            } else {
293                collect_plugin_roots(root, &path, out)?;
294            }
295        }
296    }
297    Ok(())
298}
299
300fn collect_regular_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
301    for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
302        let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
303        let path = entry.path();
304        let name = entry.file_name();
305        if name.to_string_lossy() == ".git" {
306            continue;
307        }
308        let ty = entry
309            .file_type()
310            .map_err(|e| format!("stat {}: {}", path.display(), e))?;
311        if ty.is_dir() {
312            collect_regular_files(root, &path, out)?;
313        } else if ty.is_file() {
314            let rel = path
315                .strip_prefix(root)
316                .map_err(|e| format!("strip prefix {}: {}", path.display(), e))?
317                .to_path_buf();
318            out.push(rel);
319        }
320    }
321    Ok(())
322}
323
324/// `git -C <path> pull --ff-only`, then capture new SHA.
325pub fn update_plugin(install_path: &Path) -> Result<String, String> {
326    let out = Command::new("git")
327        .args(["-C"])
328        .arg(install_path)
329        .args(["pull", "--ff-only", "-q"])
330        .output()
331        .map_err(|e| format!("spawn git: {}", e))?;
332    if !out.status.success() {
333        return Err(format!(
334            "git pull failed: {}",
335            String::from_utf8_lossy(&out.stderr).trim()
336        ));
337    }
338    rev_parse_head(install_path)
339}
340
341/// `git ls-remote <url> HEAD` → first column (SHA). Network op.
342pub fn ls_remote_head(source_url: &str) -> Result<String, String> {
343    if source_url.starts_with('-') {
344        return Err(format!("refusing suspicious url: {}", source_url));
345    }
346    let out = Command::new("git")
347        .args(["ls-remote", "--", source_url, "HEAD"])
348        .output()
349        .map_err(|e| format!("spawn git: {}", e))?;
350    if !out.status.success() {
351        return Err(format!(
352            "git ls-remote failed: {}",
353            String::from_utf8_lossy(&out.stderr).trim()
354        ));
355    }
356    let stdout = String::from_utf8_lossy(&out.stdout);
357    let sha = stdout
358        .split_whitespace()
359        .next()
360        .ok_or("empty ls-remote output")?;
361    if sha.len() != 40 {
362        return Err(format!("unexpected ls-remote output: {}", stdout));
363    }
364    Ok(sha.to_string())
365}
366
367fn rev_parse_head(repo: &Path) -> Result<String, String> {
368    let out = Command::new("git")
369        .args(["-C"])
370        .arg(repo)
371        .args(["rev-parse", "HEAD"])
372        .output()
373        .map_err(|e| format!("spawn git: {}", e))?;
374    if !out.status.success() {
375        return Err(format!(
376            "git rev-parse failed: {}",
377            String::from_utf8_lossy(&out.stderr).trim()
378        ));
379    }
380    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use std::process::Command;
387
388    /// Build a throwaway local bare git repo to clone from (no network).
389    fn mk_local_repo() -> (tempfile::TempDir, std::path::PathBuf) {
390        let dir = tempfile::tempdir().unwrap();
391        let work = dir.path().join("work");
392        std::fs::create_dir_all(&work).unwrap();
393        Command::new("git").args(["init", "-q"]).current_dir(&work).status().unwrap();
394        Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&work).status().unwrap();
395        Command::new("git").args(["config", "user.name", "t"]).current_dir(&work).status().unwrap();
396        std::fs::write(work.join("SKILL.md"),
397            "---\nname: demo\ndescription: d\n---\nbody").unwrap();
398        Command::new("git").args(["add", "."]).current_dir(&work).status().unwrap();
399        Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&work).status().unwrap();
400
401        let bare = dir.path().join("bare.git");
402        Command::new("git").args(["clone", "--bare", "-q",
403            work.to_str().unwrap(), bare.to_str().unwrap()]).status().unwrap();
404        (dir, bare)
405    }
406
407    #[test]
408    fn install_clones_and_returns_sha() {
409        let (_tmp, bare) = mk_local_repo();
410        let dest_parent = tempfile::tempdir().unwrap();
411        let dest = dest_parent.path().join("demo");
412        let sha = install_plugin(
413            &format!("file://{}", bare.display()),
414            &dest,
415        ).unwrap();
416        assert!(dest.join("SKILL.md").exists());
417        assert_eq!(sha.len(), 40);
418    }
419
420    #[test]
421    fn install_with_progress_streams_chunks_and_returns_sha() {
422        use std::sync::{Arc, Mutex};
423        let (_tmp, bare) = mk_local_repo();
424        let dest_parent = tempfile::tempdir().unwrap();
425        let dest = dest_parent.path().join("demo");
426        let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
427        let chunks_clone = Arc::clone(&chunks);
428        let sha = install_plugin_with_progress(
429            &format!("file://{}", bare.display()),
430            &dest,
431            move |c| chunks_clone.lock().unwrap().push(c.to_string()),
432        )
433        .unwrap();
434        assert_eq!(sha.len(), 40);
435        assert!(dest.join("SKILL.md").exists());
436        let captured = chunks.lock().unwrap().clone();
437        // Local file:// clones are tiny and may or may not emit Receiving lines
438        // depending on git's heuristic, but they always emit *something*
439        // (e.g. "Cloning into '/tmp/...'") on stderr with --progress.
440        assert!(
441            !captured.is_empty(),
442            "expected at least one progress chunk from --progress, got none"
443        );
444    }
445
446    #[test]
447    fn install_with_progress_failure_propagates_stderr() {
448        use std::sync::{Arc, Mutex};
449        let dest_parent = tempfile::tempdir().unwrap();
450        let dest = dest_parent.path().join("demo");
451        let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
452        let chunks_clone = Arc::clone(&chunks);
453        let err = install_plugin_with_progress(
454            "file:///definitely/not/a/real/repo.git",
455            &dest,
456            move |c| chunks_clone.lock().unwrap().push(c.to_string()),
457        )
458        .unwrap_err();
459        assert!(err.contains("git clone failed"), "err was: {err}");
460        assert!(
461            !dest.exists(),
462            "failed clone must not leave a partial dest dir"
463        );
464    }
465
466    /// Like `mk_local_repo`, but puts the plugin content under `work/<sub>/`
467    /// so the bare clone can be snapshotted via `install_plugin_from_subdir`.
468    fn mk_local_repo_with_subdir(sub: &str) -> (tempfile::TempDir, std::path::PathBuf) {
469        let dir = tempfile::tempdir().unwrap();
470        let work = dir.path().join("work");
471        std::fs::create_dir_all(work.join(sub)).unwrap();
472        Command::new("git").args(["init", "-q"]).current_dir(&work).status().unwrap();
473        Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&work).status().unwrap();
474        Command::new("git").args(["config", "user.name", "t"]).current_dir(&work).status().unwrap();
475        std::fs::write(
476            work.join(sub).join("SKILL.md"),
477            "---\nname: demo\ndescription: d\n---\nbody",
478        ).unwrap();
479        std::fs::write(work.join("README.md"), "top level").unwrap();
480        Command::new("git").args(["add", "."]).current_dir(&work).status().unwrap();
481        Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&work).status().unwrap();
482
483        let bare = dir.path().join("bare.git");
484        Command::new("git").args(["clone", "--bare", "-q",
485            work.to_str().unwrap(), bare.to_str().unwrap()]).status().unwrap();
486        (dir, bare)
487    }
488
489    #[test]
490    fn install_plugin_from_subdir_snapshots_subdir_content() {
491        let (_tmp, bare) = mk_local_repo_with_subdir("web");
492        let dest_parent = tempfile::tempdir().unwrap();
493        let dest = dest_parent.path().join("web");
494        let sha = install_plugin_from_subdir(
495            &format!("file://{}", bare.display()),
496            "web",
497            &dest,
498        ).unwrap();
499        assert_eq!(sha.len(), 40);
500        // Subdir contents landed directly at dest.
501        assert!(dest.join("SKILL.md").exists());
502        // README.md from the parent repo was NOT copied in.
503        assert!(!dest.join("README.md").exists());
504        // No leftover temp clone.
505        let tmp_leftover = dest_parent.path().join(".web-clone-tmp");
506        assert!(!tmp_leftover.exists());
507    }
508
509    #[test]
510    fn install_plugin_from_subdir_rejects_unsafe_subdir() {
511        let (_tmp, bare) = mk_local_repo_with_subdir("web");
512        let dest_parent = tempfile::tempdir().unwrap();
513        let dest = dest_parent.path().join("web");
514        let err = install_plugin_from_subdir(
515            &format!("file://{}", bare.display()),
516            "../evil",
517            &dest,
518        ).unwrap_err();
519        assert!(err.contains("unsafe"));
520        assert!(!dest.exists());
521    }
522
523    #[test]
524    fn install_plugin_from_subdir_fails_when_subdir_missing() {
525        let (_tmp, bare) = mk_local_repo_with_subdir("web");
526        let dest_parent = tempfile::tempdir().unwrap();
527        let dest = dest_parent.path().join("nope");
528        let err = install_plugin_from_subdir(
529            &format!("file://{}", bare.display()),
530            "nope",
531            &dest,
532        ).unwrap_err();
533        assert!(err.contains("not found"));
534        assert!(!dest.exists());
535    }
536
537    #[test]
538    fn install_refuses_if_target_exists() {
539        let (_tmp, bare) = mk_local_repo();
540        let dest_parent = tempfile::tempdir().unwrap();
541        let dest = dest_parent.path().join("demo");
542        std::fs::create_dir_all(&dest).unwrap();
543        let err = install_plugin(
544            &format!("file://{}", bare.display()),
545            &dest,
546        ).unwrap_err();
547        assert!(err.contains("already"));
548    }
549
550    #[test]
551    fn uninstall_removes_directory() {
552        let dir = tempfile::tempdir().unwrap();
553        let p = dir.path().join("demo");
554        std::fs::create_dir_all(&p).unwrap();
555        std::fs::write(p.join("x"), "y").unwrap();
556        uninstall_plugin(&p).unwrap();
557        assert!(!p.exists());
558    }
559
560    #[test]
561    fn uninstall_missing_dir_is_ok() {
562        let dir = tempfile::tempdir().unwrap();
563        let p = dir.path().join("nothere");
564        assert!(uninstall_plugin(&p).is_ok());
565    }
566
567    #[test]
568    fn ls_remote_head_returns_sha_on_real_repo() {
569        let (_tmp, bare) = mk_local_repo();
570        let sha = ls_remote_head(&format!("file://{}", bare.display())).unwrap();
571        assert_eq!(sha.len(), 40);
572    }
573
574    #[test]
575    fn checksum_ignores_git_and_detects_content_changes() {
576        let dir = tempfile::tempdir().unwrap();
577        let plugin = dir.path().join("demo");
578        std::fs::create_dir_all(plugin.join(".synaps-plugin")).unwrap();
579        std::fs::create_dir_all(plugin.join(".git")).unwrap();
580        std::fs::write(plugin.join(".synaps-plugin/plugin.json"), "{}").unwrap();
581        std::fs::write(plugin.join("README.md"), "one").unwrap();
582        std::fs::write(plugin.join(".git/HEAD"), "ignored").unwrap();
583
584        let first = plugin_dir_sha256(&plugin).unwrap();
585        assert_eq!(first.len(), 64);
586        verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap();
587
588        std::fs::write(plugin.join(".git/HEAD"), "still ignored").unwrap();
589        assert_eq!(plugin_dir_sha256(&plugin).unwrap(), first);
590
591        std::fs::write(plugin.join("README.md"), "two").unwrap();
592        let second = plugin_dir_sha256(&plugin).unwrap();
593        assert_ne!(second, first);
594        let err = verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap_err();
595        assert!(err.contains("checksum mismatch"));
596    }
597
598    #[test]
599    fn update_plugin_fast_forwards_and_returns_new_sha() {
600        let (_tmp, bare) = mk_local_repo();
601        let dest_parent = tempfile::tempdir().unwrap();
602        let dest = dest_parent.path().join("demo");
603        let initial_sha = install_plugin(
604            &format!("file://{}", bare.display()),
605            &dest,
606        ).unwrap();
607
608        // Push a second commit to the bare repo.
609        let pusher_parent = tempfile::tempdir().unwrap();
610        let pusher = pusher_parent.path().join("push");
611        Command::new("git").args(["clone", "-q"])
612            .arg(&bare).arg(&pusher).status().unwrap();
613        Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&pusher).status().unwrap();
614        Command::new("git").args(["config", "user.name", "t"]).current_dir(&pusher).status().unwrap();
615        std::fs::write(pusher.join("second.md"), "more").unwrap();
616        Command::new("git").args(["add", "."]).current_dir(&pusher).status().unwrap();
617        Command::new("git").args(["commit", "-q", "-m", "second"]).current_dir(&pusher).status().unwrap();
618        Command::new("git").args(["push", "-q"]).current_dir(&pusher).status().unwrap();
619
620        let updated_sha = update_plugin(&dest).unwrap();
621        assert_eq!(updated_sha.len(), 40);
622        assert_ne!(updated_sha, initial_sha);
623    }
624}