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
17pub 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 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 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 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
293pub(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 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
406fn 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
425async 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 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 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 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 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 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}