Skip to main content

ralph/git/commit/
working_tree.rs

1//! Working-tree commit helpers.
2//!
3//! Purpose:
4//! - Implement commit creation and tracked-path restore helpers for git workflows.
5//!
6//! Responsibilities:
7//! - Revert uncommitted changes while preserving local `.env*` files.
8//! - Create commits after staging repository changes.
9//! - Force-add or restore specific repo-relative paths safely.
10//!
11//! Scope:
12//! - Working-tree and index mutations only.
13//! - Upstream queries and push flows live in sibling modules.
14//!
15//! Usage:
16//! - Re-exported through `crate::git::commit` and consumed by supervision/runtime helpers.
17//!
18//! Invariants/assumptions:
19//! - Empty commit messages and empty commits are rejected.
20//! - Path restores only operate on tracked files under the repo root.
21
22use anyhow::Context;
23use std::path::{Path, PathBuf};
24
25use crate::git::error::{GitError, git_output, git_run};
26use crate::git::status::status_porcelain;
27
28/// Revert uncommitted changes, restoring the working tree to current HEAD.
29///
30/// This discards ONLY uncommitted changes. It does NOT reset to a pre-run SHA.
31pub 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
42/// Create a commit with all changes.
43///
44/// Stages everything and creates a single commit with the given message.
45/// Returns an error if the message is empty or there are no changes to commit.
46pub 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
62/// Force-add existing paths, even if they are ignored.
63///
64/// Paths must be under the repo root; missing or outside paths are skipped.
65pub 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
76/// Restore tracked paths to the current HEAD (index + working tree).
77///
78/// Paths must be under the repo root; untracked paths are skipped.
79pub 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}