Skip to main content

par_term_scripting/
plugin_git.rs

1//! Git-backed plugin distribution: `add`, `update`, `remove`.
2//!
3//! The `par-term plugin add|update|remove` CLI subcommands and their Settings
4//! UI equivalents run through this module. Everything shells out to the `git`
5//! binary (the same pattern as the root crate's status-bar git poller) rather
6//! than adding a git crate, and EVERY invocation runs with
7//! `GIT_TERMINAL_PROMPT=0` under a hard deadline: these calls run inside the
8//! GPU process (Settings UI) where an auth prompt has no terminal to answer
9//! it. The env var covers git's own prompts; the deadline is the backstop for
10//! prompts it cannot reach — an `ssh` passphrase ask writes to the
11//! controlling tty directly, and the kill turns that hang into an error.
12//!
13//! Security shape (board card 01a0c5253b287cb39a11bf6182634197):
14//! - `add` clones to a staging directory first (a clone target named by the
15//!   URL says nothing about the manifest id, which must equal the final
16//!   directory name), validates the manifest on the final path, and refuses
17//!   to overwrite an existing target. Plugins land disabled by construction:
18//!   nothing here touches the enabled set.
19//! - `update` fetches, shows a diff, and fast-forwards only. A history
20//!   rewrite upstream is refused, never reset — the trust model rests on the
21//!   user reading plugin code before enabling it, and a reset would silently
22//!   discard exactly that (decision D3).
23//! - `remove` only touches directories `add` could have created: a `.git`
24//!   directory WITH an `origin` remote. A hand-copied plugin (no `.git`) and
25//!   a bare `git init` (no remote) are both refused.
26
27use std::io::Read;
28use std::path::Path;
29use std::process::{Command, ExitStatus, Stdio};
30use std::time::{Duration, Instant};
31
32use crate::manifest::{DiscoveredPlugin, PluginManifest, validate_plugin_dir};
33
34/// Deadline for `git clone`. Generous for a slow clone of a real plugin
35/// repository; the point is that no git invocation can hang the caller
36/// forever.
37const CLONE_TIMEOUT: Duration = Duration::from_secs(180);
38/// Deadline for `git fetch` / `git pull`.
39const FETCH_TIMEOUT: Duration = Duration::from_secs(60);
40const PULL_TIMEOUT: Duration = Duration::from_secs(60);
41/// Deadline for diff/stat-producing invocations.
42const DIFF_TIMEOUT: Duration = Duration::from_secs(30);
43/// Deadline for the small read-only invocations (rev-parse, remote).
44const READ_TIMEOUT: Duration = Duration::from_secs(10);
45/// How often the bounded runner re-checks whether git has exited.
46const POLL_INTERVAL: Duration = Duration::from_millis(10);
47
48/// A fetched-but-not-applied update, for display before the fast-forward
49/// runs.
50#[derive(Debug, Clone, PartialEq)]
51pub struct UpdatePreview {
52    /// True when FETCH_HEAD equals HEAD — there is nothing to apply.
53    pub up_to_date: bool,
54    /// Short sha of the installed commit.
55    pub current: String,
56    /// Short sha of the fetched commit.
57    pub incoming: String,
58    /// `git diff --stat HEAD..FETCH_HEAD`, shown to the user before
59    /// applying.
60    pub diff: String,
61}
62
63/// Clone `url` into `root` under the plugin's manifest id, validating the
64/// result. Refuses non-URL sources (decision D1: local installs stay the
65/// documented hand-copy), refuses an existing target directory by naming it,
66/// and removes everything it staged on any failure. The plugin lands
67/// disabled: enabling is a separate config step nothing here performs.
68pub fn add(root: &Path, url: &str) -> Result<DiscoveredPlugin, String> {
69    if !is_git_url(url) {
70        return Err(format!(
71            "`{url}` is not a git URL; plugin add installs from git URLs only. \
72             Local plugin directories are installed by copying them into {} by hand.",
73            root.display()
74        ));
75    }
76    std::fs::create_dir_all(root)
77        .map_err(|e| format!("cannot create plugins root {}: {e}", root.display()))?;
78
79    // Stage the clone under root itself, dot-prefixed: same filesystem, so
80    // the rename into place cannot hit a cross-device boundary. Discovery
81    // skips nothing by name, so a staging directory left behind by a crash
82    // surfaces as a visible warning instead of disappearing silently.
83    let staging = root.join(format!(".incoming-{}-{}", std::process::id(), now_millis()));
84    let result = (|| {
85        let mut clone = git(Some(root), &[]);
86        clone.arg("clone").arg("--quiet").arg(url).arg(&staging);
87        let out = output_with_timeout(&mut clone, CLONE_TIMEOUT)?;
88        if !out.status.success() {
89            let stderr = out.stderr.trim();
90            return Err(format!(
91                "git clone failed: {}{}",
92                out.status,
93                if stderr.is_empty() {
94                    String::new()
95                } else {
96                    format!(": {stderr}")
97                }
98            ));
99        }
100        stage_into_place(root, &staging)
101    })();
102    if result.is_err() {
103        let _ = std::fs::remove_dir_all(&staging);
104    }
105    result
106}
107
108/// Move a freshly cloned staging directory to its final `<root>/<manifest
109/// id>` home and validate it there. Every error path leaves the plugins root
110/// without a new entry (the caller removes the staging directory; this
111/// function removes the moved directory when validation fails).
112fn stage_into_place(root: &Path, staging: &Path) -> Result<DiscoveredPlugin, String> {
113    // The manifest id decides the final directory name, and the URL it was
114    // cloned from says nothing about it — read it from the clone.
115    let manifest_raw = std::fs::read_to_string(staging.join("manifest.json"))
116        .map_err(|e| format!("cloned repository has no readable manifest.json: {e}"))?;
117    let manifest: PluginManifest = serde_json::from_str(&manifest_raw)
118        .map_err(|e| format!("cloned manifest.json is invalid: {e}"))?;
119    if !safe_segment(&manifest.id) {
120        return Err(format!(
121            "manifest id `{}` is not a usable plugin directory name",
122            manifest.id
123        ));
124    }
125    let final_dir = root.join(&manifest.id);
126    if final_dir.exists() {
127        return Err(format!(
128            "plugin directory already exists: {}. Remove it first \
129             (`plugin remove {}` if it was git-installed) and add again.",
130            final_dir.display(),
131            manifest.id
132        ));
133    }
134    std::fs::rename(staging, &final_dir)
135        .map_err(|e| format!("failed to move the cloned plugin into place: {e}"))?;
136    // Full validation runs on the final path only — validate_plugin_dir
137    // requires the directory name to equal the manifest id, which is not
138    // true of the staging name.
139    match validate_plugin_dir(&final_dir) {
140        Ok(plugin) => Ok(plugin),
141        Err(reason) => {
142            // Never leave behind a plugin that would only ever surface as a
143            // discovery warning.
144            let _ = std::fs::remove_dir_all(&final_dir);
145            Err(format!("cloned plugin failed validation: {reason}"))
146        }
147    }
148}
149
150/// Fetch upstream for a git-installed plugin and describe what applying
151/// would bring, without applying anything — the user sees this diff before
152/// the fast-forward runs.
153pub fn update_fetch(dir: &Path) -> Result<UpdatePreview, String> {
154    require_installed(dir)?;
155    let branch = run_git_ok(
156        Some(dir),
157        &["rev-parse", "--abbrev-ref", "HEAD"],
158        READ_TIMEOUT,
159        "git rev-parse HEAD",
160    )?;
161    let fetch_args = ["fetch", "--quiet", "origin", branch.as_str()];
162    run_git_ok(Some(dir), &fetch_args, FETCH_TIMEOUT, "git fetch")?;
163    let current = run_git_ok(
164        Some(dir),
165        &["rev-parse", "--short", "HEAD"],
166        READ_TIMEOUT,
167        "git rev-parse HEAD",
168    )?;
169    let incoming = run_git_ok(
170        Some(dir),
171        &["rev-parse", "--short", "FETCH_HEAD"],
172        READ_TIMEOUT,
173        "git rev-parse FETCH_HEAD",
174    )?;
175    if current == incoming {
176        return Ok(UpdatePreview {
177            up_to_date: true,
178            current,
179            incoming,
180            diff: String::new(),
181        });
182    }
183    let diff = run_git_ok(
184        Some(dir),
185        &["diff", "--stat", "HEAD..FETCH_HEAD"],
186        DIFF_TIMEOUT,
187        "git diff",
188    )?;
189    Ok(UpdatePreview {
190        up_to_date: false,
191        current,
192        incoming,
193        diff,
194    })
195}
196
197/// Fast-forward a git-installed plugin to the fetched upstream. Refuses when
198/// a fast-forward is impossible — never resets. Nothing is changed on
199/// refusal; if upstream rewrote history, removing and re-adding the plugin
200/// is the user's explicit way to take the rewritten version (decision D3).
201pub fn update_apply(dir: &Path) -> Result<String, String> {
202    require_installed(dir)?;
203    let out = run_git(Some(dir), &["pull", "--ff-only", "--quiet"], PULL_TIMEOUT)?;
204    if !out.status.success() {
205        let stderr = out.stderr.trim();
206        return Err(format!(
207            "update refused — `git pull --ff-only` failed and nothing was changed. \
208             If upstream rewrote history, remove and re-add the plugin to take its version. \
209             git said: {}{}",
210            out.status,
211            if stderr.is_empty() {
212                String::new()
213            } else {
214                format!(": {stderr}")
215            }
216        ));
217    }
218    run_git_ok(
219        Some(dir),
220        &["rev-parse", "--short", "HEAD"],
221        READ_TIMEOUT,
222        "git rev-parse HEAD",
223    )
224}
225
226/// Remove a git-installed plugin directory. Refuses anything `add` could not
227/// have created: no `.git` directory (hand-copied), or `.git` without an
228/// `origin` remote (a bare `git init`).
229pub fn remove(root: &Path, id: &str) -> Result<(), String> {
230    if !safe_segment(id) {
231        return Err(format!("`{id}` is not a plugin directory name"));
232    }
233    let dir = root.join(id);
234    if !dir.is_dir() {
235        return Err(format!(
236            "no plugin directory named `{id}` under {}",
237            root.display()
238        ));
239    }
240    require_installed(&dir)?;
241    std::fs::remove_dir_all(&dir).map_err(|e| format!("failed to remove {}: {e}", dir.display()))
242}
243
244/// True when `dir` looks installed by `add`: a `.git` directory with an
245/// `origin` remote. Hand-copied plugins have no `.git`; a bare `git init`
246/// has `.git` but no remote. The Settings UI uses this to decide which rows
247/// offer Update/Remove.
248pub fn is_git_installed(dir: &Path) -> bool {
249    dir.join(".git").is_dir() && origin_url(dir).is_some()
250}
251
252fn origin_url(dir: &Path) -> Option<String> {
253    run_git_ok(
254        Some(dir),
255        &["remote", "get-url", "origin"],
256        READ_TIMEOUT,
257        "git remote get-url",
258    )
259    .ok()
260}
261
262fn require_installed(dir: &Path) -> Result<(), String> {
263    if !dir.join(".git").is_dir() {
264        return Err(format!(
265            "{} was not installed by plugin add (no .git directory). \
266             Hand-copied plugins are removed by deleting their directory.",
267            dir.display()
268        ));
269    }
270    if origin_url(dir).is_none() {
271        return Err(format!(
272            "{} has .git but no `origin` remote, so it was not installed by \
273             plugin add. Remove its directory by hand.",
274            dir.display()
275        ));
276    }
277    Ok(())
278}
279
280/// True for plugin ids usable as one directory segment under the plugins
281/// root. `git clone` names its target from the URL, but the manifest id
282/// decides the final directory, and a hostile or careless `../`-style id
283/// must not move the rename outside the root.
284fn safe_segment(id: &str) -> bool {
285    !id.is_empty() && !id.starts_with('.') && !id.contains(['/', '\\']) && !id.contains('\0')
286}
287
288/// True for sources `add` accepts: scheme-qualified git URLs (https://,
289/// git://, ssh://, file://, …) and scp-like `user@host:path` forms. Bare
290/// filesystem paths — absolute, relative, `~/` — are refused (decision D1).
291fn is_git_url(url: &str) -> bool {
292    let url = url.trim();
293    if url.is_empty() {
294        return false;
295    }
296    if let Some((scheme, rest)) = url.split_once("://") {
297        return !scheme.is_empty()
298            && scheme
299                .chars()
300                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
301            && !rest.is_empty();
302    }
303    match url.split_once(':') {
304        // scp-like form: a colon before any separator, e.g.
305        // git@github.com:owner/repo.git
306        Some((host, rest)) => !host.is_empty() && !host.contains('/') && !rest.is_empty(),
307        None => false,
308    }
309}
310
311fn now_millis() -> u128 {
312    std::time::SystemTime::now()
313        .duration_since(std::time::UNIX_EPOCH)
314        .unwrap_or_default()
315        .as_millis()
316}
317
318/// Captured result of a git invocation that exited within its deadline.
319#[derive(Debug)]
320struct BoundedOutput {
321    status: ExitStatus,
322    stdout: String,
323    stderr: String,
324}
325
326/// Run `cmd` to completion, killing it if it outlives `timeout`.
327///
328/// Mirrors the root crate's `src/process_timeout.rs`; this private copy
329/// exists because the root crate depends on THIS crate, so the helper cannot
330/// be shared. Draining both pipes on helper threads matters as much here as
331/// there: `git pull` can emit more than a pipe buffer of progress output,
332/// and polling `try_wait` without reading would deadlock.
333fn output_with_timeout(cmd: &mut Command, timeout: Duration) -> Result<BoundedOutput, String> {
334    let mut child = cmd
335        .stdin(Stdio::null())
336        .stdout(Stdio::piped())
337        .stderr(Stdio::piped())
338        .spawn()
339        .map_err(|e| format!("failed to spawn: {e}"))?;
340
341    let mut child_stdout = child
342        .stdout
343        .take()
344        .ok_or_else(|| "stdout pipe unavailable".to_string())?;
345    let mut child_stderr = child
346        .stderr
347        .take()
348        .ok_or_else(|| "stderr pipe unavailable".to_string())?;
349    let stdout_reader = std::thread::spawn(move || {
350        let mut buf = String::new();
351        let _ = child_stdout.read_to_string(&mut buf);
352        buf
353    });
354    let stderr_reader = std::thread::spawn(move || {
355        let mut buf = String::new();
356        let _ = child_stderr.read_to_string(&mut buf);
357        buf
358    });
359
360    let deadline = Instant::now() + timeout;
361    let status = loop {
362        match child.try_wait() {
363            Ok(Some(status)) => break status,
364            Ok(None) => {
365                if Instant::now() >= deadline {
366                    let _ = child.kill();
367                    let _ = child.wait();
368                    return Err(format!("timed out after {:.1}s", timeout.as_secs_f64()));
369                }
370                std::thread::sleep(POLL_INTERVAL);
371            }
372            Err(e) => return Err(format!("failed while waiting: {e}")),
373        }
374    };
375
376    Ok(BoundedOutput {
377        status,
378        stdout: stdout_reader
379            .join()
380            .map_err(|_| "stdout reader thread panicked".to_string())?,
381        stderr: stderr_reader
382            .join()
383            .map_err(|_| "stderr reader thread panicked".to_string())?,
384    })
385}
386
387/// Build a `git` invocation that can never stop to ask the user anything.
388fn git(cwd: Option<&Path>, args: &[&str]) -> Command {
389    let mut cmd = Command::new("git");
390    cmd.env("GIT_TERMINAL_PROMPT", "0");
391    if let Some(dir) = cwd {
392        cmd.current_dir(dir);
393    }
394    cmd.args(args);
395    cmd
396}
397
398fn run_git(cwd: Option<&Path>, args: &[&str], timeout: Duration) -> Result<BoundedOutput, String> {
399    output_with_timeout(&mut git(cwd, args), timeout)
400}
401
402/// Run git, require exit 0, and return trimmed stdout. The error names the
403/// operation, the exit status, and git's stderr — the CLI and Settings UI
404/// surface this string verbatim.
405fn run_git_ok(
406    cwd: Option<&Path>,
407    args: &[&str],
408    timeout: Duration,
409    what: &str,
410) -> Result<String, String> {
411    let out = run_git(cwd, args, timeout)?;
412    if !out.status.success() {
413        let stderr = out.stderr.trim();
414        return Err(format!(
415            "{what} failed: {}{}",
416            out.status,
417            if stderr.is_empty() {
418                String::new()
419            } else {
420                format!(": {stderr}")
421            }
422        ));
423    }
424    Ok(out.stdout.trim().to_string())
425}
426
427// Tests run real git against file:// remotes built in temp directories — no
428// network. The shell-out fixtures need unix (exec-bit, sh); the pure-logic
429// ones run everywhere git exists.
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::manifest::discover_plugins;
434    use std::fs;
435    use std::path::PathBuf;
436    use tempfile::TempDir;
437
438    /// Minimal valid panel-plugin manifest (the same shape as manifest.rs's
439    /// MINIMAL_PANEL_MANIFEST fixture). `{id}` is replaced per fixture.
440    const PANEL_MANIFEST: &str = r#"{
441        "schemaVersion": 1,
442        "id": "{id}",
443        "name": "Test Panel",
444        "version": "0.1.0",
445        "kinds": ["panel"],
446        "entryPoints": { "panel": { "command": "panel.sh", "args": [] } }
447    }"#;
448
449    /// Parses as a manifest but fails validation: unknown kind.
450    const BOGUS_KIND_MANIFEST: &str = r#"{
451        "schemaVersion": 1,
452        "id": "{id}",
453        "name": "Bogus",
454        "version": "0.1.0",
455        "kinds": ["not-a-kind"],
456        "entryPoints": { "panel": { "command": "panel.sh", "args": [] } }
457    }"#;
458
459    fn write_exec_file(path: &Path) {
460        fs::write(path, "#!/bin/sh\nsleep 30\n").expect("write entry");
461        #[cfg(unix)]
462        {
463            use std::os::unix::fs::PermissionsExt;
464            let mut perms = fs::metadata(path).expect("entry metadata").permissions();
465            perms.set_mode(0o755);
466            fs::set_permissions(path, perms).expect("chmod entry");
467        }
468    }
469
470    fn git_ok(cwd: &Path, args: &[&str]) -> String {
471        run_git_ok(Some(cwd), args, READ_TIMEOUT, "git").expect("git fixture command")
472    }
473
474    fn git_commit(cwd: &Path, msg: &str) {
475        git_ok(
476            cwd,
477            &[
478                "-c",
479                "user.name=par-term-test",
480                "-c",
481                "user.email=test@par-term.test",
482                "commit",
483                "--quiet",
484                "-m",
485                msg,
486            ],
487        );
488    }
489
490    /// Create a source git repository at `<tmp>/src` holding one valid
491    /// panel plugin with the given id, committed; return its path.
492    fn source_repo(tmp: &Path, id: &str, manifest: &str) -> PathBuf {
493        let src = tmp.join("src");
494        fs::create_dir_all(&src).expect("create src");
495        fs::write(src.join("manifest.json"), manifest.replace("{id}", id)).expect("write manifest");
496        write_exec_file(&src.join("panel.sh"));
497        git_ok(&src, &["init", "--quiet"]);
498        git_ok(&src, &["add", "-A"]);
499        git_commit(&src, "initial plugin");
500        src
501    }
502
503    fn file_url(path: &Path) -> String {
504        format!("file://{}", path.display())
505    }
506
507    /// One prepared world per test: a source repo plus an empty plugins
508    /// root, siblings under a temp directory.
509    struct Fixture {
510        _tmp: TempDir,
511        src: PathBuf,
512        root: PathBuf,
513    }
514
515    fn fixture(id: &str, manifest: &str) -> Fixture {
516        let tmp = TempDir::new().expect("temp dir");
517        let src = source_repo(tmp.path(), id, manifest);
518        let root = tmp.path().join("plugins");
519        Fixture {
520            _tmp: tmp,
521            src,
522            root,
523        }
524    }
525
526    fn installed_plugin(fx: &Fixture) -> PathBuf {
527        fx.root.join("com.test.panel")
528    }
529
530    #[test]
531    fn add_clones_into_place_and_lands_discoverable() {
532        let fx = fixture("com.test.panel", PANEL_MANIFEST);
533        let added = add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
534        assert_eq!(added.manifest.id, "com.test.panel");
535        // Landed under the manifest id, discoverable, git-installed.
536        assert!(installed_plugin(&fx).is_dir());
537        let (plugins, warnings) = discover_plugins(&fx.root);
538        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
539        assert_eq!(plugins.len(), 1);
540        assert_eq!(plugins[0].manifest.id, "com.test.panel");
541        assert!(is_git_installed(&installed_plugin(&fx)));
542    }
543
544    #[test]
545    fn add_refuses_an_existing_target_directory() {
546        let fx = fixture("com.test.panel", PANEL_MANIFEST);
547        let existing = installed_plugin(&fx);
548        fs::create_dir_all(&existing).expect("pre-create target");
549        fs::write(existing.join("keep.txt"), "hand-copied").expect("seed target");
550        let err = add(&fx.root, &file_url(&fx.src)).expect_err("must refuse");
551        assert!(
552            err.contains("already exists"),
553            "error should name the refusal: {err}"
554        );
555        assert!(
556            err.contains(existing.display().to_string().as_str()),
557            "error should name the target directory: {err}"
558        );
559        // The hand-placed directory is untouched.
560        assert_eq!(
561            fs::read_to_string(existing.join("keep.txt")).unwrap(),
562            "hand-copied"
563        );
564    }
565
566    #[test]
567    fn add_refuses_non_url_sources() {
568        let fx = fixture("com.test.panel", PANEL_MANIFEST);
569        for bad in ["/absolute/path", "relative/dir", "~/plugin", "."] {
570            let err = add(&fx.root, bad).expect_err("local paths must be refused");
571            assert!(
572                err.contains("not a git URL"),
573                "error for `{bad}` should state the refusal: {err}"
574            );
575        }
576    }
577
578    #[test]
579    fn add_surfaces_a_clone_failure_cleanly() {
580        let fx = fixture("com.test.panel", PANEL_MANIFEST);
581        let nowhere = fx._tmp.path().join("does-not-exist");
582        let err = add(&fx.root, &file_url(&nowhere)).expect_err("clone must fail");
583        assert!(err.contains("git clone failed"), "unexpected error: {err}");
584        assert!(!installed_plugin(&fx).exists());
585    }
586
587    #[test]
588    fn add_removes_everything_it_staged_when_validation_fails() {
589        let fx = fixture("com.test.bad", BOGUS_KIND_MANIFEST);
590        let err = add(&fx.root, &file_url(&fx.src)).expect_err("must refuse");
591        assert!(
592            err.contains("failed validation"),
593            "error should carry the validation reason: {err}"
594        );
595        assert!(
596            err.contains("unknown kinds"),
597            "error should carry the validation reason: {err}"
598        );
599        // Neither the plugin nor the staging clone is left behind.
600        assert!(!fx.root.join("com.test.bad").exists());
601        let leftovers: Vec<_> = fs::read_dir(&fx.root)
602            .expect("plugins root exists")
603            .filter_map(|e| e.ok())
604            .map(|e| e.file_name().to_string_lossy().into_owned())
605            .collect();
606        assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
607    }
608
609    #[test]
610    fn update_fetch_reports_a_diff_then_applies_a_fast_forward() {
611        let fx = fixture("com.test.panel", PANEL_MANIFEST);
612        add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
613
614        // Upstream gains a commit touching one new file.
615        fs::write(fx.src.join("feature.txt"), "new upstream feature").expect("write feature");
616        git_ok(&fx.src, &["add", "-A"]);
617        git_commit(&fx.src, "add feature");
618
619        let dir = installed_plugin(&fx);
620        let preview = update_fetch(&dir).expect("fetch should succeed");
621        assert!(!preview.up_to_date);
622        assert_ne!(preview.current, preview.incoming);
623        assert!(
624            preview.diff.contains("feature.txt"),
625            "diff should name the changed file: {}",
626            preview.diff
627        );
628        assert!(!dir.join("feature.txt").exists(), "not applied yet");
629
630        let new_head = update_apply(&dir).expect("apply should fast-forward");
631        assert_eq!(new_head, preview.incoming);
632        assert!(dir.join("feature.txt").exists());
633    }
634
635    #[test]
636    fn update_reports_up_to_date_when_nothing_new() {
637        let fx = fixture("com.test.panel", PANEL_MANIFEST);
638        add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
639        let preview = update_fetch(&installed_plugin(&fx)).expect("fetch should succeed");
640        assert!(preview.up_to_date);
641        assert_eq!(preview.current, preview.incoming);
642    }
643
644    #[test]
645    fn update_refuses_a_history_rewrite_without_resetting() {
646        let fx = fixture("com.test.panel", PANEL_MANIFEST);
647        add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
648
649        // Rewrite upstream's root commit: no ancestry with the installed one.
650        git_ok(
651            &fx.src,
652            &[
653                "-c",
654                "user.name=par-term-test",
655                "-c",
656                "user.email=test@par-term.test",
657                "commit",
658                "--quiet",
659                "--amend",
660                "-m",
661                "rewritten history",
662            ],
663        );
664
665        let dir = installed_plugin(&fx);
666        let preview = update_fetch(&dir).expect("fetch still succeeds");
667        assert!(!preview.up_to_date);
668        let err = update_apply(&dir).expect_err("must refuse a non-fast-forward");
669        assert!(
670            err.contains("nothing was changed"),
671            "unexpected error: {err}"
672        );
673        assert!(
674            err.contains("remove and re-add"),
675            "error should name the remedy: {err}"
676        );
677        // The installed plugin is untouched.
678        assert!(dir.join("manifest.json").exists());
679        let head = git_ok(&dir, &["rev-parse", "--short", "HEAD"]);
680        assert_eq!(head, preview.current);
681    }
682
683    #[test]
684    fn remove_deletes_a_git_installed_plugin() {
685        let fx = fixture("com.test.panel", PANEL_MANIFEST);
686        add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
687        let dir = installed_plugin(&fx);
688        assert!(dir.is_dir());
689        remove(&fx.root, "com.test.panel").expect("remove should succeed");
690        assert!(!dir.exists());
691        let (plugins, _) = discover_plugins(&fx.root);
692        assert!(plugins.is_empty());
693    }
694
695    #[test]
696    fn remove_refuses_a_hand_copied_directory() {
697        let fx = fixture("com.test.panel", PANEL_MANIFEST);
698        let dir = installed_plugin(&fx);
699        fs::create_dir_all(&dir).expect("create dir");
700        fs::write(
701            dir.join("manifest.json"),
702            PANEL_MANIFEST.replace("{id}", "com.test.panel"),
703        )
704        .expect("write manifest");
705        write_exec_file(&dir.join("panel.sh"));
706
707        let err = remove(&fx.root, "com.test.panel").expect_err("must refuse");
708        assert!(
709            err.contains("not installed by plugin add"),
710            "unexpected error: {err}"
711        );
712        assert!(dir.is_dir(), "the hand-copied plugin must survive");
713    }
714
715    #[test]
716    fn remove_refuses_a_bare_git_init_without_a_remote() {
717        let fx = fixture("com.test.panel", PANEL_MANIFEST);
718        let dir = installed_plugin(&fx);
719        fs::create_dir_all(&dir).expect("create dir");
720        git_ok(&dir, &["init", "--quiet"]);
721
722        let err = remove(&fx.root, "com.test.panel").expect_err("must refuse");
723        assert!(
724            err.contains("no `origin` remote"),
725            "unexpected error: {err}"
726        );
727        assert!(dir.is_dir(), "the local repo must survive");
728    }
729
730    #[test]
731    fn remove_names_a_missing_plugin() {
732        let fx = fixture("com.test.panel", PANEL_MANIFEST);
733        let err = remove(&fx.root, "com.test.nope").expect_err("must refuse");
734        assert!(
735            err.contains("no plugin directory"),
736            "unexpected error: {err}"
737        );
738    }
739
740    #[test]
741    fn is_git_installed_is_false_for_plain_directories() {
742        let fx = fixture("com.test.panel", PANEL_MANIFEST);
743        assert!(!is_git_installed(&fx.root));
744        assert!(!is_git_installed(&fx.src)); // a repo, but not a plugin home
745    }
746
747    #[cfg(unix)]
748    #[test]
749    fn a_command_that_outlives_its_deadline_is_killed() {
750        let mut cmd = Command::new("sleep");
751        cmd.arg("30");
752        let err = output_with_timeout(&mut cmd, Duration::from_millis(500))
753            .expect_err("sleep 30 must exceed a 500ms deadline");
754        assert!(err.contains("timed out"), "unexpected error: {err}");
755    }
756
757    #[cfg(unix)]
758    #[test]
759    fn git_invocations_carry_no_terminal_prompt() {
760        // A `!` alias makes git run our shell command with ITS environment,
761        // so printing the variable proves what every plugin_git invocation
762        // sees — the setting that keeps a private-repo URL from blocking the
763        // GPU process on a credential prompt.
764        let tmp = TempDir::new().expect("temp dir");
765        let out = run_git_ok(
766            Some(tmp.path()),
767            &[
768                "-c",
769                r#"alias.prompt-check=!sh -c 'printf %s "$GIT_TERMINAL_PROMPT"'"#,
770                "prompt-check",
771            ],
772            READ_TIMEOUT,
773            "git alias",
774        )
775        .expect("alias should run");
776        assert_eq!(out, "0");
777    }
778}