Skip to content

Commit a963f5f

Browse files
authored
Merge pull request #1161 from org2AI/chloe/modularize-git-worktree
refactor(git): split the worktree module into focused submodules
2 parents 497a55e + 37a2277 commit a963f5f

12 files changed

Lines changed: 1406 additions & 1286 deletions

File tree

src-tauri/crates/git/src/worktree.rs

Lines changed: 33 additions & 1286 deletions
Large diffs are not rendered by default.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//! Worktree creation: the explicit-branch linked worktree and the
2+
//! per-agent-session worktree (including limit enforcement and stale-state
3+
//! cleanup before `git worktree add`).
4+
5+
use std::path::Path;
6+
7+
use tracing::{error, info};
8+
9+
use super::git_cmd::{current_head_ref, git_stderr, git_stdout, run_git};
10+
use super::paths::session_worktree_dir;
11+
use super::setup_hooks::run_worktree_setup_hooks;
12+
use super::{
13+
list_session_worktrees, session_branch_name, validate_session_id, LinkedWorktreeInfo,
14+
WorktreeInfo,
15+
};
16+
17+
/// Fallback used when the caller does not supply a configurable limit.
18+
const DEFAULT_MAX_CONCURRENT_WORKTREES: usize = 8;
19+
20+
// ============================================
21+
// Public API
22+
// ============================================
23+
24+
/// Create a linked worktree for an explicit branch name.
25+
///
26+
/// Unlike [`create_session_worktree`], this is not tied to an agent session:
27+
/// the caller supplies the branch and target path. Existing local branches are
28+
/// reused; otherwise a new branch is created from `base_ref` (or `HEAD`).
29+
pub fn create_linked_worktree(
30+
repo_path: &Path,
31+
worktree_path: &Path,
32+
branch: &str,
33+
base_ref: Option<&str>,
34+
) -> Result<LinkedWorktreeInfo, String> {
35+
let branch = branch.trim();
36+
if branch.is_empty() {
37+
return Err("branch cannot be empty".to_string());
38+
}
39+
let path_string = worktree_path.to_string_lossy().to_string();
40+
if worktree_path.exists() {
41+
return Err(format!("Worktree path already exists: {}", path_string));
42+
}
43+
if let Some(parent) = worktree_path.parent() {
44+
std::fs::create_dir_all(parent)
45+
.map_err(|err| format!("Failed to create worktree parent dir: {}", err))?;
46+
}
47+
48+
let branch_exists = run_git(repo_path, &["rev-parse", "--verify", branch])
49+
.map(|output| output.status.success())
50+
.unwrap_or(false);
51+
let output = if branch_exists {
52+
run_git(repo_path, &["worktree", "add", &path_string, branch])?
53+
} else {
54+
run_git(
55+
repo_path,
56+
&[
57+
"worktree",
58+
"add",
59+
"-b",
60+
branch,
61+
&path_string,
62+
base_ref.unwrap_or("HEAD"),
63+
],
64+
)?
65+
};
66+
67+
if !output.status.success() {
68+
return Err(format!("git worktree add failed: {}", git_stderr(&output)));
69+
}
70+
71+
if let Err(err) = run_worktree_setup_hooks(repo_path, worktree_path) {
72+
let _ = run_git(repo_path, &["worktree", "remove", "--force", &path_string]);
73+
if !branch_exists {
74+
let _ = run_git(repo_path, &["branch", "-D", branch]);
75+
}
76+
return Err(err);
77+
}
78+
79+
let head_sha = run_git(worktree_path, &["rev-parse", "HEAD"])
80+
.map(|output| git_stdout(&output))
81+
.unwrap_or_default();
82+
Ok(LinkedWorktreeInfo {
83+
path: path_string,
84+
branch: branch.to_string(),
85+
head_sha,
86+
})
87+
}
88+
89+
/// Create an isolated worktree for a coding agent session.
90+
///
91+
/// Creates a new branch from `base_branch` (or current HEAD) and sets up
92+
/// a worktree at `~/.orgii/agent-worktrees/{repo-hash}/{session-id}/`.
93+
///
94+
/// `max_count` — caller-supplied limit from `git.worktree.maxCount`.
95+
/// Falls back to `DEFAULT_MAX_CONCURRENT_WORKTREES` when `None`.
96+
pub fn create_session_worktree(
97+
repo_path: &Path,
98+
session_id: &str,
99+
base_branch: Option<&str>,
100+
max_count: Option<usize>,
101+
) -> Result<WorktreeInfo, String> {
102+
validate_session_id(session_id)?;
103+
let repo_str = repo_path.to_string_lossy().to_string();
104+
let wt_path = session_worktree_dir(&repo_str, session_id);
105+
let branch = session_branch_name(session_id);
106+
107+
// Enforce configurable max concurrent worktrees for this repo
108+
let limit = max_count.unwrap_or(DEFAULT_MAX_CONCURRENT_WORKTREES);
109+
let existing = list_session_worktrees(repo_path)?;
110+
if existing.len() >= limit {
111+
return Err(format!(
112+
"Maximum concurrent worktrees ({limit}) reached for this repo. \
113+
Merge or discard existing sessions first."
114+
));
115+
}
116+
117+
// Determine base branch
118+
let base = match base_branch {
119+
Some(b) => b.to_string(),
120+
None => current_head_ref(repo_path)?,
121+
};
122+
123+
// Clean up stale worktree if path exists but isn't registered
124+
if wt_path.exists() {
125+
info!(
126+
"[worktree] Cleaning up stale worktree directory: {}",
127+
wt_path.display()
128+
);
129+
let _ = run_git(repo_path, &["worktree", "prune"]);
130+
if wt_path.exists() {
131+
std::fs::remove_dir_all(&wt_path)
132+
.map_err(|err| format!("Failed to remove stale worktree: {}", err))?;
133+
}
134+
}
135+
136+
// Delete branch if it exists (stale from previous run)
137+
let branch_check = run_git(repo_path, &["rev-parse", "--verify", &branch]);
138+
if let Ok(ref output) = branch_check {
139+
if output.status.success() {
140+
info!("[worktree] Deleting stale branch: {}", branch);
141+
let _ = run_git(repo_path, &["branch", "-D", &branch]);
142+
}
143+
}
144+
145+
// Create parent directory
146+
if let Some(parent) = wt_path.parent() {
147+
std::fs::create_dir_all(parent)
148+
.map_err(|err| format!("Failed to create worktree parent dir: {}", err))?;
149+
}
150+
151+
// Create worktree with new branch
152+
let wt_path_str = wt_path.to_string_lossy().to_string();
153+
let output = run_git(
154+
repo_path,
155+
&["worktree", "add", "-b", &branch, &wt_path_str, &base],
156+
)?;
157+
158+
if !output.status.success() {
159+
let stderr = git_stderr(&output);
160+
error!("[worktree] Failed to create worktree: {}", stderr);
161+
return Err(format!("git worktree add failed: {}", stderr));
162+
}
163+
164+
info!(
165+
"[worktree] Created worktree for session {} at {} (branch: {}, base: {})",
166+
session_id,
167+
wt_path.display(),
168+
branch,
169+
base
170+
);
171+
172+
if let Err(err) = run_worktree_setup_hooks(repo_path, &wt_path) {
173+
let _ = run_git(repo_path, &["worktree", "remove", "--force", &wt_path_str]);
174+
let _ = run_git(repo_path, &["branch", "-D", &branch]);
175+
return Err(err);
176+
}
177+
178+
Ok(WorktreeInfo {
179+
path: wt_path_str,
180+
branch,
181+
base_branch: Some(base),
182+
session_id: session_id.to_string(),
183+
})
184+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//! Thin git invocation layer shared by the worktree submodules: the retrying
2+
//! `git` runner plus the stdout/stderr and HEAD/cleanliness accessors built
3+
//! on top of it.
4+
5+
use std::path::Path;
6+
use std::process::Output;
7+
8+
use crate::util::run_git_with_retry;
9+
10+
const GIT_RETRIES: u32 = 3;
11+
12+
/// Check that a repo working directory is clean (no uncommitted changes).
13+
pub(super) fn is_working_dir_clean(repo_path: &Path) -> Result<bool, String> {
14+
let output = run_git(repo_path, &["status", "--porcelain"])?;
15+
if !output.status.success() {
16+
return Err(format!("git status failed: {}", git_stderr(&output)));
17+
}
18+
Ok(git_stdout(&output).is_empty())
19+
}
20+
21+
pub(super) fn run_git(cwd: &Path, args: &[&str]) -> Result<Output, String> {
22+
run_git_with_retry(cwd, args, GIT_RETRIES)
23+
}
24+
25+
pub(super) fn git_stdout(output: &Output) -> String {
26+
String::from_utf8_lossy(&output.stdout).trim().to_string()
27+
}
28+
29+
pub(super) fn git_stderr(output: &Output) -> String {
30+
String::from_utf8_lossy(&output.stderr).trim().to_string()
31+
}
32+
33+
/// Get the current branch or HEAD commit of a repo.
34+
pub(super) fn current_head_ref(repo_path: &Path) -> Result<String, String> {
35+
let output = run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
36+
if !output.status.success() {
37+
return Err(format!("Failed to get HEAD: {}", git_stderr(&output)));
38+
}
39+
let branch = git_stdout(&output);
40+
if branch == "HEAD" {
41+
let output = run_git(repo_path, &["rev-parse", "HEAD"])?;
42+
if output.status.success() {
43+
return Ok(git_stdout(&output));
44+
}
45+
}
46+
Ok(branch)
47+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//! Read-only queries about a session worktree: its unmerged-work snapshot and
2+
//! the diff against its base branch.
3+
4+
use std::path::Path;
5+
6+
use super::git_cmd::{git_stderr, git_stdout, is_working_dir_clean, run_git};
7+
use super::paths::session_worktree_dir;
8+
use super::{session_branch_name, validate_session_id, SessionWorktreeState};
9+
10+
/// Inspect a session worktree for unmerged work: uncommitted file state
11+
/// and commits ahead of `base_branch` (when known). A missing worktree
12+
/// directory reports `worktree_exists: false` with `dirty: false`; the
13+
/// ahead-count is still computed so a manually pruned worktree with a
14+
/// surviving committed branch is not treated as clean.
15+
pub fn session_worktree_state(
16+
repo_path: &Path,
17+
session_id: &str,
18+
base_branch: Option<&str>,
19+
) -> Result<SessionWorktreeState, String> {
20+
validate_session_id(session_id)?;
21+
let repo_str = repo_path.to_string_lossy().to_string();
22+
let worktree_path = session_worktree_dir(&repo_str, session_id);
23+
let branch = session_branch_name(session_id);
24+
25+
let worktree_exists = worktree_path.exists();
26+
let dirty = if worktree_exists {
27+
!is_working_dir_clean(&worktree_path)?
28+
} else {
29+
false
30+
};
31+
32+
let mut commits_ahead_of_base = 0u64;
33+
if let Some(base) = base_branch {
34+
let branch_exists = matches!(
35+
run_git(repo_path, &["rev-parse", "--verify", &branch]),
36+
Ok(ref output) if output.status.success()
37+
);
38+
if branch_exists {
39+
let range = format!("{}..{}", base, branch);
40+
let output = run_git(repo_path, &["rev-list", "--count", &range])?;
41+
if !output.status.success() {
42+
return Err(format!(
43+
"git rev-list --count {} failed: {}",
44+
range,
45+
git_stderr(&output)
46+
));
47+
}
48+
commits_ahead_of_base = git_stdout(&output).parse().unwrap_or(0);
49+
}
50+
}
51+
52+
Ok(SessionWorktreeState {
53+
worktree_path,
54+
branch,
55+
worktree_exists,
56+
dirty,
57+
commits_ahead_of_base,
58+
})
59+
}
60+
61+
/// Get diff between a session's branch and its base branch.
62+
pub fn get_session_diff(
63+
repo_path: &Path,
64+
session_id: &str,
65+
base_branch: &str,
66+
) -> Result<String, String> {
67+
let branch = session_branch_name(session_id);
68+
let output = run_git(
69+
repo_path,
70+
&[
71+
"diff",
72+
"--unified=3",
73+
&format!("{}...{}", base_branch, branch),
74+
],
75+
)?;
76+
77+
if !output.status.success() {
78+
return Err(format!("git diff failed: {}", git_stderr(&output)));
79+
}
80+
81+
Ok(git_stdout(&output))
82+
}

0 commit comments

Comments
 (0)