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 read_text(path: impl AsRef<Path>) -> String {
477 std::fs::read_to_string(path).unwrap().replace("\r\n", "\n")
478 }
479
480 fn git(dir: &Path, args: &[&str]) {
481 let output = StdCommand::new("git")
482 .args(args)
483 .current_dir(dir)
484 .output()
485 .unwrap();
486 assert!(
487 output.status.success(),
488 "git {args:?} failed: {}",
489 String::from_utf8_lossy(&output.stderr)
490 );
491 }
492
493 fn init_repo(dir: &Path) {
494 std::fs::create_dir_all(dir).unwrap();
495 git(dir, &["init"]);
496 git(dir, &["config", "user.name", "Shine Tests"]);
497 git(dir, &["config", "user.email", "shine@example.invalid"]);
498 std::fs::write(dir.join("preset.txt"), "one\n").unwrap();
499 git(dir, &["add", "preset.txt"]);
500 git(dir, &["commit", "-m", "initial"]);
501 }
502
503 #[test]
504 fn configured_targets_are_ordered_preset_then_overlay() {
505 let dir = std::env::temp_dir().join("shine-pull-targets");
506 let config =
507 Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(dir.join("overlay")));
508 assert_eq!(
509 configured_targets(&config),
510 vec![
511 PullTarget {
512 label: "preset source",
513 path: dir.join("presets"),
514 },
515 PullTarget {
516 label: "overlay source",
517 path: dir.join("overlay"),
518 },
519 ]
520 );
521 }
522
523 #[tokio::test]
524 async fn non_git_directory_has_nothing_to_pull() {
525 let root = temp_dir("non-git");
526 std::fs::create_dir_all(root.join("presets")).unwrap();
527 let config = Config::new_for_test(&root);
528
529 handle_pull(&config, false).await.unwrap();
530
531 std::fs::remove_dir_all(root).unwrap();
532 }
533
534 #[tokio::test]
535 async fn dirty_worktree_is_rejected_before_pull() {
536 let root = temp_dir("dirty");
537 let presets = root.join("presets");
538 init_repo(&presets);
539 std::fs::write(presets.join("local.txt"), "dirty\n").unwrap();
540 let config = Config::new_for_test(&root);
541
542 let error = handle_pull(&config, false).await.unwrap_err();
543
544 assert!(error.to_string().contains("uncommitted changes"));
545 std::fs::remove_dir_all(root).unwrap();
546 }
547
548 #[tokio::test]
549 async fn branch_without_upstream_is_rejected() {
550 let root = temp_dir("no-upstream");
551 let presets = root.join("presets");
552 init_repo(&presets);
553 let config = Config::new_for_test(&root);
554
555 let error = handle_pull(&config, false).await.unwrap_err();
556
557 assert!(error.to_string().contains("has no upstream"));
558 std::fs::remove_dir_all(root).unwrap();
559 }
560
561 #[tokio::test]
562 async fn pulls_fast_forward_from_local_remote() {
563 let root = temp_dir("fast-forward");
564 let remote = root.join("remote.git");
565 let seed = root.join("seed");
566 let presets = root.join("presets");
567 std::fs::create_dir_all(&root).unwrap();
568 git(&root, &["init", "--bare", remote.to_str().unwrap()]);
569 git(
570 &root,
571 &["clone", remote.to_str().unwrap(), seed.to_str().unwrap()],
572 );
573 git(&seed, &["config", "user.name", "Shine Tests"]);
574 git(&seed, &["config", "user.email", "shine@example.invalid"]);
575 std::fs::write(seed.join("preset.txt"), "one\n").unwrap();
576 git(&seed, &["add", "preset.txt"]);
577 git(&seed, &["commit", "-m", "initial"]);
578 git(&seed, &["push", "-u", "origin", "HEAD"]);
579 git(
580 &root,
581 &["clone", remote.to_str().unwrap(), presets.to_str().unwrap()],
582 );
583 std::fs::write(seed.join("preset.txt"), "two\n").unwrap();
584 git(&seed, &["add", "preset.txt"]);
585 git(&seed, &["commit", "-m", "update"]);
586 git(&seed, &["push"]);
587 let config = Config::new_for_test(&root);
588
589 handle_pull(&config, false).await.unwrap();
590
591 assert_eq!(read_text(presets.join("preset.txt")), "two\n");
592 std::fs::remove_dir_all(root).unwrap();
593 }
594
595 #[tokio::test]
596 async fn detached_head_is_rejected() {
597 let root = temp_dir("detached");
598 let presets = root.join("presets");
599 init_repo(&presets);
600 git(&presets, &["checkout", "--detach"]);
601 let config = Config::new_for_test(&root);
602
603 let error = handle_pull(&config, false).await.unwrap_err();
604
605 assert!(error.to_string().contains("detached HEAD"));
606 std::fs::remove_dir_all(root).unwrap();
607 }
608
609 fn seed_remote(root: &Path) -> (PathBuf, PathBuf, String) {
612 let remote = root.join("remote.git");
613 let seed = root.join("seed");
614 std::fs::create_dir_all(root).unwrap();
615 git(root, &["init", "--bare", remote.to_str().unwrap()]);
616 git(
617 root,
618 &["clone", remote.to_str().unwrap(), seed.to_str().unwrap()],
619 );
620 git(&seed, &["config", "user.name", "Shine Tests"]);
621 git(&seed, &["config", "user.email", "shine@example.invalid"]);
622 std::fs::write(seed.join("overlay.txt"), "one\n").unwrap();
623 git(&seed, &["add", "overlay.txt"]);
624 git(&seed, &["commit", "-m", "initial"]);
625 git(&seed, &["push", "-u", "origin", "HEAD"]);
626 let url = remote.to_string_lossy().into_owned();
627 (remote, seed, url)
628 }
629
630 fn commit_count(dir: &Path) -> String {
631 let output = StdCommand::new("git")
632 .args(["rev-list", "--count", "HEAD"])
633 .current_dir(dir)
634 .output()
635 .unwrap();
636 String::from_utf8_lossy(&output.stdout).trim().to_string()
637 }
638
639 #[tokio::test]
640 async fn managed_overlay_clones_shallow_then_force_mirrors() {
641 let root = temp_dir("managed-overlay");
642 let (_remote, seed, url) = seed_remote(&root);
643 let dir = root.join("overlay");
644
645 sync_managed_overlay(&url, None, &dir, false).await.unwrap();
647 assert_eq!(read_text(dir.join("overlay.txt")), "one\n");
648 assert_eq!(commit_count(&dir), "1");
649
650 std::fs::write(seed.join("overlay.txt"), "two\n").unwrap();
652 git(&seed, &["add", "overlay.txt"]);
653 git(&seed, &["commit", "-m", "update"]);
654 git(&seed, &["push"]);
655 sync_managed_overlay(&url, None, &dir, false).await.unwrap();
656 assert_eq!(read_text(dir.join("overlay.txt")), "two\n");
657
658 std::fs::write(seed.join("overlay.txt"), "three\n").unwrap();
661 git(&seed, &["add", "overlay.txt"]);
662 git(&seed, &["commit", "--amend", "-m", "rewritten"]);
663 git(&seed, &["push", "--force"]);
664 sync_managed_overlay(&url, None, &dir, false).await.unwrap();
665 assert_eq!(read_text(dir.join("overlay.txt")), "three\n");
666
667 std::fs::remove_dir_all(root).unwrap();
668 }
669
670 #[tokio::test]
671 async fn managed_overlay_fetch_failure_keeps_existing_checkout() {
672 let root = temp_dir("managed-overlay-offline");
673 let (remote, _seed, url) = seed_remote(&root);
674 let dir = root.join("overlay");
675 sync_managed_overlay(&url, None, &dir, false).await.unwrap();
676
677 std::fs::remove_dir_all(&remote).unwrap();
680 let error = sync_managed_overlay(&url, None, &dir, false)
681 .await
682 .unwrap_err();
683 assert!(error.to_string().contains("fetch"));
684 assert_eq!(read_text(dir.join("overlay.txt")), "one\n");
685
686 std::fs::remove_dir_all(root).unwrap();
687 }
688
689 #[tokio::test]
690 async fn managed_overlay_failed_clone_leaves_no_dir() {
691 let root = temp_dir("managed-overlay-badurl");
692 std::fs::create_dir_all(&root).unwrap();
693 let dir = root.join("overlay");
694 let bogus = root.join("does-not-exist.git");
695
696 let error = sync_managed_overlay(&bogus.to_string_lossy(), None, &dir, false)
697 .await
698 .unwrap_err();
699 assert!(error.to_string().contains("clone"));
700 assert!(
701 !dir.exists(),
702 "a failed first clone must not leave a managed overlay dir"
703 );
704 assert!(
705 !temp_clone_path(&dir).unwrap().exists(),
706 "the staging temp dir must be cleaned up on clone failure"
707 );
708
709 std::fs::remove_dir_all(root).unwrap();
710 }
711}