1use std::fs;
10use std::path::{Component, Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use sha2::{Digest, Sha256};
15
16use crate::format::CommitPrecondition;
17use crate::git::{Git, GitError, nul_fields};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Refusal {
22 pub message: String,
24 pub paths: Vec<(String, String)>,
26 pub hint: String,
28 pub code: &'static str,
30}
31
32impl Refusal {
33 fn new(message: impl Into<String>, hint: impl Into<String>) -> Self {
34 Self {
35 code: "GIT",
36 message: message.into(),
37 paths: Vec::new(),
38 hint: hint.into(),
39 }
40 }
41}
42
43impl From<GitError> for Refusal {
44 fn from(e: GitError) -> Self {
45 Self::new(
46 e.0,
47 "check that git is installed and the repository is healthy",
48 )
49 }
50}
51
52#[must_use]
55pub fn state_dir() -> Option<PathBuf> {
56 let var = |k| {
57 std::env::var_os(k)
58 .filter(|v| !v.is_empty())
59 .map(PathBuf::from)
60 };
61 var("SPECCODER_STATE_DIR")
62 .or_else(|| var("XDG_STATE_HOME").map(|p| p.join("speccoder")))
63 .or_else(|| var("HOME").map(|h| h.join(".local/state/speccoder")))
64}
65
66pub fn repo_state_dir(state: &Path, repo: &Git) -> Result<PathBuf, GitError> {
69 let common = repo.line(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
70 let canonical = fs::canonicalize(&common).unwrap_or_else(|_| PathBuf::from(&common));
71 let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
72 let id: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
73 Ok(state.join(id))
74}
75
76#[must_use]
79pub fn project_state_dir(state: &Path, project: &Path) -> PathBuf {
80 let git = Git::at(project);
81 if let Ok(Some(_)) = git.toplevel()
82 && let Ok(dir) = repo_state_dir(state, &git)
83 {
84 return dir;
85 }
86 let canonical = fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
87 let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
88 let id: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
89 state.join(id)
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Worktree {
95 pub path: PathBuf,
97 pub branch: String,
99 pub base: String,
101 pub created: bool,
103}
104
105#[derive(Debug)]
109pub struct Scratch {
110 repo: PathBuf,
111 pub path: PathBuf,
113}
114
115pub fn scratch(checkout: &Path, under: &Path) -> Result<Scratch, GitError> {
117 let git = Git::at(checkout);
118 let top = git
119 .toplevel()?
120 .ok_or_else(|| GitError("not a git repository".to_owned()))?;
121 fs::create_dir_all(under).map_err(|e| GitError(format!("{}: {e}", under.display())))?;
122 let nanos = std::time::SystemTime::now()
123 .duration_since(std::time::UNIX_EPOCH)
124 .map_or(0, |d| d.as_nanos());
125 let path = under.join(format!("scratch-{}-{nanos}", std::process::id()));
126 let path_str = path.display().to_string();
127 Git::at(&top).run(&["worktree", "add", "--quiet", "--detach", &path_str, "HEAD"])?;
128 Ok(Scratch { repo: top, path })
129}
130
131impl Drop for Scratch {
132 fn drop(&mut self) {
133 crate::prepare::forget(&self.path);
135 crate::include::forget(&self.path);
136 let path = self.path.display().to_string();
137 let git = Git::at(&self.repo);
138 let _ = git.run(&["worktree", "remove", "--force", &path]);
139 let _ = fs::remove_dir_all(&self.path);
140 let _ = git.run(&["worktree", "prune"]);
141 }
142}
143
144pub fn ensure_worktree(
149 repo_root: &Path,
150 state: &Path,
151 feature: &str,
152 committed: impl Into<CommitPrecondition>,
153) -> Result<Worktree, Refusal> {
154 let artifacts = &committed.into();
155 let repo = Git::at(repo_root);
156 let Some(_head) = repo.head()? else {
157 return Err(Refusal {
158 code: "NO-REPOSITORY",
159 ..Refusal::new(
160 "the repository has no commits yet; a worktree needs one",
161 artifacts.no_commits_hint,
162 )
163 });
164 };
165 require_committed(&repo, artifacts)?;
166
167 let branch = format!("speccoder/{feature}");
168 let path = repo_state_dir(state, &repo)?
169 .join("worktrees")
170 .join(feature);
171
172 repo.run(&["worktree", "prune"])?;
174 if let Some(existing) = worktree_for(&repo, &branch)? {
175 bring_spec_changes(&repo, &existing, &branch, artifacts)?;
176 let base = Git::at(&existing).line(&["rev-parse", "HEAD"])?;
177 return Ok(Worktree {
178 path: existing,
179 branch,
180 base,
181 created: false,
182 });
183 }
184 if path.exists() {
185 return Err(Refusal::new(
186 format!(
187 "{} exists but is not a worktree of this repository",
188 path.display()
189 ),
190 "move it away; speccoder does not delete directories it did not create",
191 ));
192 }
193 if let Some(parent) = path.parent() {
194 fs::create_dir_all(parent).map_err(|e| {
195 Refusal::new(
196 format!("{}: {e}", parent.display()),
197 "check permissions of the state directory",
198 )
199 })?;
200 }
201 let path_str = path.to_string_lossy().into_owned();
202 let branch_ref = format!("refs/heads/{branch}");
203 if repo.succeeds(&["rev-parse", "--verify", "-q", &branch_ref])? {
204 repo.run(&["worktree", "add", "--quiet", &path_str, &branch])?;
208 bring_spec_changes(
209 &repo,
210 &fs::canonicalize(&path).unwrap_or(path.clone()),
211 &branch,
212 artifacts,
213 )?;
214 } else {
215 repo.run(&[
216 "worktree", "add", "--quiet", "-b", &branch, &path_str, "HEAD",
217 ])?;
218 }
219 let path = fs::canonicalize(&path).unwrap_or(path);
220 let base = Git::at(&path).line(&["rev-parse", "HEAD"])?;
221 Ok(Worktree {
222 path,
223 branch,
224 base,
225 created: true,
226 })
227}
228
229fn bring_spec_changes(
234 repo: &Git,
235 worktree: &Path,
236 branch: &str,
237 artifacts: &CommitPrecondition,
238) -> Result<(), Refusal> {
239 let head = repo.line(&["rev-parse", "HEAD"])?;
240 let base = repo.line(&["merge-base", branch, &head])?;
241 let mut paths = artifacts.paths();
245 paths.extend(SETUP.iter().map(|p| (*p).to_owned()));
246 let mut diff = vec!["diff", "--quiet", &base, &head, "--"];
247 diff.extend(paths.iter().map(String::as_str));
248 let moved = !repo.succeeds(&diff)?;
249 if !moved {
250 return Ok(());
251 }
252 let wt = Git::at(worktree);
253 for lock in LOCKFILES {
256 if worktree.join(lock).is_file()
257 && wt
258 .line(&["ls-files", "--error-unmatch", "--", lock])
259 .is_err()
260 && repo.succeeds(&["cat-file", "-e", &format!("{head}:{lock}")])?
261 {
262 let _ = fs::remove_file(worktree.join(lock));
263 }
264 }
265 let merged = wt.run(&[
266 "-c",
267 "user.name=speccoder",
268 "-c",
269 "user.email=speccoder@users.noreply.invalid",
270 "-c",
271 "commit.gpgsign=false",
272 "merge",
273 "-q",
274 "--no-edit",
275 "-m",
276 "speccoder: bring in the spec and setup changes from the checkout",
277 &head,
278 ]);
279 let merged = merged.or_else(|e| resolve_task_lists(worktree).map_err(|()| e));
283 if let Err(e) = merged {
284 let _ = wt.run(&["merge", "--abort"]);
285 return Err(Refusal::new(
286 format!(
287 "the spec changed in your checkout and could not be merged into {branch}: {}",
288 e.0.lines().next().unwrap_or_default()
289 ),
290 format!(
291 "merge it by hand in {} (git merge {head}), then run again",
292 worktree.display()
293 ),
294 ));
295 }
296 Ok(())
297}
298
299fn resolve_task_lists(worktree: &Path) -> Result<Vec<u8>, ()> {
306 let wt = Git::at(worktree);
307 let conflicted = wt
308 .line(&["diff", "--name-only", "--diff-filter=U"])
309 .map_err(|_| ())?;
310 let files: Vec<&str> = conflicted.lines().filter(|l| !l.is_empty()).collect();
311 if files.is_empty() {
312 return Err(());
313 }
314 for f in files {
315 let side = |stage: u8| {
316 wt.run(&["show", &format!(":{stage}:{f}")])
317 .map(|b| String::from_utf8_lossy(&b).into_owned())
318 .map_err(|_| ())
319 };
320 let (base, ours, theirs) = (side(1)?, side(2)?, side(3)?);
321 if !only_ticked(&base, &ours) {
322 return Err(());
323 }
324 let text = keep_ticks(&ours, &theirs);
325 fs::write(worktree.join(f), text).map_err(|_| ())?;
326 wt.run(&["add", "--", f]).map_err(|_| ())?;
327 }
328 wt.run(&[
329 "-c",
330 "user.name=speccoder",
331 "-c",
332 "user.email=speccoder@users.noreply.invalid",
333 "-c",
334 "commit.gpgsign=false",
335 "commit",
336 "-q",
337 "--no-edit",
338 ])
339 .map_err(|_| ())
340}
341
342#[must_use]
344pub fn only_ticked(base: &str, ours: &str) -> bool {
345 let untick = |l: &str| {
346 l.replacen("- [x] ", "- [ ] ", 1)
347 .replacen("- [X] ", "- [ ] ", 1)
348 };
349 base != ours && base.lines().map(untick).eq(ours.lines().map(untick))
350}
351
352#[must_use]
355pub fn keep_ticks(ours: &str, theirs: &str) -> String {
356 let words = |l: &str, ticked: bool| -> Option<String> {
357 let t = l.trim_start();
358 let rest = if ticked {
359 t.strip_prefix("- [x] ")
360 .or_else(|| t.strip_prefix("- [X] "))
361 } else {
362 t.strip_prefix("- [ ] ")
363 }?;
364 Some(rest.trim_end().to_owned())
365 };
366 let ticked: std::collections::BTreeSet<String> =
367 ours.lines().filter_map(|l| words(l, true)).collect();
368 let mut out: Vec<String> = theirs
369 .lines()
370 .map(|l| match words(l, false) {
371 Some(w) if ticked.contains(&w) => l.replacen("- [ ] ", "- [x] ", 1),
372 _ => l.to_owned(),
373 })
374 .collect();
375 if theirs.ends_with('\n') {
376 out.push(String::new());
377 }
378 out.join("\n")
379}
380
381#[must_use]
383pub fn lane_branch(feature: &str, lane: &str) -> String {
384 format!("speccoder-lane/{feature}/{lane}")
385}
386
387pub fn ensure_lane(
392 repo_root: &Path,
393 state: &Path,
394 feature: &str,
395 lane: &str,
396 start: &str,
397) -> Result<Worktree, Refusal> {
398 let repo = Git::at(repo_root);
399 let branch = lane_branch(feature, lane);
400 let path = repo_state_dir(state, &repo)?
401 .join("worktrees")
402 .join(format!("{feature}@{lane}"));
403 remove_lane(repo_root, feature, lane)?;
404 if path.exists() {
405 return Err(Refusal::new(
406 format!(
407 "{} exists but is not a worktree of this repository",
408 path.display()
409 ),
410 "move it away; speccoder does not delete directories it did not create",
411 ));
412 }
413 if let Some(parent) = path.parent() {
414 let _ = fs::create_dir_all(parent);
415 }
416 let path_str = path.to_string_lossy().into_owned();
417 repo.run(&[
418 "worktree", "add", "--quiet", "-B", &branch, &path_str, start,
419 ])?;
420 let path = fs::canonicalize(&path).unwrap_or(path);
421 Ok(Worktree {
422 path,
423 branch,
424 base: start.to_owned(),
425 created: true,
426 })
427}
428
429pub fn remove_lane(repo_root: &Path, feature: &str, lane: &str) -> Result<(), GitError> {
431 let repo = Git::at(repo_root);
432 let branch = lane_branch(feature, lane);
433 repo.run(&["worktree", "prune"])?;
434 if let Some(existing) = worktree_for(&repo, &branch)? {
435 crate::prepare::forget(&existing);
437 crate::include::forget(&existing);
438 repo.run(&["worktree", "remove", "--force", &existing.to_string_lossy()])?;
439 }
440 if repo.succeeds(&[
441 "rev-parse",
442 "--verify",
443 "-q",
444 &format!("refs/heads/{branch}"),
445 ])? {
446 repo.run(&["branch", "-q", "-D", &branch])?;
447 }
448 Ok(())
449}
450
451pub fn worktree_for(repo: &Git, branch: &str) -> Result<Option<PathBuf>, GitError> {
453 let fields = nul_fields(&repo.run(&["worktree", "list", "--porcelain", "-z"])?);
454 let wanted = format!("branch refs/heads/{branch}");
455 let mut current: Option<PathBuf> = None;
456 for f in fields {
457 if let Some(p) = f.strip_prefix("worktree ") {
458 current = Some(PathBuf::from(p));
459 } else if f == wanted {
460 return Ok(current);
461 }
462 }
463 Ok(None)
464}
465
466#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct Artifacts {
469 pub specify_dir: String,
471 pub feature_dir: String,
473}
474
475impl From<&Artifacts> for CommitPrecondition {
476 fn from(a: &Artifacts) -> Self {
477 crate::format::speckit::precondition(&a.specify_dir, &a.feature_dir)
478 }
479}
480
481fn require_committed(repo: &Git, a: &CommitPrecondition) -> Result<(), Refusal> {
485 let mut paths: Vec<(String, String)> = Vec::new();
486
487 for dir in &a.strict {
488 let out = repo.run(&[
489 "status",
490 "--porcelain=v1",
491 "-z",
492 "--untracked-files=all",
493 "--ignored=traditional",
494 "--",
495 dir,
496 ])?;
497 paths.extend(status_entries(&out));
498 }
499 for dir in &a.tracked {
500 let out = repo.run(&[
501 "status",
502 "--porcelain=v1",
503 "-z",
504 "--untracked-files=all",
505 "--",
506 dir,
507 ])?;
508 paths.extend(
509 status_entries(&out)
510 .into_iter()
511 .filter(|(p, _)| !(a.runtime)(p)),
512 );
513 }
514
515 for p in &a.essentials {
518 let tracked = !repo.run(&["ls-files", "--", p])?.is_empty();
519 if !tracked && repo.dir().join(p).exists() && !paths.iter().any(|(q, _)| q == p) {
520 let reason = if repo.succeeds(&["check-ignore", "-q", "--", p])? {
521 "ignored"
522 } else {
523 "untracked"
524 };
525 paths.push((p.clone(), reason.to_owned()));
526 }
527 }
528
529 if paths.is_empty() {
530 return Ok(());
531 }
532 paths.sort();
533 paths.dedup();
534 Err(Refusal {
535 code: "UNCOMMITTED-INTENT",
536 message: a.message.to_owned(),
537 paths,
538 hint: a.hint.to_owned(),
539 })
540}
541
542fn status_entries(out: &[u8]) -> Vec<(String, String)> {
544 let mut entries = Vec::new();
545 let mut fields = nul_fields(out).into_iter();
546 while let Some(f) = fields.next() {
547 if f.len() < 4 {
548 continue;
549 }
550 let (code, path) = f.split_at(3);
551 let code = code.trim_end();
552 let reason = match code {
553 "??" => "untracked",
554 "!!" => "ignored",
555 _ => "modified",
556 };
557 if code.starts_with('R') || code.starts_with('C') {
559 fields.next();
560 }
561 entries.push((path.to_owned(), reason.to_owned()));
562 }
563 entries
564}
565
566#[must_use]
568pub fn build_output(argv: &[String]) -> &'static [&'static str] {
569 let program = argv
570 .first()
571 .map(|a| a.rsplit('/').next().unwrap_or(a))
572 .unwrap_or_default();
573 match program {
574 "cargo" => &["target/"],
575 "npm" | "npx" | "pnpm" | "yarn" | "bun" => &["node_modules/"],
576 "pytest" | "python" | "python3" | "uv" => &[".pytest_cache/", "__pycache__/"],
577 _ => &[],
578 }
579}
580
581pub const SETUP: &[&str] = &[
584 "speccoder.toml",
585 ".config/nextest.toml",
586 ".gitignore",
587 "Cargo.lock",
588 "package-lock.json",
589 "pnpm-lock.yaml",
590 "yarn.lock",
591 "uv.lock",
592 "poetry.lock",
593 "go.sum",
594];
595
596pub const LOCKFILES: &[&str] = &[
598 "Cargo.lock",
599 "package-lock.json",
600 "pnpm-lock.yaml",
601 "yarn.lock",
602 "uv.lock",
603 "poetry.lock",
604 "go.sum",
605];
606
607#[derive(Debug, Default, PartialEq, Eq)]
611pub struct Leftovers {
612 pub ignore: Vec<String>,
614 pub commit: Vec<String>,
616}
617
618impl Leftovers {
619 #[must_use]
621 pub fn is_empty(&self) -> bool {
622 self.ignore.is_empty() && self.commit.is_empty()
623 }
624
625 #[must_use]
629 pub fn fix(&self, checkout: &Path, worktree: &Path, also: &[String]) -> String {
630 let mut steps = Vec::new();
631 for f in &self.commit {
632 if !checkout.join(f).exists() {
633 steps.push(format!("cp '{}' .", worktree.join(f).display()));
634 }
635 }
636 if !self.ignore.is_empty() {
637 steps.push(format!(
638 "printf '{}\\n' >> .gitignore",
639 self.ignore.join("\\n")
640 ));
641 }
642 let mut add: Vec<&str> = also
643 .iter()
644 .chain(&self.commit)
645 .map(String::as_str)
646 .collect();
647 if !self.ignore.is_empty() {
648 add.push(".gitignore");
649 }
650 steps.push(format!(
651 "git add {} && git commit -m \"speccoder: setup\"",
652 add.join(" ")
653 ));
654 steps.join(" && ")
655 }
656}
657
658#[must_use]
660pub fn leftovers(dir: &Path) -> Leftovers {
661 const BUILD: [&str; 4] = ["target", "node_modules", ".pytest_cache", "__pycache__"];
662 let Ok(out) = Git::at(dir).run(&["status", "--porcelain=v1", "--untracked-files=normal"])
663 else {
664 return Leftovers::default();
665 };
666 let mut found = Leftovers::default();
667 for p in String::from_utf8_lossy(&out)
668 .lines()
669 .filter_map(|l| l.strip_prefix("?? "))
670 {
671 let p = p.trim_end_matches('/');
672 let first = p.split('/').next().unwrap_or(p);
673 if BUILD.contains(&first) {
674 found.ignore.push(format!("{first}/"));
675 } else if LOCKFILES.contains(&p) {
676 found.commit.push(p.to_owned());
677 }
678 }
679 found.ignore.sort();
680 found.ignore.dedup();
681 found
682}
683
684pub fn require_clean(dir: &Path) -> Result<(), Refusal> {
686 let out = Git::at(dir).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])?;
687 let paths = status_entries(&out);
688 if paths.is_empty() {
689 Ok(())
690 } else {
691 Err(Refusal {
692 code: "DIRTY-CHECKOUT",
693 message: "the working tree has uncommitted changes".to_owned(),
694 paths,
695 hint: "commit or stash them first".to_owned(),
696 })
697 }
698}
699
700#[derive(Debug, Clone, PartialEq, Eq)]
704pub struct Checkpoint(pub String);
705
706#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
708pub enum ChangeKind {
709 Added,
711 Modified,
713 Deleted,
715}
716
717#[derive(Debug, Clone, PartialEq, Eq)]
719pub struct Change {
720 pub path: String,
722 pub kind: ChangeKind,
724}
725
726struct TempIndex(PathBuf);
728
729impl TempIndex {
730 fn new(tmp: &Path) -> Result<Self, GitError> {
731 static COUNTER: AtomicU64 = AtomicU64::new(0);
732 fs::create_dir_all(tmp).map_err(|e| GitError(format!("{}: {e}", tmp.display())))?;
733 let nanos = SystemTime::now()
734 .duration_since(UNIX_EPOCH)
735 .map(|d| d.as_nanos())
736 .unwrap_or(0);
737 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
738 Ok(Self(
739 tmp.join(format!("index-{}-{nanos}-{n}", std::process::id())),
740 ))
741 }
742}
743
744impl Drop for TempIndex {
745 fn drop(&mut self) {
746 let _ = fs::remove_file(&self.0);
748 }
749}
750
751pub fn checkpoint(dir: &Path, tmp: &Path) -> Result<Checkpoint, GitError> {
754 let git = Git::at(dir);
755 let index = TempIndex::new(tmp)?;
756 let real = git.git_path("index")?;
758 if real.exists() {
759 fs::copy(&real, &index.0).map_err(|e| GitError(format!("copy index: {e}")))?;
760 } else if git.head()?.is_some() {
761 git.run_with_index(&index.0, &["read-tree", "HEAD"], None)?;
762 } else {
763 git.run_with_index(&index.0, &["read-tree", "--empty"], None)?;
764 }
765 let excluded = git
768 .toplevel()?
769 .map(|top| crate::include::pathspecs(&top))
770 .unwrap_or_default();
771 let mut add = vec!["add", "-A", "--", ":/"];
772 add.extend(excluded.iter().map(String::as_str));
773 git.run_with_index(&index.0, &add, None)?;
774 let tree = git.run_with_index(&index.0, &["write-tree"], None)?;
775 Ok(Checkpoint(String::from_utf8_lossy(&tree).trim().to_owned()))
776}
777
778pub fn diff(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<Vec<Change>, GitError> {
780 let out = Git::at(dir).run(&[
781 "diff-tree",
782 "-r",
783 "-z",
784 "--no-renames",
785 "--name-status",
786 &from.0,
787 &to.0,
788 ])?;
789 let fields = nul_fields(&out);
790 let mut changes = Vec::new();
791 for pair in fields.chunks(2) {
792 let [status, path] = pair else { continue };
793 let kind = match status.chars().next() {
794 Some('A') => ChangeKind::Added,
795 Some('D') => ChangeKind::Deleted,
796 _ => ChangeKind::Modified,
797 };
798 changes.push(Change {
799 path: path.clone(),
800 kind,
801 });
802 }
803 Ok(changes)
804}
805
806pub fn files(dir: &Path, checkpoint: &Checkpoint) -> Result<Vec<String>, GitError> {
808 let out = Git::at(dir).run(&["ls-tree", "-r", "--name-only", "-z", &checkpoint.0])?;
809 Ok(nul_fields(&out))
810}
811
812pub fn patch(dir: &Path, from: &Checkpoint, to: &Checkpoint) -> Result<String, GitError> {
814 let out = Git::at(dir).run(&["diff", "--no-color", "--no-renames", "-U0", &from.0, &to.0])?;
815 Ok(String::from_utf8_lossy(&out).into_owned())
816}
817
818pub fn restore(dir: &Path, tmp: &Path, target: &Checkpoint) -> Result<Vec<Change>, GitError> {
822 let git = Git::at(dir);
823 let root = git
824 .toplevel()?
825 .ok_or_else(|| GitError(format!("{} is not in a git repository", dir.display())))?;
826 let current = checkpoint(&root, tmp)?;
827 let changes = diff(&root, target, ¤t)?;
828 if changes.is_empty() {
829 return Ok(changes);
830 }
831
832 let mut write: Vec<u8> = Vec::new();
835 let mut remove: Vec<&str> = Vec::new();
836 for c in &changes {
837 if !is_plain_relative(&c.path) {
838 return Err(GitError(format!(
839 "refusing to restore unsafe path {}",
840 c.path
841 )));
842 }
843 match c.kind {
844 ChangeKind::Added => remove.push(&c.path),
845 ChangeKind::Deleted | ChangeKind::Modified => {
846 write.extend_from_slice(c.path.as_bytes());
847 write.push(0);
848 }
849 }
850 }
851 let rgit = Git::at(&root);
852 for path in &remove {
853 let full = root.join(path);
854 if fs::symlink_metadata(&full).is_ok() {
857 fs::remove_file(&full).map_err(|e| GitError(format!("remove {path}: {e}")))?;
858 prune_empty_parents(&root, &full);
859 }
860 }
861 if !write.is_empty() {
862 let index = TempIndex::new(tmp)?;
863 rgit.run_with_index(&index.0, &["read-tree", &target.0], None)?;
864 rgit.run_with_index(
865 &index.0,
866 &["checkout-index", "-f", "-z", "--stdin"],
867 Some(&write),
868 )?;
869 }
870 Ok(changes)
871}
872
873fn is_plain_relative(path: &str) -> bool {
874 let p = Path::new(path);
875 !path.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))
876}
877
878fn prune_empty_parents(root: &Path, removed: &Path) {
880 let mut dir = removed.parent();
881 while let Some(d) = dir {
882 if d == root || !d.starts_with(root) || fs::remove_dir(d).is_err() {
883 break;
884 }
885 dir = d.parent();
886 }
887}