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    /// Fixture git commands take ~0.1s idle but have exceeded the 10s
471    /// READ_TIMEOUT under full-suite parallel load (load avg >100 observed
472    /// 2026-09-28); 60s still bounds a hung git.
473    const FIXTURE_TIMEOUT: Duration = Duration::from_secs(60);
474
475    fn git_ok(cwd: &Path, args: &[&str]) -> String {
476        run_git_ok(Some(cwd), args, FIXTURE_TIMEOUT, "git").expect("git fixture command")
477    }
478
479    fn git_commit(cwd: &Path, msg: &str) {
480        git_ok(
481            cwd,
482            &[
483                "-c",
484                "user.name=par-term-test",
485                "-c",
486                "user.email=test@par-term.test",
487                "commit",
488                "--quiet",
489                "-m",
490                msg,
491            ],
492        );
493    }
494
495    /// Create a source git repository at `<tmp>/src` holding one valid
496    /// panel plugin with the given id, committed; return its path.
497    fn source_repo(tmp: &Path, id: &str, manifest: &str) -> PathBuf {
498        let src = tmp.join("src");
499        fs::create_dir_all(&src).expect("create src");
500        fs::write(src.join("manifest.json"), manifest.replace("{id}", id)).expect("write manifest");
501        write_exec_file(&src.join("panel.sh"));
502        git_ok(&src, &["init", "--quiet"]);
503        git_ok(&src, &["add", "-A"]);
504        git_commit(&src, "initial plugin");
505        src
506    }
507
508    fn file_url(path: &Path) -> String {
509        format!("file://{}", path.display())
510    }
511
512    /// One prepared world per test: a source repo plus an empty plugins
513    /// root, siblings under a temp directory.
514    struct Fixture {
515        _tmp: TempDir,
516        src: PathBuf,
517        root: PathBuf,
518    }
519
520    fn fixture(id: &str, manifest: &str) -> Fixture {
521        let tmp = TempDir::new().expect("temp dir");
522        let src = source_repo(tmp.path(), id, manifest);
523        let root = tmp.path().join("plugins");
524        Fixture {
525            _tmp: tmp,
526            src,
527            root,
528        }
529    }
530
531    fn installed_plugin(fx: &Fixture) -> PathBuf {
532        fx.root.join("com.test.panel")
533    }
534
535    #[test]
536    fn add_clones_into_place_and_lands_discoverable() {
537        let fx = fixture("com.test.panel", PANEL_MANIFEST);
538        let added = add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
539        assert_eq!(added.manifest.id, "com.test.panel");
540        // Landed under the manifest id, discoverable, git-installed.
541        assert!(installed_plugin(&fx).is_dir());
542        let (plugins, warnings) = discover_plugins(&fx.root);
543        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
544        assert_eq!(plugins.len(), 1);
545        assert_eq!(plugins[0].manifest.id, "com.test.panel");
546        assert!(is_git_installed(&installed_plugin(&fx)));
547    }
548
549    #[test]
550    fn add_refuses_an_existing_target_directory() {
551        let fx = fixture("com.test.panel", PANEL_MANIFEST);
552        let existing = installed_plugin(&fx);
553        fs::create_dir_all(&existing).expect("pre-create target");
554        fs::write(existing.join("keep.txt"), "hand-copied").expect("seed target");
555        let err = add(&fx.root, &file_url(&fx.src)).expect_err("must refuse");
556        assert!(
557            err.contains("already exists"),
558            "error should name the refusal: {err}"
559        );
560        assert!(
561            err.contains(existing.display().to_string().as_str()),
562            "error should name the target directory: {err}"
563        );
564        // The hand-placed directory is untouched.
565        assert_eq!(
566            fs::read_to_string(existing.join("keep.txt")).unwrap(),
567            "hand-copied"
568        );
569    }
570
571    #[test]
572    fn add_refuses_non_url_sources() {
573        let fx = fixture("com.test.panel", PANEL_MANIFEST);
574        for bad in ["/absolute/path", "relative/dir", "~/plugin", "."] {
575            let err = add(&fx.root, bad).expect_err("local paths must be refused");
576            assert!(
577                err.contains("not a git URL"),
578                "error for `{bad}` should state the refusal: {err}"
579            );
580        }
581    }
582
583    #[test]
584    fn add_surfaces_a_clone_failure_cleanly() {
585        let fx = fixture("com.test.panel", PANEL_MANIFEST);
586        let nowhere = fx._tmp.path().join("does-not-exist");
587        let err = add(&fx.root, &file_url(&nowhere)).expect_err("clone must fail");
588        assert!(err.contains("git clone failed"), "unexpected error: {err}");
589        assert!(!installed_plugin(&fx).exists());
590    }
591
592    #[test]
593    fn add_removes_everything_it_staged_when_validation_fails() {
594        let fx = fixture("com.test.bad", BOGUS_KIND_MANIFEST);
595        let err = add(&fx.root, &file_url(&fx.src)).expect_err("must refuse");
596        assert!(
597            err.contains("failed validation"),
598            "error should carry the validation reason: {err}"
599        );
600        assert!(
601            err.contains("unknown kinds"),
602            "error should carry the validation reason: {err}"
603        );
604        // Neither the plugin nor the staging clone is left behind.
605        assert!(!fx.root.join("com.test.bad").exists());
606        let leftovers: Vec<_> = fs::read_dir(&fx.root)
607            .expect("plugins root exists")
608            .filter_map(|e| e.ok())
609            .map(|e| e.file_name().to_string_lossy().into_owned())
610            .collect();
611        assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
612    }
613
614    #[test]
615    fn update_fetch_reports_a_diff_then_applies_a_fast_forward() {
616        let fx = fixture("com.test.panel", PANEL_MANIFEST);
617        add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
618
619        // Upstream gains a commit touching one new file.
620        fs::write(fx.src.join("feature.txt"), "new upstream feature").expect("write feature");
621        git_ok(&fx.src, &["add", "-A"]);
622        git_commit(&fx.src, "add feature");
623
624        let dir = installed_plugin(&fx);
625        let preview = update_fetch(&dir).expect("fetch should succeed");
626        assert!(!preview.up_to_date);
627        assert_ne!(preview.current, preview.incoming);
628        assert!(
629            preview.diff.contains("feature.txt"),
630            "diff should name the changed file: {}",
631            preview.diff
632        );
633        assert!(!dir.join("feature.txt").exists(), "not applied yet");
634
635        let new_head = update_apply(&dir).expect("apply should fast-forward");
636        assert_eq!(new_head, preview.incoming);
637        assert!(dir.join("feature.txt").exists());
638    }
639
640    #[test]
641    fn update_reports_up_to_date_when_nothing_new() {
642        let fx = fixture("com.test.panel", PANEL_MANIFEST);
643        add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
644        let preview = update_fetch(&installed_plugin(&fx)).expect("fetch should succeed");
645        assert!(preview.up_to_date);
646        assert_eq!(preview.current, preview.incoming);
647    }
648
649    #[test]
650    fn update_refuses_a_history_rewrite_without_resetting() {
651        let fx = fixture("com.test.panel", PANEL_MANIFEST);
652        add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
653
654        // Rewrite upstream's root commit: no ancestry with the installed one.
655        git_ok(
656            &fx.src,
657            &[
658                "-c",
659                "user.name=par-term-test",
660                "-c",
661                "user.email=test@par-term.test",
662                "commit",
663                "--quiet",
664                "--amend",
665                "-m",
666                "rewritten history",
667            ],
668        );
669
670        let dir = installed_plugin(&fx);
671        let preview = update_fetch(&dir).expect("fetch still succeeds");
672        assert!(!preview.up_to_date);
673        let err = update_apply(&dir).expect_err("must refuse a non-fast-forward");
674        assert!(
675            err.contains("nothing was changed"),
676            "unexpected error: {err}"
677        );
678        assert!(
679            err.contains("remove and re-add"),
680            "error should name the remedy: {err}"
681        );
682        // The installed plugin is untouched.
683        assert!(dir.join("manifest.json").exists());
684        let head = git_ok(&dir, &["rev-parse", "--short", "HEAD"]);
685        assert_eq!(head, preview.current);
686    }
687
688    #[test]
689    fn remove_deletes_a_git_installed_plugin() {
690        let fx = fixture("com.test.panel", PANEL_MANIFEST);
691        add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
692        let dir = installed_plugin(&fx);
693        assert!(dir.is_dir());
694        remove(&fx.root, "com.test.panel").expect("remove should succeed");
695        assert!(!dir.exists());
696        let (plugins, _) = discover_plugins(&fx.root);
697        assert!(plugins.is_empty());
698    }
699
700    #[test]
701    fn remove_refuses_a_hand_copied_directory() {
702        let fx = fixture("com.test.panel", PANEL_MANIFEST);
703        let dir = installed_plugin(&fx);
704        fs::create_dir_all(&dir).expect("create dir");
705        fs::write(
706            dir.join("manifest.json"),
707            PANEL_MANIFEST.replace("{id}", "com.test.panel"),
708        )
709        .expect("write manifest");
710        write_exec_file(&dir.join("panel.sh"));
711
712        let err = remove(&fx.root, "com.test.panel").expect_err("must refuse");
713        assert!(
714            err.contains("not installed by plugin add"),
715            "unexpected error: {err}"
716        );
717        assert!(dir.is_dir(), "the hand-copied plugin must survive");
718    }
719
720    #[test]
721    fn remove_refuses_a_bare_git_init_without_a_remote() {
722        let fx = fixture("com.test.panel", PANEL_MANIFEST);
723        let dir = installed_plugin(&fx);
724        fs::create_dir_all(&dir).expect("create dir");
725        git_ok(&dir, &["init", "--quiet"]);
726
727        let err = remove(&fx.root, "com.test.panel").expect_err("must refuse");
728        assert!(
729            err.contains("no `origin` remote"),
730            "unexpected error: {err}"
731        );
732        assert!(dir.is_dir(), "the local repo must survive");
733    }
734
735    #[test]
736    fn remove_names_a_missing_plugin() {
737        let fx = fixture("com.test.panel", PANEL_MANIFEST);
738        let err = remove(&fx.root, "com.test.nope").expect_err("must refuse");
739        assert!(
740            err.contains("no plugin directory"),
741            "unexpected error: {err}"
742        );
743    }
744
745    #[test]
746    fn is_git_installed_is_false_for_plain_directories() {
747        let fx = fixture("com.test.panel", PANEL_MANIFEST);
748        assert!(!is_git_installed(&fx.root));
749        assert!(!is_git_installed(&fx.src)); // a repo, but not a plugin home
750    }
751
752    #[cfg(unix)]
753    #[test]
754    fn a_command_that_outlives_its_deadline_is_killed() {
755        let mut cmd = Command::new("sleep");
756        cmd.arg("30");
757        let err = output_with_timeout(&mut cmd, Duration::from_millis(500))
758            .expect_err("sleep 30 must exceed a 500ms deadline");
759        assert!(err.contains("timed out"), "unexpected error: {err}");
760    }
761
762    #[cfg(unix)]
763    #[test]
764    fn git_invocations_carry_no_terminal_prompt() {
765        // A `!` alias makes git run our shell command with ITS environment,
766        // so printing the variable proves what every plugin_git invocation
767        // sees — the setting that keeps a private-repo URL from blocking the
768        // GPU process on a credential prompt.
769        let tmp = TempDir::new().expect("temp dir");
770        let out = run_git_ok(
771            Some(tmp.path()),
772            &[
773                "-c",
774                r#"alias.prompt-check=!sh -c 'printf %s "$GIT_TERMINAL_PROMPT"'"#,
775                "prompt-check",
776            ],
777            FIXTURE_TIMEOUT,
778            "git alias",
779        )
780        .expect("alias should run");
781        assert_eq!(out, "0");
782    }
783}