1use std::path::Path;
9use std::process::Command;
10
11use anyhow::{Context, Result, bail};
12
13use crate::output::{
14 BranchDeleteOutput, CommitOutput, DotfileWarning, MergeOutput, OtherStagedMode,
15 WorktreeAddOutput, WorktreeRemoveOutput,
16};
17use crate::{GitModule, git_cmd};
18
19impl GitModule {
20 pub fn worktree_add(
22 &mut self,
23 name: &str,
24 branch: &str,
25 base_branch: Option<&str>,
26 ) -> Result<WorktreeAddOutput> {
27 let wt_dir = self.worktrees_dir();
28 std::fs::create_dir_all(&wt_dir).context("failed to create .worktrees directory")?;
29
30 let wt_path = wt_dir.join(name);
31 if wt_path.exists() {
32 bail!("worktree already exists: {}", wt_path.display());
33 }
34
35 let path_str = wt_path.to_str().unwrap_or(name);
36 if let Some(base) = base_branch {
37 git_cmd(
38 self.session().root(),
39 &["worktree", "add", "-b", branch, path_str, base],
40 )?;
41 } else {
42 git_cmd(
43 self.session().root(),
44 &["worktree", "add", "-b", branch, path_str],
45 )?;
46 }
47
48 let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
49 self.register_worktree(canon);
50 self.register_branch(branch.to_string());
51
52 Ok(WorktreeAddOutput {
53 path: wt_path,
54 branch: branch.to_string(),
55 session: self.session().id().to_string(),
56 })
57 }
58
59 pub fn worktree_remove(&mut self, name: &str) -> Result<WorktreeRemoveOutput> {
61 let wt_path = self.worktrees_dir().join(name);
62 let canon = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone());
63
64 self.ensure_owned(&canon)
65 .or_else(|_| self.ensure_owned(&wt_path))?;
66
67 git_cmd(
68 self.session().root(),
69 &[
70 "worktree",
71 "remove",
72 "--force",
73 wt_path.to_str().unwrap_or(name),
74 ],
75 )?;
76
77 self.forget_worktree(&canon);
78 self.forget_worktree(&wt_path);
79
80 Ok(WorktreeRemoveOutput { path: wt_path })
81 }
82
83 pub fn commit(
116 &self,
117 working_dir: &Path,
118 message: &str,
119 only: Option<&[String]>,
120 other_staged: OtherStagedMode,
121 force_dot: bool,
122 ) -> Result<CommitOutput> {
123 self.ensure_session_scope(working_dir)?;
124
125 let (warnings, skipped) = match only {
126 None | Some([]) => {
127 let candidates = enumerate_changes(working_dir)?;
128 if force_dot || !candidates.iter().any(|p| is_dotfile_path(p)) {
129 git_cmd(working_dir, &["add", "-A"])?;
133 (Vec::new(), Vec::new())
134 } else {
135 let cls = classify_paths(working_dir, &candidates, false)?;
136 stage_add_all(working_dir, &cls.stage)?;
137 (cls.warnings, cls.skipped)
138 }
139 }
140 Some(ps) => {
141 let intruders = detect_other_staged(working_dir, ps)?;
142 if !intruders.is_empty() {
143 match other_staged {
144 OtherStagedMode::Stop => {
145 bail!(
146 "commit aborted: index carries staged paths outside \
147 the requested set (other_staged=stop): {}",
148 intruders.join(", ")
149 );
150 }
151 OtherStagedMode::Restage => {
152 unstage(working_dir, &intruders)?;
153 let cls = classify_paths(working_dir, ps, force_dot)?;
154 stage(working_dir, &cls.stage)?;
155 git_cmd(working_dir, &["commit", "-m", message])?;
156 let output =
157 commit_output(working_dir, message, cls.warnings, cls.skipped);
158 stage(working_dir, &intruders)?;
162 return output;
163 }
164 }
165 }
166 let cls = classify_paths(working_dir, ps, force_dot)?;
167 stage(working_dir, &cls.stage)?;
168 (cls.warnings, cls.skipped)
169 }
170 };
171
172 git_cmd(working_dir, &["commit", "-m", message])?;
173 commit_output(working_dir, message, warnings, skipped)
174 }
175
176 pub fn merge(
179 &self,
180 branch: &str,
181 into_branch: &str,
182 working_dir: &Path,
183 ) -> Result<MergeOutput> {
184 self.ensure_session_scope(working_dir)?;
185
186 let current = git_cmd(working_dir, &["branch", "--show-current"])?;
187 if current != into_branch {
188 git_cmd(working_dir, &["checkout", into_branch])?;
189 }
190
191 let merge_message = format!("Merge branch '{}' into {}", branch, into_branch);
192 match git_cmd(
193 working_dir,
194 &["merge", "--no-ff", branch, "-m", &merge_message],
195 ) {
196 Ok(raw) => {
197 let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
198 let short_sha = sha[..7.min(sha.len())].to_string();
199 Ok(MergeOutput {
200 branch: branch.to_string(),
201 into_branch: into_branch.to_string(),
202 sha,
203 short_sha,
204 raw,
205 })
206 }
207 Err(e) => {
208 let _ = git_cmd(working_dir, &["merge", "--abort"]);
209 bail!("merge failed (aborted): {e}");
210 }
211 }
212 }
213
214 pub fn branch_delete(&self, branch: &str) -> Result<BranchDeleteOutput> {
217 self.ensure_branch_owned(branch)?;
218 git_cmd(self.session().root(), &["branch", "-d", branch])?;
219 Ok(BranchDeleteOutput {
220 branch: branch.to_string(),
221 })
222 }
223}
224
225fn detect_other_staged(working_dir: &Path, only: &[String]) -> Result<Vec<String>> {
228 let staged_raw = git_cmd(working_dir, &["diff", "--cached", "--name-only"])?;
229 let only_set: std::collections::HashSet<&str> = only.iter().map(|s| s.as_str()).collect();
230 Ok(staged_raw
231 .lines()
232 .filter(|l| !l.is_empty())
233 .filter(|l| !only_set.contains(*l))
234 .map(|s| s.to_string())
235 .collect())
236}
237
238fn stage(working_dir: &Path, paths: &[String]) -> Result<()> {
240 if paths.is_empty() {
241 return Ok(());
242 }
243 let mut args = vec!["add", "--"];
244 args.extend(paths.iter().map(|s| s.as_str()));
245 git_cmd(working_dir, &args)?;
246 Ok(())
247}
248
249fn unstage(working_dir: &Path, paths: &[String]) -> Result<()> {
253 if paths.is_empty() {
254 return Ok(());
255 }
256 let mut args = vec!["reset", "--"];
257 args.extend(paths.iter().map(|s| s.as_str()));
258 git_cmd(working_dir, &args)?;
259 Ok(())
260}
261
262fn commit_output(
264 working_dir: &Path,
265 message: &str,
266 dotfile_warnings: Vec<DotfileWarning>,
267 dotfile_skipped: Vec<String>,
268) -> Result<CommitOutput> {
269 let sha = git_cmd(working_dir, &["rev-parse", "HEAD"])?;
270 let short_sha = sha[..7.min(sha.len())].to_string();
271 let files_changed = git_cmd(working_dir, &["diff", "--name-only", "HEAD~1..HEAD"])
272 .map(|out| out.lines().filter(|l| !l.is_empty()).count())
273 .unwrap_or_else(|e| {
274 tracing::warn!(error = %e, "git diff HEAD~1..HEAD failed (initial commit?)");
275 0
276 });
277 Ok(CommitOutput {
278 sha,
279 short_sha,
280 message: message.to_string(),
281 files_changed,
282 dotfile_warnings,
283 dotfile_skipped,
284 })
285}
286
287struct Classification {
289 stage: Vec<String>,
293 warnings: Vec<DotfileWarning>,
296 skipped: Vec<String>,
300}
301
302fn classify_paths(
304 working_dir: &Path,
305 candidates: &[String],
306 force_dot: bool,
307) -> Result<Classification> {
308 let mut stage = Vec::new();
309 let mut warnings = Vec::new();
310 let mut skipped = Vec::new();
311
312 for p in candidates {
313 if force_dot || !is_dotfile_path(p) {
314 stage.push(p.clone());
315 continue;
316 }
317 let tracked = is_tracked(working_dir, p)?;
319 if tracked {
320 stage.push(p.clone());
321 warnings.push(DotfileWarning {
322 path: p.clone(),
323 tracked: true,
324 in_gitignore: false,
325 });
326 } else if is_ignored(working_dir, p) {
327 skipped.push(p.clone());
329 } else {
330 skipped.push(p.clone());
331 warnings.push(DotfileWarning {
332 path: p.clone(),
333 tracked: false,
334 in_gitignore: false,
335 });
336 }
337 }
338
339 Ok(Classification {
340 stage,
341 warnings,
342 skipped,
343 })
344}
345
346fn is_dotfile_path(p: &str) -> bool {
350 p.split('/')
351 .any(|c| c.starts_with('.') && c != "." && c != "..")
352}
353
354fn enumerate_changes(working_dir: &Path) -> Result<Vec<String>> {
366 let output = Command::new("git")
367 .args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
368 .current_dir(working_dir)
369 .output()
370 .context("failed to run git status --porcelain")?;
371 if !output.status.success() {
372 let stderr = String::from_utf8_lossy(&output.stderr);
373 bail!("git status --porcelain: {}", stderr.trim());
374 }
375
376 let raw = String::from_utf8_lossy(&output.stdout);
377 let mut paths = Vec::new();
378 let mut it = raw.split('\0').peekable();
382 while let Some(rec) = it.next() {
383 if rec.len() < 4 {
384 continue;
385 }
386 let status = &rec[..2];
387 let path = &rec[3..];
388 if status.starts_with('R') || status.starts_with('C') {
389 it.next();
392 }
393 if !path.is_empty() {
394 paths.push(path.to_string());
395 }
396 }
397 Ok(paths)
398}
399
400fn is_tracked(working_dir: &Path, path: &str) -> Result<bool> {
403 let out = git_cmd(working_dir, &["ls-files", "--", path])?;
404 Ok(!out.trim().is_empty())
405}
406
407fn is_ignored(working_dir: &Path, path: &str) -> bool {
411 let output = Command::new("git")
412 .args(["check-ignore", "--quiet", "--", path])
413 .current_dir(working_dir)
414 .output();
415 match output {
416 Ok(o) => o.status.code() == Some(0),
417 Err(_) => false,
418 }
419}
420
421fn stage_add_all(working_dir: &Path, paths: &[String]) -> Result<()> {
424 if paths.is_empty() {
425 return Ok(());
426 }
427 let mut args = vec!["add", "-A", "--"];
428 args.extend(paths.iter().map(|s| s.as_str()));
429 git_cmd(working_dir, &args)?;
430 Ok(())
431}