ralph/git/commit/
working_tree.rs1use anyhow::Context;
23use std::path::{Path, PathBuf};
24
25use crate::git::error::{GitError, git_output, git_run};
26use crate::git::status::status_porcelain;
27
28pub fn revert_uncommitted(repo_root: &Path) -> Result<(), GitError> {
32 if git_run(repo_root, &["restore", "--staged", "--worktree", "."]).is_err() {
33 git_run(repo_root, &["checkout", "--", "."]).context("fallback git checkout -- .")?;
34 git_run(repo_root, &["reset", "--quiet", "HEAD"]).context("git reset --quiet HEAD")?;
35 }
36
37 git_run(repo_root, &["clean", "-fd", "-e", ".env", "-e", ".env.*"])
38 .context("git clean -fd -e .env*")?;
39 Ok(())
40}
41
42pub fn commit_all(repo_root: &Path, message: &str) -> Result<(), GitError> {
47 let message = message.trim();
48 if message.is_empty() {
49 return Err(GitError::EmptyCommitMessage);
50 }
51
52 git_run(repo_root, &["add", "-A"]).context("git add -A")?;
53 let status = status_porcelain(repo_root)?;
54 if status.trim().is_empty() {
55 return Err(GitError::NoChangesToCommit);
56 }
57
58 git_run(repo_root, &["commit", "-m", message]).context("git commit")?;
59 Ok(())
60}
61
62pub fn add_paths_force(repo_root: &Path, paths: &[PathBuf]) -> Result<(), GitError> {
66 let rel_paths = existing_repo_relative_paths(repo_root, paths);
67 if rel_paths.is_empty() {
68 return Ok(());
69 }
70
71 run_path_command(repo_root, &["add", "-f", "--"], &rel_paths)
72 .context("git add -f -- <paths>")?;
73 Ok(())
74}
75
76pub fn restore_tracked_paths_to_head(repo_root: &Path, paths: &[PathBuf]) -> Result<(), GitError> {
80 let rel_paths = tracked_repo_relative_paths(repo_root, paths)?;
81 if rel_paths.is_empty() {
82 return Ok(());
83 }
84
85 if run_path_command(
86 repo_root,
87 &["restore", "--staged", "--worktree", "--"],
88 &rel_paths,
89 )
90 .is_err()
91 {
92 run_path_command(repo_root, &["checkout", "--"], &rel_paths)
93 .context("fallback git checkout -- <paths>")?;
94 run_path_command(repo_root, &["reset", "--quiet", "HEAD", "--"], &rel_paths)
95 .context("git reset --quiet HEAD -- <paths>")?;
96 }
97
98 Ok(())
99}
100
101fn existing_repo_relative_paths(repo_root: &Path, paths: &[PathBuf]) -> Vec<String> {
102 repo_relative_paths(repo_root, paths, true)
103}
104
105fn tracked_repo_relative_paths(
106 repo_root: &Path,
107 paths: &[PathBuf],
108) -> Result<Vec<String>, GitError> {
109 let mut rel_paths = Vec::new();
110 for rel_path in repo_relative_paths(repo_root, paths, false) {
111 if is_tracked_path(repo_root, &rel_path)? {
112 rel_paths.push(rel_path);
113 } else {
114 log::debug!("Skipping restore for untracked path: {}", rel_path);
115 }
116 }
117 Ok(rel_paths)
118}
119
120fn repo_relative_paths(repo_root: &Path, paths: &[PathBuf], require_exists: bool) -> Vec<String> {
121 let mut rel_paths = Vec::new();
122 for path in paths {
123 if require_exists && !path.exists() {
124 continue;
125 }
126 let rel = match path.strip_prefix(repo_root) {
127 Ok(rel) => rel,
128 Err(_) => {
129 log::debug!("Skipping repo path outside repo root: {}", path.display());
130 continue;
131 }
132 };
133 if rel.as_os_str().is_empty() {
134 continue;
135 }
136 rel_paths.push(rel.to_string_lossy().to_string());
137 }
138 rel_paths
139}
140
141fn run_path_command(
142 repo_root: &Path,
143 base_args: &[&str],
144 rel_paths: &[String],
145) -> Result<(), GitError> {
146 let mut args: Vec<&str> = base_args.to_vec();
147 args.extend(rel_paths.iter().map(String::as_str));
148 git_run(repo_root, &args)?;
149 Ok(())
150}
151
152fn is_tracked_path(repo_root: &Path, rel_path: &str) -> Result<bool, GitError> {
153 let output = git_output(repo_root, &["ls-files", "--error-unmatch", "--", rel_path])
154 .with_context(|| {
155 format!(
156 "run git ls-files --error-unmatch for {} in {}",
157 rel_path,
158 repo_root.display()
159 )
160 })?;
161
162 if output.status.success() {
163 return Ok(true);
164 }
165
166 let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
167 if stderr.contains("pathspec") || stderr.contains("did not match any file") {
168 return Ok(false);
169 }
170
171 Err(GitError::CommandFailed {
172 args: format!("ls-files --error-unmatch -- {}", rel_path),
173 code: output.status.code(),
174 stderr: stderr.trim().to_string(),
175 })
176}