Skip to main content

sandbox_core/
git.rs

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/// Run a git command in the given directory, returning stdout on success.
9#[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
34/// Create a git worktree with a new branch.
35/// If the branch already exists (e.g. from a previous session), prunes stale
36/// worktrees and checks out the existing branch instead of creating a new one.
37/// Detects the repo's configured user and configures the worktree identity.
38pub 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    // Check if the branch already exists
47    let branch_exists = run_git(repo_dir, &["rev-parse", "--verify", branch])
48        .await
49        .is_ok();
50
51    if branch_exists {
52        // Prune stale worktree entries whose directories no longer exist
53        let _ = run_git(repo_dir, &["worktree", "prune"]).await;
54        // Check out the existing branch into the new worktree
55        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        // Configure the worktree-local user identity for a git worktree.
71        // Enables `extensions.worktreeConfig` on the parent repo so that
72        // `git config --worktree` writes to the worktree-specific config file.
73        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
86/// Remove a git worktree.
87pub 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
96/// Stage all changes and create a commit.
97pub 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
107/// Stage all changes and amend the current commit.
108/// When `message` is `Some`, the commit message is replaced; when `None` the
109/// existing message is kept (`--no-edit`).
110pub 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
122/// Merge a branch into the current branch.
123pub async fn git_merge_branch(dir: &Path, branch: &str) -> Result<(), SandboxError> {
124    run_git(dir, &["merge", branch, "--no-edit"]).await?;
125    Ok(())
126}
127
128/// Delete a branch.
129pub async fn git_delete_branch(dir: &Path, branch: &str) -> Result<(), SandboxError> {
130    run_git(dir, &["branch", "-D", branch]).await?;
131    Ok(())
132}
133
134/// Check if a branch has commits beyond its fork point from HEAD.
135pub 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
141/// Check if the top commit on a branch has no file changes vs its parent.
142pub async fn git_branch_top_is_empty(dir: &Path) -> bool {
143    run_git(dir, &["diff", "--quiet", "HEAD~1"]).await.is_ok()
144}
145
146/// Read the configured git user from a repo directory.
147/// Returns `Some((name, email))` if both are configured, `None` otherwise.
148pub 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
154// ── Shared git mode logic, used by the built-in git mode providers ──
155
156/// Detect a git repository during `clone_repo` and resolve its base revision.
157///
158/// This is the fallback mode: it claims every repository not claimed by an
159/// earlier provider, and produces a helpful error (pointing at
160/// `open_sandbox_direct`) when no commit can be resolved — e.g. for empty
161/// repos or non-repository directories.
162pub 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    // `ModeInit::repo_root` is documented as canonicalized; `repo_root_note`
182    // compares it against a canonicalized requested path.
183    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
192/// Squash the changes from `from_branch` into the sandbox by merging, then
193/// delete the branch (best-effort).
194pub 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
200/// Compute the changed files (with old/new contents) between the sandbox
201/// branch and its parent, for the diff view. Runs against the original repo
202/// (`repo_path`), which shares refs with the sandbox worktree.
203pub 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}