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