Skip to main content

cli/
git_pull.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use anyhow::{Context, Result, bail};
6use tokio::process::Command;
7
8use crate::colors;
9use crate::config::Config;
10
11#[derive(Debug, Eq, PartialEq)]
12struct PullTarget {
13    label: &'static str,
14    path: PathBuf,
15}
16
17/// Pull each Git repository that contributes to the effective preset namespace.
18pub async fn handle_pull(config: &Config, verbose: bool) -> Result<()> {
19    let overlay_git = config.overlay_git_source();
20
21    let targets = configured_targets(config);
22    let mut repositories = Vec::new();
23    let mut seen = HashSet::new();
24
25    for target in targets {
26        match repository_root(&target.path).await? {
27            Some(root) if seen.insert(root.clone()) => repositories.push((target.label, root)),
28            Some(root) if verbose => println!(
29                "  {} {} ({})",
30                colors::dim("skipped"),
31                target.label,
32                colors::dim(&format!("same repository: {}", root.display()))
33            ),
34            Some(_) => {}
35            None if verbose => println!(
36                "  {} {} ({})",
37                colors::dim("skipped"),
38                target.label,
39                colors::dim(&format!("not a Git repository: {}", target.path.display()))
40            ),
41            None => {}
42        }
43    }
44
45    if overlay_git.is_none() && repositories.is_empty() {
46        println!("{}", colors::dim("Nothing to pull."));
47        return Ok(());
48    }
49
50    // Validate every fast-forward repository before changing any of them.
51    for (label, root) in &repositories {
52        ensure_clean(label, root).await?;
53        ensure_tracking_branch(label, root).await?;
54    }
55
56    println!("{}", colors::bold("Preset Sources"));
57
58    // The shine-managed Git overlay syncs first: cloned on first use, then
59    // force-mirrored to the remote tip on every subsequent pull.
60    if let Some((url, branch, dir)) = overlay_git {
61        sync_managed_overlay(url, branch, dir, verbose).await?;
62    }
63
64    for (label, root) in repositories {
65        if verbose {
66            println!("Pulling {label} from {} ...", root.display());
67        }
68        let summary = pull_ff_only(&root, verbose).await?;
69        print_pull_summary(label, &summary);
70    }
71
72    Ok(())
73}
74
75fn configured_targets(config: &Config) -> Vec<PullTarget> {
76    let mut targets = vec![PullTarget {
77        label: "preset source",
78        path: config.presets_dir().to_path_buf(),
79    }];
80    // Only a *manually linked* overlay is fast-forward-pulled here. A
81    // shine-managed Git overlay (`presets_overlay_git`) is force-mirrored
82    // separately by `sync_managed_overlay` and must never go through the
83    // clean-worktree / fast-forward path.
84    if let Some(path) = config.presets_overlay_dir_override.as_deref() {
85        targets.push(PullTarget {
86            label: "overlay source",
87            path: path.to_path_buf(),
88        });
89    }
90    targets
91}
92
93async fn repository_root(path: &Path) -> Result<Option<PathBuf>> {
94    let output = Command::new("git")
95        .args(["rev-parse", "--show-toplevel"])
96        .current_dir(path)
97        .output()
98        .await
99        .with_context(|| "failed to run git; install Git and ensure it is available in PATH")?;
100
101    if !output.status.success() {
102        let detail = String::from_utf8_lossy(&output.stderr);
103        if detail.contains("not a git repository") {
104            return Ok(None);
105        }
106        bail!(
107            "failed to inspect Git repository at {}: {}",
108            path.display(),
109            detail.trim()
110        );
111    }
112
113    let root =
114        String::from_utf8(output.stdout).context("git returned a non-UTF-8 repository path")?;
115    let root = root.trim();
116    if root.is_empty() {
117        bail!(
118            "git returned an empty repository root for {}",
119            path.display()
120        );
121    }
122    Ok(Some(PathBuf::from(root)))
123}
124
125async fn ensure_clean(label: &str, root: &Path) -> Result<()> {
126    let output = git_output(root, &["status", "--porcelain=v1", "--untracked-files=all"]).await?;
127    if !output.status.success() {
128        let detail = String::from_utf8_lossy(&output.stderr);
129        bail!(
130            "failed to inspect {label} repository {}: {}",
131            root.display(),
132            detail.trim()
133        );
134    }
135    if !output.stdout.is_empty() {
136        bail!(
137            "refusing to pull {label}: Git worktree has uncommitted changes: {}\nCommit, stash, or discard the changes, then run 'shine preset pull' again.",
138            root.display()
139        );
140    }
141    Ok(())
142}
143
144async fn ensure_tracking_branch(label: &str, root: &Path) -> Result<()> {
145    let branch = git_output(root, &["symbolic-ref", "--quiet", "--short", "HEAD"]).await?;
146    if !branch.status.success() {
147        bail!(
148            "refusing to pull {label}: repository is in detached HEAD state: {}",
149            root.display()
150        );
151    }
152
153    let upstream = git_output(
154        root,
155        &[
156            "rev-parse",
157            "--abbrev-ref",
158            "--symbolic-full-name",
159            "@{upstream}",
160        ],
161    )
162    .await?;
163    if !upstream.status.success() {
164        let branch = String::from_utf8_lossy(&branch.stdout);
165        bail!(
166            "refusing to pull {label}: branch '{}' has no upstream: {}",
167            branch.trim(),
168            root.display()
169        );
170    }
171    Ok(())
172}
173
174#[derive(Debug, PartialEq, Eq)]
175struct PullSummary {
176    before: String,
177    after: String,
178    shortstat: Option<String>,
179}
180
181impl PullSummary {
182    fn updated(&self) -> bool {
183        self.before != self.after
184    }
185}
186
187async fn pull_ff_only(root: &Path, verbose: bool) -> Result<PullSummary> {
188    let before = head_short(root).await?;
189    let mut command = Command::new("git");
190    command
191        .args(["pull", "--ff-only"])
192        .current_dir(root)
193        .stdin(Stdio::inherit());
194    let failure_detail = if verbose {
195        let status = command
196            .stdout(Stdio::inherit())
197            .stderr(Stdio::inherit())
198            .status()
199            .await
200            .with_context(|| "failed to run git; install Git and ensure it is available in PATH")?;
201        if status.success() {
202            None
203        } else {
204            Some(format!("status {status}"))
205        }
206    } else {
207        let output = command
208            .output()
209            .await
210            .with_context(|| "failed to run git; install Git and ensure it is available in PATH")?;
211        if output.status.success() {
212            None
213        } else {
214            let stdout = String::from_utf8_lossy(&output.stdout);
215            let stderr = String::from_utf8_lossy(&output.stderr);
216            Some(
217                [stdout.trim(), stderr.trim()]
218                    .into_iter()
219                    .filter(|part| !part.is_empty())
220                    .collect::<Vec<_>>()
221                    .join("\n"),
222            )
223        }
224    };
225    if let Some(detail) = failure_detail {
226        bail!(
227            "Git pull failed in {}: {}\nResolve the Git error, then run 'shine preset pull' again.",
228            root.display(),
229            detail
230        );
231    }
232
233    let after = head_short(root).await?;
234    let shortstat = if before == after {
235        None
236    } else {
237        let output = git_output(root, &["diff", "--shortstat", &before, &after]).await?;
238        output
239            .status
240            .success()
241            .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
242    }
243    .filter(|stat| !stat.is_empty());
244    Ok(PullSummary {
245        before,
246        after,
247        shortstat,
248    })
249}
250
251async fn head_short(root: &Path) -> Result<String> {
252    let output = git_output(root, &["rev-parse", "--short=7", "HEAD"]).await?;
253    if !output.status.success() {
254        bail!("failed to resolve Git HEAD in {}", root.display());
255    }
256    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
257}
258
259fn print_pull_summary(label: &str, summary: &PullSummary) {
260    if summary.updated() {
261        let stat = summary
262            .shortstat
263            .as_deref()
264            .map(|value| format!("  {}", colors::dim(value)))
265            .unwrap_or_default();
266        println!(
267            "  {}  {} updated  {} → {}{}",
268            colors::symbol("✓"),
269            label,
270            summary.before,
271            summary.after,
272            stat
273        );
274    } else {
275        println!(
276            "  {}  {} {}",
277            colors::symbol("✓"),
278            label,
279            colors::dim("up-to-date")
280        );
281    }
282}
283
284async fn git_output(root: &Path, args: &[&str]) -> Result<std::process::Output> {
285    Command::new("git")
286        .args(args)
287        .current_dir(root)
288        .output()
289        .await
290        .with_context(|| "failed to run git; install Git and ensure it is available in PATH")
291}
292
293/// Sync the shine-managed Git overlay. Cloned `--depth 1` on first use, then
294/// force-mirrored to the remote tip on every subsequent call.
295///
296/// The managed overlay is a read-only mirror, so this deliberately discards any
297/// local edits. Two invariants keep the *previous* checkout usable when a sync
298/// fails: the fetch runs before any local reset (a network failure never touches
299/// the working tree), and a first-time clone lands via a temp dir + atomic
300/// rename (a failed clone never leaves a half-populated overlay dir).
301pub(crate) async fn sync_managed_overlay(
302    url: &str,
303    branch: Option<&str>,
304    dir: &Path,
305    verbose: bool,
306) -> Result<()> {
307    if dir.exists() {
308        mirror_managed_overlay(url, branch, dir, verbose).await
309    } else {
310        clone_managed_overlay(url, branch, dir, verbose).await
311    }
312}
313
314async fn clone_managed_overlay(
315    url: &str,
316    branch: Option<&str>,
317    dir: &Path,
318    verbose: bool,
319) -> Result<()> {
320    let parent = dir
321        .parent()
322        .context("managed overlay path has no parent directory")?;
323    tokio::fs::create_dir_all(parent)
324        .await
325        .with_context(|| format!("failed to create {}", parent.display()))?;
326
327    let temp = temp_clone_path(dir)?;
328    if temp.exists() {
329        tokio::fs::remove_dir_all(&temp)
330            .await
331            .with_context(|| format!("failed to remove stale clone dir {}", temp.display()))?;
332    }
333
334    let temp_arg = temp.to_string_lossy().into_owned();
335    let mut args: Vec<&str> = vec!["clone", "--depth", "1"];
336    if let Some(branch) = branch {
337        args.push("--branch");
338        args.push(branch);
339    }
340    args.push(url);
341    args.push(&temp_arg);
342
343    if let Err(err) = run_git(parent, &args, verbose, "clone").await {
344        let _ = tokio::fs::remove_dir_all(&temp).await;
345        return Err(err);
346    }
347
348    tokio::fs::rename(&temp, dir).await.with_context(|| {
349        format!(
350            "failed to move cloned overlay into place at {}",
351            dir.display()
352        )
353    })?;
354
355    let after = head_short(dir).await?;
356    println!("  {}  overlay source cloned  {after}", colors::symbol("✓"));
357    Ok(())
358}
359
360async fn mirror_managed_overlay(
361    url: &str,
362    branch: Option<&str>,
363    dir: &Path,
364    verbose: bool,
365) -> Result<()> {
366    let branch = match branch {
367        Some(branch) => branch.to_string(),
368        None => current_branch(dir).await?,
369    };
370    let before = head_short(dir).await?;
371
372    // Fetch first, reset only on success: an unreachable remote leaves the
373    // existing checkout intact and usable.
374    run_git(
375        dir,
376        &["fetch", "--depth", "1", "origin", &branch],
377        verbose,
378        "fetch",
379    )
380    .await
381    .with_context(|| format!("failed to fetch managed overlay from {url}"))?;
382    run_git(dir, &["reset", "--hard", "FETCH_HEAD"], verbose, "reset").await?;
383
384    let after = head_short(dir).await?;
385    let shortstat = if before == after {
386        None
387    } else {
388        let output = git_output(dir, &["diff", "--shortstat", &before, &after]).await?;
389        output
390            .status
391            .success()
392            .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
393    }
394    .filter(|stat| !stat.is_empty());
395    print_pull_summary(
396        "overlay source",
397        &PullSummary {
398            before,
399            after,
400            shortstat,
401        },
402    );
403    Ok(())
404}
405
406/// Sibling temp path used to stage a fresh clone before atomically renaming it
407/// into place, so a failed clone never clobbers an existing good checkout.
408fn temp_clone_path(dir: &Path) -> Result<PathBuf> {
409    let name = dir
410        .file_name()
411        .context("managed overlay path has no final component")?;
412    let mut tmp = name.to_os_string();
413    tmp.push(".shine-clone-tmp");
414    Ok(dir.with_file_name(tmp))
415}
416
417async fn current_branch(dir: &Path) -> Result<String> {
418    let output = git_output(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).await?;
419    if !output.status.success() {
420        bail!("failed to resolve current branch in {}", dir.display());
421    }
422    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
423}
424
425/// Run a git command, inheriting stdin and (when verbose) stdout/stderr, and
426/// bail with captured output on a non-zero exit.
427async fn run_git(cwd: &Path, args: &[&str], verbose: bool, action: &str) -> Result<()> {
428    let mut command = Command::new("git");
429    command.args(args).current_dir(cwd).stdin(Stdio::inherit());
430    let failure_detail = if verbose {
431        let status = command
432            .stdout(Stdio::inherit())
433            .stderr(Stdio::inherit())
434            .status()
435            .await
436            .with_context(|| "failed to run git; install Git and ensure it is available in PATH")?;
437        if status.success() {
438            None
439        } else {
440            Some(format!("status {status}"))
441        }
442    } else {
443        let output = command
444            .output()
445            .await
446            .with_context(|| "failed to run git; install Git and ensure it is available in PATH")?;
447        if output.status.success() {
448            None
449        } else {
450            let stdout = String::from_utf8_lossy(&output.stdout);
451            let stderr = String::from_utf8_lossy(&output.stderr);
452            Some(
453                [stdout.trim(), stderr.trim()]
454                    .into_iter()
455                    .filter(|part| !part.is_empty())
456                    .collect::<Vec<_>>()
457                    .join("\n"),
458            )
459        }
460    };
461    if let Some(detail) = failure_detail {
462        bail!("git {action} failed: {detail}");
463    }
464    Ok(())
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use std::process::Command as StdCommand;
471
472    fn temp_dir(name: &str) -> PathBuf {
473        std::env::temp_dir().join(format!("shine-git-pull-{name}-{}", uuid::Uuid::new_v4()))
474    }
475
476    fn git(dir: &Path, args: &[&str]) {
477        let output = StdCommand::new("git")
478            .args(args)
479            .current_dir(dir)
480            .output()
481            .unwrap();
482        assert!(
483            output.status.success(),
484            "git {args:?} failed: {}",
485            String::from_utf8_lossy(&output.stderr)
486        );
487    }
488
489    fn init_repo(dir: &Path) {
490        std::fs::create_dir_all(dir).unwrap();
491        git(dir, &["init"]);
492        git(dir, &["config", "user.name", "Shine Tests"]);
493        git(dir, &["config", "user.email", "shine@example.invalid"]);
494        std::fs::write(dir.join("preset.txt"), "one\n").unwrap();
495        git(dir, &["add", "preset.txt"]);
496        git(dir, &["commit", "-m", "initial"]);
497    }
498
499    #[test]
500    fn configured_targets_are_ordered_preset_then_overlay() {
501        let dir = std::env::temp_dir().join("shine-pull-targets");
502        let config =
503            Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(dir.join("overlay")));
504        assert_eq!(
505            configured_targets(&config),
506            vec![
507                PullTarget {
508                    label: "preset source",
509                    path: dir.join("presets"),
510                },
511                PullTarget {
512                    label: "overlay source",
513                    path: dir.join("overlay"),
514                },
515            ]
516        );
517    }
518
519    #[tokio::test]
520    async fn non_git_directory_has_nothing_to_pull() {
521        let root = temp_dir("non-git");
522        std::fs::create_dir_all(root.join("presets")).unwrap();
523        let config = Config::new_for_test(&root);
524
525        handle_pull(&config, false).await.unwrap();
526
527        std::fs::remove_dir_all(root).unwrap();
528    }
529
530    #[tokio::test]
531    async fn dirty_worktree_is_rejected_before_pull() {
532        let root = temp_dir("dirty");
533        let presets = root.join("presets");
534        init_repo(&presets);
535        std::fs::write(presets.join("local.txt"), "dirty\n").unwrap();
536        let config = Config::new_for_test(&root);
537
538        let error = handle_pull(&config, false).await.unwrap_err();
539
540        assert!(error.to_string().contains("uncommitted changes"));
541        std::fs::remove_dir_all(root).unwrap();
542    }
543
544    #[tokio::test]
545    async fn branch_without_upstream_is_rejected() {
546        let root = temp_dir("no-upstream");
547        let presets = root.join("presets");
548        init_repo(&presets);
549        let config = Config::new_for_test(&root);
550
551        let error = handle_pull(&config, false).await.unwrap_err();
552
553        assert!(error.to_string().contains("has no upstream"));
554        std::fs::remove_dir_all(root).unwrap();
555    }
556
557    #[tokio::test]
558    async fn pulls_fast_forward_from_local_remote() {
559        let root = temp_dir("fast-forward");
560        let remote = root.join("remote.git");
561        let seed = root.join("seed");
562        let presets = root.join("presets");
563        std::fs::create_dir_all(&root).unwrap();
564        git(&root, &["init", "--bare", remote.to_str().unwrap()]);
565        git(
566            &root,
567            &["clone", remote.to_str().unwrap(), seed.to_str().unwrap()],
568        );
569        git(&seed, &["config", "user.name", "Shine Tests"]);
570        git(&seed, &["config", "user.email", "shine@example.invalid"]);
571        std::fs::write(seed.join("preset.txt"), "one\n").unwrap();
572        git(&seed, &["add", "preset.txt"]);
573        git(&seed, &["commit", "-m", "initial"]);
574        git(&seed, &["push", "-u", "origin", "HEAD"]);
575        git(
576            &root,
577            &["clone", remote.to_str().unwrap(), presets.to_str().unwrap()],
578        );
579        std::fs::write(seed.join("preset.txt"), "two\n").unwrap();
580        git(&seed, &["add", "preset.txt"]);
581        git(&seed, &["commit", "-m", "update"]);
582        git(&seed, &["push"]);
583        let config = Config::new_for_test(&root);
584
585        handle_pull(&config, false).await.unwrap();
586
587        assert_eq!(
588            std::fs::read_to_string(presets.join("preset.txt")).unwrap(),
589            "two\n"
590        );
591        std::fs::remove_dir_all(root).unwrap();
592    }
593
594    #[tokio::test]
595    async fn detached_head_is_rejected() {
596        let root = temp_dir("detached");
597        let presets = root.join("presets");
598        init_repo(&presets);
599        git(&presets, &["checkout", "--detach"]);
600        let config = Config::new_for_test(&root);
601
602        let error = handle_pull(&config, false).await.unwrap_err();
603
604        assert!(error.to_string().contains("detached HEAD"));
605        std::fs::remove_dir_all(root).unwrap();
606    }
607
608    /// Create a bare remote with a single `overlay.txt` commit and return
609    /// `(remote_path, seed_worktree, url)` for driving further commits.
610    fn seed_remote(root: &Path) -> (PathBuf, PathBuf, String) {
611        let remote = root.join("remote.git");
612        let seed = root.join("seed");
613        std::fs::create_dir_all(root).unwrap();
614        git(root, &["init", "--bare", remote.to_str().unwrap()]);
615        git(
616            root,
617            &["clone", remote.to_str().unwrap(), seed.to_str().unwrap()],
618        );
619        git(&seed, &["config", "user.name", "Shine Tests"]);
620        git(&seed, &["config", "user.email", "shine@example.invalid"]);
621        std::fs::write(seed.join("overlay.txt"), "one\n").unwrap();
622        git(&seed, &["add", "overlay.txt"]);
623        git(&seed, &["commit", "-m", "initial"]);
624        git(&seed, &["push", "-u", "origin", "HEAD"]);
625        let url = remote.to_string_lossy().into_owned();
626        (remote, seed, url)
627    }
628
629    fn commit_count(dir: &Path) -> String {
630        let output = StdCommand::new("git")
631            .args(["rev-list", "--count", "HEAD"])
632            .current_dir(dir)
633            .output()
634            .unwrap();
635        String::from_utf8_lossy(&output.stdout).trim().to_string()
636    }
637
638    #[tokio::test]
639    async fn managed_overlay_clones_shallow_then_force_mirrors() {
640        let root = temp_dir("managed-overlay");
641        let (_remote, seed, url) = seed_remote(&root);
642        let dir = root.join("overlay");
643
644        // First sync clones --depth 1: content present, history is a single commit.
645        sync_managed_overlay(&url, None, &dir, false).await.unwrap();
646        assert_eq!(
647            std::fs::read_to_string(dir.join("overlay.txt")).unwrap(),
648            "one\n"
649        );
650        assert_eq!(commit_count(&dir), "1");
651
652        // Fast-forward update on the remote is mirrored.
653        std::fs::write(seed.join("overlay.txt"), "two\n").unwrap();
654        git(&seed, &["add", "overlay.txt"]);
655        git(&seed, &["commit", "-m", "update"]);
656        git(&seed, &["push"]);
657        sync_managed_overlay(&url, None, &dir, false).await.unwrap();
658        assert_eq!(
659            std::fs::read_to_string(dir.join("overlay.txt")).unwrap(),
660            "two\n"
661        );
662
663        // Rewritten history (amend + force-push) still mirrors where a
664        // fast-forward pull would fail.
665        std::fs::write(seed.join("overlay.txt"), "three\n").unwrap();
666        git(&seed, &["add", "overlay.txt"]);
667        git(&seed, &["commit", "--amend", "-m", "rewritten"]);
668        git(&seed, &["push", "--force"]);
669        sync_managed_overlay(&url, None, &dir, false).await.unwrap();
670        assert_eq!(
671            std::fs::read_to_string(dir.join("overlay.txt")).unwrap(),
672            "three\n"
673        );
674
675        std::fs::remove_dir_all(root).unwrap();
676    }
677
678    #[tokio::test]
679    async fn managed_overlay_fetch_failure_keeps_existing_checkout() {
680        let root = temp_dir("managed-overlay-offline");
681        let (remote, _seed, url) = seed_remote(&root);
682        let dir = root.join("overlay");
683        sync_managed_overlay(&url, None, &dir, false).await.unwrap();
684
685        // Remote disappears → the next sync's fetch fails, but the existing
686        // checkout is left untouched and still usable.
687        std::fs::remove_dir_all(&remote).unwrap();
688        let error = sync_managed_overlay(&url, None, &dir, false)
689            .await
690            .unwrap_err();
691        assert!(error.to_string().contains("fetch"));
692        assert_eq!(
693            std::fs::read_to_string(dir.join("overlay.txt")).unwrap(),
694            "one\n"
695        );
696
697        std::fs::remove_dir_all(root).unwrap();
698    }
699
700    #[tokio::test]
701    async fn managed_overlay_failed_clone_leaves_no_dir() {
702        let root = temp_dir("managed-overlay-badurl");
703        std::fs::create_dir_all(&root).unwrap();
704        let dir = root.join("overlay");
705        let bogus = root.join("does-not-exist.git");
706
707        let error = sync_managed_overlay(&bogus.to_string_lossy(), None, &dir, false)
708            .await
709            .unwrap_err();
710        assert!(error.to_string().contains("clone"));
711        assert!(
712            !dir.exists(),
713            "a failed first clone must not leave a managed overlay dir"
714        );
715        assert!(
716            !temp_clone_path(&dir).unwrap().exists(),
717            "the staging temp dir must be cleaned up on clone failure"
718        );
719
720        std::fs::remove_dir_all(root).unwrap();
721    }
722}