Skip to main content

rstask_core/
git.rs

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    // Check for git required
27    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 true to indicate repo was just created
51        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    // Check if repo is brand new (needed before diff-index to avoid missing HEAD error)
60    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    // Add all files
70    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    // Check for changes -- only if repo has commits (to avoid missing HEAD error)
93    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    // Commit
122    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        // Parse the commit output to extract a short summary
145        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    // Check if a remote is configured
218    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    // Get current branch name
226    let branch = get_current_branch(repo_path)?;
227
228    // Check if upstream is set
229    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    // Check if a remote is configured
304    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    // Get current branch name
312    let branch = get_current_branch(repo_path)?;
313
314    // Check if upstream is set
315    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        // git push output goes to stderr
341        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    // Reset to HEAD~1 (one commit back)
361    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