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