use std::path::Path;
use anyhow::{Context, Result, bail};
use crate::output::{
BranchDeleteOutput, CommitOutput, DotfileWarning, MergeOutput, OtherStagedMode,
WorktreeAddOutput, WorktreeRemoveOutput,
};
use crate::{GitModule, TIMEOUT_LOCAL, git_cmd};
impl GitModule {
pub async fn worktree_add(
&mut self,
name: &str,
branch: &str,
base_branch: Option<&str>,
) -> Result<WorktreeAddOutput> {
let wt_dir = self.worktrees_dir();
std::fs::create_dir_all(&wt_dir).with_context(|| {
format!("failed to create worktrees directory: {}", wt_dir.display())
})?;
let wt_path = wt_dir.join(name);
if wt_path.exists() {
bail!("worktree already exists: {}", wt_path.display());
}
let path_str = wt_path.to_str().unwrap_or(name);
if let Some(base) = base_branch {
git_cmd(
self.session().root(),
&["worktree", "add", "-b", branch, path_str, base],
TIMEOUT_LOCAL,
)
.await?;
} else {
git_cmd(
self.session().root(),
&["worktree", "add", "-b", branch, path_str],
TIMEOUT_LOCAL,
)
.await?;
}
let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
self.register_worktree(canon);
self.register_branch(branch.to_string());
Ok(WorktreeAddOutput {
path: wt_path,
branch: branch.to_string(),
session: self.session().id().to_string(),
})
}
pub async fn worktree_remove(&mut self, name: &str) -> Result<WorktreeRemoveOutput> {
let wt_path = self.worktrees_dir().join(name);
let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
self.ensure_owned(&canon)
.or_else(|_| self.ensure_owned(&wt_path))?;
git_cmd(
self.session().root(),
&[
"worktree",
"remove",
"--force",
wt_path.to_str().unwrap_or(name),
],
TIMEOUT_LOCAL,
)
.await?;
self.forget_worktree(&canon);
self.forget_worktree(&wt_path);
Ok(WorktreeRemoveOutput { path: wt_path })
}
pub async fn commit(
&self,
working_dir: &Path,
message: &str,
only: Option<&[String]>,
other_staged: OtherStagedMode,
force_dot: bool,
) -> Result<CommitOutput> {
self.ensure_session_scope(working_dir)?;
let (warnings, skipped) = match only {
None | Some([]) => {
let candidates = enumerate_changes(working_dir).await?;
if force_dot || !candidates.iter().any(|p| is_dotfile_path(p)) {
git_cmd(working_dir, &["add", "-A"], TIMEOUT_LOCAL).await?;
(Vec::new(), Vec::new())
} else {
let cls = classify_paths(working_dir, &candidates, false).await?;
stage_add_all(working_dir, &cls.stage).await?;
(cls.warnings, cls.skipped)
}
}
Some(ps) => {
let intruders = detect_other_staged(working_dir, ps).await?;
if !intruders.is_empty() {
match other_staged {
OtherStagedMode::Stop => {
bail!(
"commit aborted: index carries staged paths outside \
the requested set (other_staged=stop): {}",
intruders.join(", ")
);
}
OtherStagedMode::Restage => {
unstage(working_dir, &intruders).await?;
let cls = classify_paths(working_dir, ps, force_dot).await?;
stage(working_dir, &cls.stage).await?;
git_cmd(working_dir, &["commit", "-m", message], TIMEOUT_LOCAL).await?;
let output =
commit_output(working_dir, message, cls.warnings, cls.skipped)
.await;
stage(working_dir, &intruders).await?;
return output;
}
}
}
let cls = classify_paths(working_dir, ps, force_dot).await?;
stage(working_dir, &cls.stage).await?;
(cls.warnings, cls.skipped)
}
};
git_cmd(working_dir, &["commit", "-m", message], TIMEOUT_LOCAL).await?;
commit_output(working_dir, message, warnings, skipped).await
}
pub async fn merge(
&self,
branch: &str,
into_branch: &str,
working_dir: &Path,
) -> Result<MergeOutput> {
self.ensure_session_scope(working_dir)?;
let current = git_cmd(working_dir, &["branch", "--show-current"], TIMEOUT_LOCAL).await?;
if current != into_branch {
git_cmd(working_dir, &["checkout", into_branch], TIMEOUT_LOCAL).await?;
}
let merge_message = format!("Merge branch '{}' into {}", branch, into_branch);
match git_cmd(
working_dir,
&["merge", "--no-ff", branch, "-m", &merge_message],
TIMEOUT_LOCAL,
)
.await
{
Ok(raw) => {
let sha = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
let short_sha = sha[..7.min(sha.len())].to_string();
Ok(MergeOutput {
branch: branch.to_string(),
into_branch: into_branch.to_string(),
sha,
short_sha,
raw,
})
}
Err(e) => {
let _ = git_cmd(working_dir, &["merge", "--abort"], TIMEOUT_LOCAL).await;
bail!("merge failed (aborted): {e}");
}
}
}
pub async fn branch_delete(&self, branch: &str) -> Result<BranchDeleteOutput> {
self.ensure_branch_owned(branch)?;
git_cmd(
self.session().root(),
&["branch", "-d", branch],
TIMEOUT_LOCAL,
)
.await?;
Ok(BranchDeleteOutput {
branch: branch.to_string(),
})
}
}
async fn detect_other_staged(working_dir: &Path, only: &[String]) -> Result<Vec<String>> {
let staged_raw = git_cmd(
working_dir,
&["diff", "--cached", "--name-only"],
TIMEOUT_LOCAL,
)
.await?;
let only_set: std::collections::HashSet<&str> = only.iter().map(|s| s.as_str()).collect();
Ok(staged_raw
.lines()
.filter(|l| !l.is_empty())
.filter(|l| !only_set.contains(*l))
.map(|s| s.to_string())
.collect())
}
async fn stage(working_dir: &Path, paths: &[String]) -> Result<()> {
if paths.is_empty() {
return Ok(());
}
let mut args = vec!["add", "--"];
args.extend(paths.iter().map(|s| s.as_str()));
git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
Ok(())
}
async fn unstage(working_dir: &Path, paths: &[String]) -> Result<()> {
if paths.is_empty() {
return Ok(());
}
let mut args = vec!["reset", "--"];
args.extend(paths.iter().map(|s| s.as_str()));
git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
Ok(())
}
async fn commit_output(
working_dir: &Path,
message: &str,
dotfile_warnings: Vec<DotfileWarning>,
dotfile_skipped: Vec<String>,
) -> Result<CommitOutput> {
let sha = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
let short_sha = sha[..7.min(sha.len())].to_string();
let files_changed = match git_cmd(
working_dir,
&["diff", "--name-only", "HEAD~1..HEAD"],
TIMEOUT_LOCAL,
)
.await
{
Ok(out) => out.lines().filter(|l| !l.is_empty()).count(),
Err(e) => {
tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
0
}
};
Ok(CommitOutput {
sha,
short_sha,
message: message.to_string(),
files_changed,
dotfile_warnings,
dotfile_skipped,
})
}
struct Classification {
stage: Vec<String>,
warnings: Vec<DotfileWarning>,
skipped: Vec<String>,
}
async fn classify_paths(
working_dir: &Path,
candidates: &[String],
force_dot: bool,
) -> Result<Classification> {
let mut stage = Vec::new();
let mut warnings = Vec::new();
let mut skipped = Vec::new();
for p in candidates {
if force_dot || !is_dotfile_path(p) {
stage.push(p.clone());
continue;
}
let tracked = is_tracked(working_dir, p).await?;
if tracked {
stage.push(p.clone());
warnings.push(DotfileWarning {
path: p.clone(),
tracked: true,
in_gitignore: false,
});
} else if is_ignored(working_dir, p).await {
skipped.push(p.clone());
} else {
skipped.push(p.clone());
warnings.push(DotfileWarning {
path: p.clone(),
tracked: false,
in_gitignore: false,
});
}
}
Ok(Classification {
stage,
warnings,
skipped,
})
}
fn is_dotfile_path(p: &str) -> bool {
p.split('/')
.any(|c| c.starts_with('.') && c != "." && c != "..")
}
async fn enumerate_changes(working_dir: &Path) -> Result<Vec<String>> {
let mut cmd = tokio::process::Command::new("git");
cmd.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
.current_dir(working_dir)
.kill_on_drop(true);
let output = match tokio::time::timeout(TIMEOUT_LOCAL, cmd.output()).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => {
return Err(anyhow::Error::from(e)).context("failed to run git status --porcelain");
}
Err(_elapsed) => {
bail!("git status: timed out after {}s", TIMEOUT_LOCAL.as_secs());
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git status --porcelain: {}", stderr.trim());
}
let raw = String::from_utf8_lossy(&output.stdout);
let mut paths = Vec::new();
let mut it = raw.split('\0').peekable();
while let Some(rec) = it.next() {
if rec.len() < 4 {
continue;
}
let status = &rec[..2];
let path = &rec[3..];
if status.starts_with('R') || status.starts_with('C') {
it.next();
}
if !path.is_empty() {
paths.push(path.to_string());
}
}
Ok(paths)
}
async fn is_tracked(working_dir: &Path, path: &str) -> Result<bool> {
let out = git_cmd(working_dir, &["ls-files", "--", path], TIMEOUT_LOCAL).await?;
Ok(!out.trim().is_empty())
}
async fn is_ignored(working_dir: &Path, path: &str) -> bool {
let mut cmd = tokio::process::Command::new("git");
cmd.args(["check-ignore", "--quiet", "--", path])
.current_dir(working_dir)
.kill_on_drop(true);
match tokio::time::timeout(TIMEOUT_LOCAL, cmd.output()).await {
Ok(Ok(o)) => o.status.code() == Some(0),
Ok(Err(_)) => false,
Err(_elapsed) => false,
}
}
async fn stage_add_all(working_dir: &Path, paths: &[String]) -> Result<()> {
if paths.is_empty() {
return Ok(());
}
let mut args = vec!["add", "-A", "--"];
args.extend(paths.iter().map(|s| s.as_str()));
git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
Ok(())
}