1use std::path::Path;
2use std::process::Stdio;
3
4use crate::error::SandboxError;
5use crate::sandbox::ModeInit;
6use crate::types::{ChangedFile, FileChangeStatus, SandboxMode, parse_changed_files};
7
8#[tracing::instrument(level = "warn")]
10pub async fn run_git(dir: &Path, args: &[&str]) -> Result<String, SandboxError> {
11 let output = tokio::process::Command::new("git")
12 .args(args)
13 .current_dir(dir)
14 .stdout(Stdio::piped())
15 .stderr(Stdio::piped())
16 .stdin(Stdio::null())
17 .output()
18 .await
19 .map_err(|e| {
20 tracing::warn!("failed to spawn: {e}");
21 SandboxError::CommandError(format!("failed to spawn git: {e}"))
22 })?;
23
24 if !output.status.success() {
25 let stderr = String::from_utf8_lossy(&output.stderr);
26 tracing::warn!(%stderr, "failed");
27 return Err(SandboxError::CommandError(format!("git failed: {stderr}")));
28 }
29
30 tracing::debug!("success");
31 Ok(String::from_utf8_lossy(&output.stdout).to_string())
32}
33
34pub async fn git_worktree_add(
39 repo_dir: &Path,
40 worktree_path: &Path,
41 branch: &str,
42 start_point: Option<&str>,
43) -> Result<(), SandboxError> {
44 let wt_str = worktree_path.to_string_lossy();
45
46 let branch_exists = run_git(repo_dir, &["rev-parse", "--verify", branch])
48 .await
49 .is_ok();
50
51 if branch_exists {
52 let _ = run_git(repo_dir, &["worktree", "prune"]).await;
54 let args = vec!["worktree", "add", &wt_str, branch];
56 run_git(repo_dir, &args).await?;
57 } else {
58 let mut args = vec!["worktree", "add", "-b", branch, &wt_str];
59 if let Some(sp) = start_point {
60 args.push(sp);
61 }
62 run_git(repo_dir, &args).await?;
63 let (name, email) = git_configured_user(repo_dir).await.unwrap_or_else(|| {
64 (
65 crate::DEFAULT_SANDBOX_NAME.to_owned(),
66 crate::DEFAULT_SANDBOX_EMAIL.to_owned(),
67 )
68 });
69
70 run_git(repo_dir, &["config", "extensions.worktreeConfig", "true"]).await?;
74 run_git(worktree_path, &["config", "--worktree", "user.name", &name]).await?;
75 run_git(
76 worktree_path,
77 &["config", "--worktree", "user.email", &email],
78 )
79 .await?;
80
81 git_commit_all(worktree_path, "sandbox init").await?;
82 }
83 Ok(())
84}
85
86pub async fn git_worktree_remove(
88 repo_dir: &Path,
89 worktree_path: &Path,
90) -> Result<(), SandboxError> {
91 let wt = worktree_path.to_string_lossy();
92 run_git(repo_dir, &["worktree", "remove", "--force", &wt]).await?;
93 Ok(())
94}
95
96pub async fn git_commit_all(dir: &Path, message: &str) -> Result<(), SandboxError> {
98 run_git(dir, &["add", "-A"]).await?;
99 run_git(
100 dir,
101 &["commit", "--allow-empty", "--no-verify", "-m", message],
102 )
103 .await?;
104 Ok(())
105}
106
107pub async fn git_amend_all(dir: &Path, message: Option<&str>) -> Result<(), SandboxError> {
111 run_git(dir, &["add", "-A"]).await?;
112 let mut args = vec!["commit", "--amend", "--allow-empty", "--no-verify"];
113 if let Some(msg) = message {
114 args.extend(["-m", msg]);
115 } else {
116 args.push("--no-edit");
117 }
118 run_git(dir, &args).await?;
119 Ok(())
120}
121
122pub async fn git_merge_branch(dir: &Path, branch: &str) -> Result<(), SandboxError> {
124 run_git(dir, &["merge", branch, "--no-edit"]).await?;
125 Ok(())
126}
127
128pub async fn git_delete_branch(dir: &Path, branch: &str) -> Result<(), SandboxError> {
130 run_git(dir, &["branch", "-D", branch]).await?;
131 Ok(())
132}
133
134pub async fn git_has_commits_on_branch(dir: &Path, branch: &str) -> Result<bool, SandboxError> {
136 let range = format!("HEAD..{branch}");
137 let output = run_git(dir, &["log", &range, "--oneline"]).await?;
138 Ok(!output.trim().is_empty())
139}
140
141pub async fn git_branch_top_is_empty(dir: &Path) -> bool {
143 run_git(dir, &["diff", "--quiet", "HEAD~1"]).await.is_ok()
144}
145
146pub async fn git_configured_user(dir: &Path) -> Option<(String, String)> {
149 let name = run_git(dir, &["config", "user.name"]).await.ok()?;
150 let email = run_git(dir, &["config", "user.email"]).await.ok()?;
151 Some((name.trim().to_owned(), email.trim().to_owned()))
152}
153
154pub async fn detect_mode(
163 repo_path: &Path,
164 base_bookmark: Option<&str>,
165) -> Result<Option<ModeInit>, SandboxError> {
166 let rev = base_bookmark.unwrap_or("HEAD");
167 let Ok(output) = run_git(repo_path, &["rev-parse", rev]).await else {
168 return Err(SandboxError::Other(
169 "Failed to resolve the HEAD commit. This can happen when the repository \
170 has no commits yet. Use the `open_sandbox_direct` tool instead, which \
171 operates directly on the repository without requiring an existing commit."
172 .to_owned(),
173 ));
174 };
175 let base_revision = output.trim().to_owned();
176
177 let message = match base_bookmark {
178 Some(rev) => format!("Repository initialized on top of {rev}."),
179 None => "Repository initialized (using Git worktrees).".to_owned(),
180 };
181 let repo_root = repo_path.canonicalize().map_err(SandboxError::Io)?;
184 Ok(Some(ModeInit {
185 mode: SandboxMode::Git { base_revision },
186 repo_root,
187 message,
188 precreate: false,
189 }))
190}
191
192pub async fn squash_from(sandbox_dir: &Path, from_branch: &str) -> Result<(), SandboxError> {
195 git_merge_branch(sandbox_dir, from_branch).await?;
196 let _ = git_delete_branch(sandbox_dir, from_branch).await;
197 Ok(())
198}
199
200pub async fn diff_files(
204 repo_path: &Path,
205 bookmark: &str,
206) -> Result<Vec<ChangedFile>, SandboxError> {
207 let bookmark_parent = format!("{bookmark}~1");
208 let summary = run_git(
209 repo_path,
210 &["diff", "--name-status", &bookmark_parent, bookmark],
211 )
212 .await?;
213 let mut files = Vec::new();
214 for (status, path) in parse_changed_files(&summary) {
215 let old_contents = if matches!(status, FileChangeStatus::Added) {
216 String::new()
217 } else {
218 let ref_path = format!("{bookmark_parent}:{path}");
219 run_git(repo_path, &["show", &ref_path])
220 .await
221 .unwrap_or_default()
222 };
223 let new_contents = if matches!(status, FileChangeStatus::Deleted) {
224 String::new()
225 } else {
226 let ref_path = format!("{bookmark}:{path}");
227 run_git(repo_path, &["show", &ref_path])
228 .await
229 .unwrap_or_default()
230 };
231 files.push(ChangedFile {
232 path: path.to_owned(),
233 status,
234 old_contents,
235 new_contents,
236 });
237 }
238 Ok(files)
239}