Skip to main content

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