1use crate::Result;
2use git2::Repository;
3use std::io::{self, Write};
4use std::path::Path;
5
6fn is_stdout_tty() -> bool {
7 atty::is(atty::Stream::Stdout)
8}
9
10fn confirm_or_abort(message: &str) -> Result<()> {
11 eprint!("{} [y/n] ", message);
12 io::stderr().flush()?;
13
14 let mut input = String::new();
15 io::stdin().read_line(&mut input)?;
16
17 let normalized = input.trim().to_lowercase();
18 if normalized == "y" || normalized == "yes" {
19 Ok(())
20 } else {
21 Err(crate::RstaskError::Other("Aborted.".to_string()))
22 }
23}
24
25pub fn ensure_repo_exists(repo_path: &Path) -> Result<bool> {
26 if std::process::Command::new("git")
28 .arg("--version")
29 .output()
30 .is_err()
31 {
32 return Err(crate::RstaskError::Other(
33 "git required, please install".to_string(),
34 ));
35 }
36
37 let git_dir = repo_path.join(".git");
38
39 if !git_dir.exists() {
40 if is_stdout_tty() {
41 confirm_or_abort(&format!(
42 "Could not find dstask repository at {} -- create?",
43 repo_path.display()
44 ))?;
45 }
46
47 std::fs::create_dir_all(repo_path)?;
48 Repository::init(repo_path)?;
49
50 return Ok(true);
52 }
53 Ok(false)
54}
55
56pub fn git_commit(repo_path: &Path, message: &str, quiet: bool) -> Result<String> {
57 use std::process::{Command, Stdio};
58
59 let objects_dir = repo_path.join(".git/objects");
61 let brand_new = if let Ok(entries) = std::fs::read_dir(&objects_dir) {
62 entries.count() <= 2
63 } else {
64 return Err(crate::RstaskError::Other(
65 "failed to read git objects directory".to_string(),
66 ));
67 };
68
69 let mut add_cmd = Command::new("git");
71 add_cmd.args(["-C", &repo_path.to_string_lossy(), "add", "."]);
72 if quiet {
73 add_cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
74 }
75
76 if quiet {
77 let add_output = add_cmd.output()?;
78 if !add_output.status.success() {
79 let stderr = String::from_utf8_lossy(&add_output.stderr);
80 return Err(crate::RstaskError::Other(format!(
81 "git add failed: {}",
82 stderr.trim()
83 )));
84 }
85 } else {
86 let add_status = add_cmd.status()?;
87 if !add_status.success() {
88 return Err(crate::RstaskError::Other("git add failed".to_string()));
89 }
90 }
91
92 if !brand_new {
94 let mut diff_cmd = Command::new("git");
95 diff_cmd.args([
96 "-C",
97 &repo_path.to_string_lossy(),
98 "diff-index",
99 "--quiet",
100 "HEAD",
101 "--",
102 ]);
103 if quiet {
104 diff_cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
105 }
106
107 if quiet {
108 if let Ok(output) = diff_cmd.output()
109 && output.status.success()
110 {
111 return Ok("no changes".to_string());
112 }
113 } else if let Ok(status) = diff_cmd.status()
114 && status.success()
115 {
116 println!("No changes detected");
117 return Ok("no changes".to_string());
118 }
119 }
120
121 let mut commit_cmd = Command::new("git");
123 commit_cmd.args([
124 "-C",
125 &repo_path.to_string_lossy(),
126 "commit",
127 "--no-gpg-sign",
128 "-m",
129 message,
130 ]);
131 if quiet {
132 commit_cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
133 }
134
135 if quiet {
136 let commit_output = commit_cmd.output()?;
137 if !commit_output.status.success() {
138 let stderr = String::from_utf8_lossy(&commit_output.stderr);
139 return Err(crate::RstaskError::Other(format!(
140 "git commit failed: {}",
141 stderr.trim()
142 )));
143 }
144 let stdout = String::from_utf8_lossy(&commit_output.stdout);
146 let summary = stdout
147 .lines()
148 .find(|line| line.contains("changed"))
149 .map(|line| line.trim().to_string())
150 .unwrap_or_else(|| "committed".to_string());
151 Ok(summary)
152 } else {
153 let commit_status = commit_cmd.status()?;
154 if !commit_status.success() {
155 return Err(crate::RstaskError::Other("git commit failed".to_string()));
156 }
157 Ok("committed".to_string())
158 }
159}
160
161fn get_current_branch(repo_path: &str) -> Result<String> {
162 use std::process::Command;
163
164 let output = Command::new("git")
165 .args(["-C", repo_path, "branch", "--show-current"])
166 .output()?;
167
168 if !output.status.success() {
169 return Err(crate::RstaskError::Other(
170 "failed to get current branch".to_string(),
171 ));
172 }
173
174 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
175
176 if branch.is_empty() {
177 return Err(crate::RstaskError::Other("not on a branch".to_string()));
178 }
179
180 Ok(branch)
181}
182
183fn has_upstream_branch(repo_path: &str, branch: &str) -> Result<bool> {
184 use std::process::Command;
185
186 let output = Command::new("git")
187 .args([
188 "-C",
189 repo_path,
190 "rev-parse",
191 "--abbrev-ref",
192 &format!("{}@{{upstream}}", branch),
193 ])
194 .output()?;
195
196 Ok(output.status.success())
197}
198
199fn has_remote(repo_path: &str) -> Result<bool> {
200 use std::process::Command;
201
202 let output = Command::new("git")
203 .args(["-C", repo_path, "remote"])
204 .output()?;
205
206 if !output.status.success() {
207 return Ok(false);
208 }
209
210 let remotes = String::from_utf8_lossy(&output.stdout);
211 Ok(!remotes.trim().is_empty())
212}
213
214pub fn git_pull(repo_path: &str, quiet: bool) -> Result<String> {
215 use std::process::{Command, Stdio};
216
217 if !has_remote(repo_path)? {
219 return Err(crate::RstaskError::Other(
220 "No remote configured. Add a remote with: rstask git remote add origin <url>"
221 .to_string(),
222 ));
223 }
224
225 let branch = get_current_branch(repo_path)?;
227
228 let has_upstream = has_upstream_branch(repo_path, &branch)?;
230
231 let mut cmd = if has_upstream {
232 let mut c = Command::new("git");
233 c.args([
234 "-C",
235 repo_path,
236 "pull",
237 "--ff",
238 "--no-rebase",
239 "--no-edit",
240 "--commit",
241 "--allow-unrelated-histories",
242 ]);
243 c
244 } else {
245 let mut c = Command::new("git");
246 c.args([
247 "-C",
248 repo_path,
249 "pull",
250 "--set-upstream",
251 "origin",
252 &branch,
253 "--ff",
254 "--no-rebase",
255 "--no-edit",
256 "--commit",
257 "--allow-unrelated-histories",
258 ]);
259 c
260 };
261
262 if quiet {
263 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
264 }
265
266 if quiet {
267 let output = cmd.output()?;
268 if !output.status.success() {
269 let stderr = String::from_utf8_lossy(&output.stderr);
270 return Err(crate::RstaskError::Other(format!(
271 "git pull failed: {}",
272 stderr.trim()
273 )));
274 }
275 let stdout = String::from_utf8_lossy(&output.stdout);
276 let summary = if stdout.trim() == "Already up to date."
277 || stdout.trim() == "Already up-to-date."
278 {
279 "up to date".to_string()
280 } else {
281 let file_count = stdout.lines().filter(|l| l.contains('|')).count();
282 if file_count > 0 {
283 format!("pulled {} file(s)", file_count)
284 } else {
285 "pulled".to_string()
286 }
287 };
288 Ok(summary)
289 } else {
290 let status = cmd.status()?;
291 if !status.success() {
292 return Err(crate::RstaskError::Other(
293 "git pull failed. Make sure the remote is set up correctly with: rstask git remote add origin <url>".to_string()
294 ));
295 }
296 Ok("pulled".to_string())
297 }
298}
299
300pub fn git_push(repo_path: &str, quiet: bool) -> Result<String> {
301 use std::process::{Command, Stdio};
302
303 if !has_remote(repo_path)? {
305 return Err(crate::RstaskError::Other(
306 "No remote configured. Add a remote with: rstask git remote add origin <url>"
307 .to_string(),
308 ));
309 }
310
311 let branch = get_current_branch(repo_path)?;
313
314 let has_upstream = has_upstream_branch(repo_path, &branch)?;
316
317 let mut cmd = if has_upstream {
318 let mut c = Command::new("git");
319 c.args(["-C", repo_path, "push"]);
320 c
321 } else {
322 let mut c = Command::new("git");
323 c.args(["-C", repo_path, "push", "-u", "origin", &branch]);
324 c
325 };
326
327 if quiet {
328 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
329 }
330
331 if quiet {
332 let output = cmd.output()?;
333 if !output.status.success() {
334 let stderr = String::from_utf8_lossy(&output.stderr);
335 return Err(crate::RstaskError::Other(format!(
336 "git push failed: {}",
337 stderr.trim()
338 )));
339 }
340 let stderr = String::from_utf8_lossy(&output.stderr);
342 let summary = if stderr.contains("Everything up-to-date") {
343 "already pushed".to_string()
344 } else {
345 "pushed".to_string()
346 };
347 Ok(summary)
348 } else {
349 let status = cmd.status()?;
350 if !status.success() {
351 return Err(crate::RstaskError::Other("git push failed".to_string()));
352 }
353 Ok("pushed".to_string())
354 }
355}
356
357pub fn git_reset(repo_path: &Path) -> Result<()> {
358 let repo = Repository::open(repo_path)?;
359
360 let head = repo.head()?;
362 let head_commit = head.peel_to_commit()?;
363
364 let parent = head_commit.parent(0).map_err(|_| {
365 crate::RstaskError::Git(git2::Error::from_str("no parent commit to reset to"))
366 })?;
367
368 repo.reset(parent.as_object(), git2::ResetType::Hard, None)?;
369 Ok(())
370}
371
372