workon/worktree.rs
1//! Worktree descriptor and metadata access.
2//!
3//! This module provides the core `WorktreeDescriptor` type that wraps git2's `Worktree`
4//! and exposes rich metadata about worktree state.
5//!
6//! ## Completed Metadata Methods
7//!
8//! The following metadata is fully implemented and working:
9//! - **Basic info**: `name()`, `path()`, `branch()`
10//! - **State detection**: `is_detached()`, `is_dirty()`, `is_valid()`, `is_locked()`
11//! - **Remote tracking**: `remote()`, `remote_branch()`, `remote_url()`, `remote_fetch_url()`, `remote_push_url()`
12//! - **Commit info**: `head_commit()`
13//! - **Status checks**: `has_unpushed_commits()`, `is_behind_upstream()`, `has_gone_upstream()`, `is_merged_into()`
14//!
15//! These methods enable status filtering (`--dirty`, `--ahead`, `--behind`, `--gone`) and
16//! interactive display with status indicators.
17//!
18//! ## Branch Types
19//!
20//! Supports three branch types for worktree creation:
21//! - **Normal**: Standard branch, tracks existing or creates from HEAD
22//! - **Orphan**: Independent history with initial empty commit (for documentation, gh-pages, etc.)
23//! - **Detached**: Detached HEAD state (for exploring specific commits)
24//!
25//! - **Activity tracking**: `last_activity()`, `is_stale()`
26//!
27//! ## Future Extensions
28//!
29//! Planned metadata methods for smart worktree management:
30//!
31//! TODO: Add worktree notes/descriptions support
32//! - Store user-provided notes/context for worktrees
33//! - Help remember why a worktree was created
34//! - Storage strategy TBD (git notes, config, or metadata file)
35
36use std::{fmt, fs::create_dir_all, path::Path};
37
38use git2::WorktreeAddOptions;
39use git2::{Repository, Worktree};
40use log::debug;
41
42use crate::error::{Result, WorktreeError};
43use crate::workon_root;
44
45/// Type of branch to create for a new worktree
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum BranchType {
48 /// Normal branch - track existing or create from HEAD
49 #[default]
50 Normal,
51 /// Orphan branch - independent history with initial empty commit
52 Orphan,
53 /// Detached HEAD
54 Detached,
55}
56
57/// A handle to a git worktree with rich metadata access.
58///
59/// Wraps a [`git2::Worktree`] and exposes branch state, remote tracking info,
60/// commit history, and status checks used by the CLI commands.
61pub struct WorktreeDescriptor {
62 worktree: Worktree,
63}
64
65impl WorktreeDescriptor {
66 /// Open a worktree by name within the given repository.
67 pub fn new(repo: &Repository, name: &str) -> Result<Self> {
68 Ok(Self {
69 worktree: repo.find_worktree(name)?,
70 })
71 }
72
73 /// Wrap an existing [`git2::Worktree`] without a repository lookup.
74 pub fn of(worktree: Worktree) -> Self {
75 Self { worktree }
76 }
77
78 /// Returns the name of the worktree, or `None` if the name is invalid UTF-8.
79 pub fn name(&self) -> Option<&str> {
80 self.worktree.name()
81 }
82
83 /// Returns the filesystem path to the worktree's working directory.
84 pub fn path(&self) -> &Path {
85 self.worktree.path()
86 }
87
88 /// Returns the branch name if the worktree is on a branch, or None if detached.
89 ///
90 /// This reads the HEAD file from the worktree's git directory to determine
91 /// if HEAD points to a branch reference or directly to a commit SHA.
92 pub fn branch(&self) -> Result<Option<String>> {
93 use std::fs;
94
95 // Get the path to the worktree's HEAD file
96 let git_dir = self.worktree.path().join(".git");
97 let head_path = if git_dir.is_file() {
98 // Linked worktree - read .git file to find actual git directory
99 let git_file_content = fs::read_to_string(&git_dir)?;
100 let git_dir_path = git_file_content
101 .strip_prefix("gitdir: ")
102 .and_then(|s| s.trim().strip_suffix('\n').or(Some(s.trim())))
103 .ok_or(WorktreeError::InvalidGitFile)?;
104 Path::new(git_dir_path).join("HEAD")
105 } else {
106 // Main worktree
107 git_dir.join("HEAD")
108 };
109
110 let head_content = fs::read_to_string(&head_path)?;
111
112 // HEAD file contains either:
113 // - "ref: refs/heads/branch-name" for a branch
114 // - A direct SHA for detached HEAD
115 if let Some(ref_line) = head_content.strip_prefix("ref: ") {
116 let ref_name = ref_line.trim();
117 Ok(ref_name.strip_prefix("refs/heads/").map(|s| s.to_string()))
118 } else {
119 // Direct SHA - detached HEAD
120 Ok(None)
121 }
122 }
123
124 /// Returns true if the worktree has a detached HEAD (not on a branch).
125 pub fn is_detached(&self) -> Result<bool> {
126 Ok(self.branch()?.is_none())
127 }
128
129 /// Returns true if the worktree has uncommitted changes (dirty working tree).
130 ///
131 /// This includes:
132 /// - Modified files (staged or unstaged)
133 /// - New untracked files
134 /// - Deleted files
135 pub fn is_dirty(&self) -> Result<bool> {
136 let repo = Repository::open(self.path())?;
137 let statuses = repo.statuses(None)?;
138 Ok(!statuses.is_empty())
139 }
140
141 /// Returns true if the worktree has uncommitted changes to tracked files.
142 ///
143 /// Unlike `is_dirty()`, this excludes untracked files. Use this when
144 /// untracked files should not block an operation (e.g. pruning a worktree
145 /// whose remote branch is gone).
146 pub fn has_tracked_changes(&self) -> Result<bool> {
147 let repo = Repository::open(self.path())?;
148 let mut opts = git2::StatusOptions::new();
149 opts.include_untracked(false);
150 let statuses = repo.statuses(Some(&mut opts))?;
151 Ok(!statuses.is_empty())
152 }
153
154 /// Returns true if the worktree's branch has unpushed commits (ahead of upstream).
155 ///
156 /// Returns false if:
157 /// - The worktree is detached (no branch)
158 /// - The branch has no upstream configured
159 /// - The branch is up to date with upstream
160 ///
161 /// Returns true if:
162 /// - The branch has commits ahead of its upstream
163 /// - The upstream is configured but the remote reference is gone (conservative)
164 pub fn has_unpushed_commits(&self) -> Result<bool> {
165 // Get the branch name - return false if detached
166 let branch_name = match self.branch()? {
167 Some(name) => name,
168 None => return Ok(false), // Detached HEAD, no branch to check
169 };
170
171 // Open the repository (use the bare repo, not the worktree)
172 let repo = Repository::open(self.path())?;
173
174 // Find the local branch
175 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
176 Ok(b) => b,
177 Err(_) => return Ok(false), // Branch doesn't exist
178 };
179
180 // Check if upstream is configured via git config
181 let config = repo.config()?;
182 let remote_key = format!("branch.{}.remote", branch_name);
183
184 // If no upstream is configured, there can't be unpushed commits
185 let _remote = match config.get_string(&remote_key) {
186 Ok(r) => r,
187 Err(_) => return Ok(false), // No remote configured
188 };
189
190 // Get the upstream branch
191 let upstream = match branch.upstream() {
192 Ok(u) => u,
193 Err(_) => {
194 // Upstream is configured but ref is gone - conservatively assume unpushed
195 return Ok(true);
196 }
197 };
198
199 // Get the local and upstream commit OIDs
200 let local_oid = branch
201 .get()
202 .target()
203 .ok_or(WorktreeError::NoLocalBranchTarget)?;
204 let upstream_oid = upstream
205 .get()
206 .target()
207 .ok_or(WorktreeError::NoBranchTarget)?;
208
209 // Check if local is ahead of upstream
210 let (ahead, _behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
211
212 Ok(ahead > 0)
213 }
214
215 /// Returns true if the worktree's branch is behind its upstream.
216 ///
217 /// Returns false if:
218 /// - The worktree is detached (no branch)
219 /// - The branch has no upstream configured
220 /// - The branch is up to date with upstream
221 /// - The upstream is configured but the remote reference is gone
222 ///
223 /// Returns true if:
224 /// - The branch has commits behind its upstream
225 pub fn is_behind_upstream(&self) -> Result<bool> {
226 // Get the branch name - return false if detached
227 let branch_name = match self.branch()? {
228 Some(name) => name,
229 None => return Ok(false), // Detached HEAD, no branch to check
230 };
231
232 // Open the repository
233 let repo = Repository::open(self.path())?;
234
235 // Find the local branch
236 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
237 Ok(b) => b,
238 Err(_) => return Ok(false), // Branch doesn't exist
239 };
240
241 // Check if upstream is configured via git config
242 let config = repo.config()?;
243 let remote_key = format!("branch.{}.remote", branch_name);
244
245 // If no upstream is configured, can't be behind
246 let _remote = match config.get_string(&remote_key) {
247 Ok(r) => r,
248 Err(_) => return Ok(false), // No remote configured
249 };
250
251 // Get the upstream branch
252 let upstream = match branch.upstream() {
253 Ok(u) => u,
254 Err(_) => {
255 // Upstream is configured but ref is gone - can't be behind non-existent branch
256 return Ok(false);
257 }
258 };
259
260 // Get the local and upstream commit OIDs
261 let local_oid = branch
262 .get()
263 .target()
264 .ok_or(WorktreeError::NoLocalBranchTarget)?;
265 let upstream_oid = upstream
266 .get()
267 .target()
268 .ok_or(WorktreeError::NoBranchTarget)?;
269
270 // Check if local is behind upstream
271 let (_ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
272
273 Ok(behind > 0)
274 }
275
276 /// Returns true if the worktree's upstream branch reference is gone (deleted on remote).
277 ///
278 /// Returns false if:
279 /// - The worktree is detached (no branch)
280 /// - The branch has no upstream configured
281 /// - The upstream branch reference exists
282 ///
283 /// Returns true if:
284 /// - Upstream is configured (branch.{name}.remote exists in config)
285 /// - But the upstream branch reference cannot be found
286 pub fn has_gone_upstream(&self) -> Result<bool> {
287 // Get the branch name - return false if detached
288 let branch_name = match self.branch()? {
289 Some(name) => name,
290 None => return Ok(false), // Detached HEAD, no branch to check
291 };
292
293 // Open the repository
294 let repo = Repository::open(self.path())?;
295
296 // Find the local branch
297 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
298 Ok(b) => b,
299 Err(_) => return Ok(false), // Branch doesn't exist
300 };
301
302 // Check if upstream is configured via git config
303 let config = repo.config()?;
304 let remote_key = format!("branch.{}.remote", branch_name);
305
306 // If no upstream is configured, it's not "gone"
307 match config.get_string(&remote_key) {
308 Ok(_) => {
309 // Upstream is configured - check if the reference exists
310 match branch.upstream() {
311 Ok(_) => Ok(false), // Upstream exists
312 Err(_) => Ok(true), // Upstream configured but ref is gone
313 }
314 }
315 Err(_) => Ok(false), // No upstream configured
316 }
317 }
318
319 /// Returns true if the worktree's branch has been merged into the target branch.
320 ///
321 /// A branch is considered merged if its HEAD commit is reachable from the target branch,
322 /// meaning all commits in this branch exist in the target branch's history.
323 ///
324 /// Returns false if:
325 /// - The worktree is detached (no branch)
326 /// - The target branch doesn't exist
327 /// - The branch has commits not in the target branch
328 ///
329 /// Returns true if:
330 /// - All commits in this branch are reachable from the target branch
331 pub fn is_merged_into(&self, target_branch: &str) -> Result<bool> {
332 // Get the branch name - return false if detached
333 let branch_name = match self.branch()? {
334 Some(name) => name,
335 None => return Ok(false), // Detached HEAD, no branch to check
336 };
337
338 // Don't consider the target branch as merged into itself
339 if branch_name == target_branch {
340 return Ok(false);
341 }
342
343 // Open the bare repository (not the worktree) to check actual branch states
344 // The worktree's .git points to the commondir (bare repo)
345 let worktree_repo = Repository::open(self.path())?;
346 let commondir = worktree_repo.commondir();
347 let repo = Repository::open(commondir)?;
348
349 // Find the current branch
350 let current_branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
351 Ok(b) => b,
352 Err(_) => return Ok(false), // Branch doesn't exist
353 };
354
355 // Find the target branch
356 let target = match repo.find_branch(target_branch, git2::BranchType::Local) {
357 Ok(b) => b,
358 Err(_) => return Ok(false), // Target branch doesn't exist
359 };
360
361 // Get commit OIDs
362 let current_oid = current_branch
363 .get()
364 .target()
365 .ok_or(WorktreeError::NoCurrentBranchTarget)?;
366 let target_oid = target.get().target().ok_or(WorktreeError::NoBranchTarget)?;
367
368 // If they point to the same commit, the branch is merged
369 if current_oid == target_oid {
370 return Ok(true);
371 }
372
373 // Check if current branch's commit is reachable from target
374 // This means target is a descendant of (or equal to) current
375 Ok(repo.graph_descendant_of(target_oid, current_oid)?)
376 }
377
378 /// Returns the commit hash (SHA) of the worktree's current HEAD.
379 ///
380 /// Returns None if HEAD cannot be resolved (e.g., empty repository).
381 pub fn head_commit(&self) -> Result<Option<String>> {
382 let repo = Repository::open(self.path())?;
383
384 // Try to resolve HEAD to a commit and extract the OID immediately
385 let commit_oid = match repo.head() {
386 Ok(head) => match head.peel_to_commit() {
387 Ok(commit) => Some(commit.id()),
388 Err(_) => return Ok(None), // HEAD exists but can't resolve to commit
389 },
390 Err(_) => return Ok(None), // No HEAD (unborn branch)
391 };
392
393 Ok(commit_oid.map(|oid| oid.to_string()))
394 }
395
396 /// Returns the timestamp of the HEAD commit as the last activity time.
397 ///
398 /// Returns None if:
399 /// - HEAD cannot be resolved (empty/unborn repository)
400 /// - HEAD cannot be peeled to a commit
401 pub fn last_activity(&self) -> Result<Option<i64>> {
402 let repo = Repository::open(self.path())?;
403 let seconds = match repo.head() {
404 Ok(head) => match head.peel_to_commit() {
405 Ok(commit) => Some(commit.time().seconds()),
406 Err(_) => None,
407 },
408 Err(_) => None,
409 };
410 Ok(seconds)
411 }
412
413 /// Returns true if the worktree's last activity is older than `days` days.
414 ///
415 /// Returns false if:
416 /// - Last activity cannot be determined
417 /// - The worktree has recent activity within the threshold
418 pub fn is_stale(&self, days: u32) -> Result<bool> {
419 let last = match self.last_activity()? {
420 Some(ts) => ts,
421 None => return Ok(false),
422 };
423 let now = std::time::SystemTime::now()
424 .duration_since(std::time::UNIX_EPOCH)
425 .map_err(std::io::Error::other)?
426 .as_secs() as i64;
427 let threshold = i64::from(days) * 86400;
428 Ok((now - last) > threshold)
429 }
430
431 /// Returns the name of the remote that the worktree's branch tracks (e.g., "origin").
432 ///
433 /// Returns None if:
434 /// - The worktree is detached (no branch)
435 /// - The branch has no upstream configured
436 pub fn remote(&self) -> Result<Option<String>> {
437 // Get the branch name - return None if detached
438 let branch_name = match self.branch()? {
439 Some(name) => name,
440 None => return Ok(None), // Detached HEAD, no branch to check
441 };
442
443 let repo = Repository::open(self.path())?;
444 let config = repo.config()?;
445
446 // Check for branch.<name>.remote in git config
447 let remote_key = format!("branch.{}.remote", branch_name);
448 match config.get_string(&remote_key) {
449 Ok(remote) => Ok(Some(remote)),
450 Err(_) => Ok(None), // No remote configured
451 }
452 }
453
454 /// Returns the full name of the upstream remote branch (e.g., "refs/remotes/origin/main").
455 ///
456 /// Returns None if:
457 /// - The worktree is detached (no branch)
458 /// - The branch has no upstream configured
459 pub fn remote_branch(&self) -> Result<Option<String>> {
460 // Get the branch name - return None if detached
461 let branch_name = match self.branch()? {
462 Some(name) => name,
463 None => return Ok(None), // Detached HEAD, no branch to check
464 };
465
466 let repo = Repository::open(self.path())?;
467
468 // Find the local branch and get its upstream, extracting the name immediately
469 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
470 Ok(b) => b,
471 Err(_) => return Ok(None), // Branch doesn't exist
472 };
473
474 let upstream_name = match branch.upstream() {
475 Ok(upstream) => match upstream.name() {
476 Ok(Some(name)) => Some(name.to_string()),
477 _ => None,
478 },
479 Err(_) => return Ok(None), // No upstream configured
480 };
481
482 Ok(upstream_name)
483 }
484
485 /// Returns the default URL for the remote (usually the fetch URL).
486 ///
487 /// Returns None if:
488 /// - The worktree is detached (no branch)
489 /// - The branch has no upstream configured
490 /// - The remote has no URL configured
491 pub fn remote_url(&self) -> Result<Option<String>> {
492 // Get the remote name
493 let remote_name = match self.remote()? {
494 Some(name) => name,
495 None => return Ok(None),
496 };
497
498 let repo = Repository::open(self.path())?;
499
500 // Find the remote and extract the URL immediately
501 let url = match repo.find_remote(&remote_name) {
502 Ok(remote) => remote.url().map(|s| s.to_string()),
503 Err(_) => return Ok(None), // Remote doesn't exist
504 };
505
506 Ok(url)
507 }
508
509 /// Returns the fetch URL for the remote.
510 ///
511 /// Returns None if:
512 /// - The worktree is detached (no branch)
513 /// - The branch has no upstream configured
514 /// - The remote has no fetch URL configured
515 pub fn remote_fetch_url(&self) -> Result<Option<String>> {
516 // Get the remote name
517 let remote_name = match self.remote()? {
518 Some(name) => name,
519 None => return Ok(None),
520 };
521
522 let repo = Repository::open(self.path())?;
523
524 // Find the remote and extract the fetch URL immediately
525 let url = match repo.find_remote(&remote_name) {
526 Ok(remote) => remote.url().map(|s| s.to_string()),
527 Err(_) => return Ok(None), // Remote doesn't exist
528 };
529
530 Ok(url)
531 }
532
533 /// Returns the push URL for the remote.
534 ///
535 /// Returns None if:
536 /// - The worktree is detached (no branch)
537 /// - The branch has no upstream configured
538 /// - The remote has no push URL configured (falls back to fetch URL)
539 pub fn remote_push_url(&self) -> Result<Option<String>> {
540 // Get the remote name
541 let remote_name = match self.remote()? {
542 Some(name) => name,
543 None => return Ok(None),
544 };
545
546 let repo = Repository::open(self.path())?;
547
548 // Find the remote and extract the push URL (or fallback to fetch URL) immediately
549 let url = match repo.find_remote(&remote_name) {
550 Ok(remote) => remote
551 .pushurl()
552 .or_else(|| remote.url())
553 .map(|s| s.to_string()),
554 Err(_) => return Ok(None), // Remote doesn't exist
555 };
556
557 Ok(url)
558 }
559}
560
561impl fmt::Debug for WorktreeDescriptor {
562 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
563 write!(f, "WorktreeDescriptor({:?})", self.worktree.path())
564 }
565}
566
567impl fmt::Display for WorktreeDescriptor {
568 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569 write!(f, "{}", self.worktree.path().display())
570 }
571}
572
573/// Return all worktrees registered with the repository.
574pub fn get_worktrees(repo: &Repository) -> Result<Vec<WorktreeDescriptor>> {
575 repo.worktrees()?
576 .into_iter()
577 .map(|name| {
578 let name = name.ok_or(WorktreeError::InvalidName)?;
579 WorktreeDescriptor::new(repo, name)
580 })
581 .collect()
582}
583
584/// Return the worktree that contains the current working directory.
585///
586/// Returns [`WorktreeError::NotInWorktree`] if the current directory is not
587/// inside any registered worktree.
588pub fn current_worktree(repo: &Repository) -> Result<WorktreeDescriptor> {
589 let current_dir = std::env::current_dir().map_err(std::io::Error::other)?;
590
591 let worktrees = get_worktrees(repo)?;
592 worktrees
593 .into_iter()
594 .find(|wt| current_dir.starts_with(wt.path()))
595 .ok_or_else(|| WorktreeError::NotInWorktree.into())
596}
597
598/// Find a worktree by its name or by its branch name.
599///
600/// Returns [`WorktreeError::NotFound`] if no matching worktree exists.
601pub fn find_worktree(repo: &Repository, name: &str) -> Result<WorktreeDescriptor> {
602 let worktrees = get_worktrees(repo)?;
603 worktrees
604 .into_iter()
605 .find(|wt| {
606 // Match by worktree name or branch name
607 wt.name() == Some(name) || wt.branch().ok().flatten().as_deref() == Some(name)
608 })
609 .ok_or_else(|| WorktreeError::NotFound(name.to_string()).into())
610}
611
612/// Create a new worktree for the given branch.
613///
614/// The worktree directory is placed under the workon root (see [`workon_root`]).
615/// Branch names containing `/` are supported; parent directories are created
616/// automatically and the worktree is named after the final path component.
617///
618/// # Branch types
619///
620/// - [`BranchType::Normal`] — uses an existing local/remote branch, or creates one from
621/// `base_branch` (or HEAD if `base_branch` is `None`).
622/// - [`BranchType::Orphan`] — creates an independent branch with no shared history,
623/// seeded with an empty initial commit.
624/// - [`BranchType::Detached`] — creates a worktree with a detached HEAD pointing to
625/// the current HEAD commit.
626pub fn add_worktree(
627 repo: &Repository,
628 branch_name: &str,
629 branch_type: BranchType,
630 base_branch: Option<&str>,
631) -> Result<WorktreeDescriptor> {
632 // git worktree add <branch>
633 debug!(
634 "adding worktree for branch {:?} with type: {:?}",
635 branch_name, branch_type
636 );
637
638 let reference = match branch_type {
639 BranchType::Orphan => {
640 debug!("creating orphan branch {:?}", branch_name);
641 // For orphan branches, we'll create the branch after the worktree
642 None
643 }
644 BranchType::Detached => {
645 debug!("creating detached HEAD worktree at {:?}", branch_name);
646 // For detached worktrees, we don't create or use a branch reference
647 None
648 }
649 BranchType::Normal => {
650 let branch = match repo.find_branch(branch_name, git2::BranchType::Local) {
651 Ok(b) => b,
652 Err(e) => {
653 debug!("local branch not found: {:?}", e);
654 debug!("looking for remote branch {:?}", branch_name);
655 match repo.find_branch(branch_name, git2::BranchType::Remote) {
656 Ok(b) => b,
657 Err(e) => {
658 debug!("remote branch not found: {:?}", e);
659 debug!("creating new local branch {:?}", branch_name);
660
661 // Determine which commit to branch from
662 let base_commit = if let Some(base) = base_branch {
663 // Branch from specified base branch
664 debug!("branching from base branch {:?}", base);
665 // Try local branch first, then remote branch
666 let base_branch =
667 match repo.find_branch(base, git2::BranchType::Local) {
668 Ok(b) => b,
669 Err(_) => {
670 debug!("base branch not found as local, trying remote");
671 repo.find_branch(base, git2::BranchType::Remote)?
672 }
673 };
674 base_branch.into_reference().peel_to_commit()?
675 } else {
676 // Default: branch from HEAD
677 repo.head()?.peel_to_commit()?
678 };
679
680 repo.branch(branch_name, &base_commit, false)?
681 }
682 }
683 }
684 };
685
686 Some(branch.into_reference())
687 }
688 };
689
690 let root = workon_root(repo)?;
691
692 // Git does not support worktree names with slashes in them,
693 // so take the base of the branch name as the worktree name.
694 let worktree_name = match Path::new(&branch_name).file_name() {
695 Some(basename) => basename.to_str().ok_or(WorktreeError::InvalidName)?,
696 None => branch_name,
697 };
698
699 let worktree_path = root.join(branch_name);
700
701 // Create parent directories if the branch name contains slashes
702 if let Some(parent) = worktree_path.parent() {
703 create_dir_all(parent)?;
704 }
705
706 let mut opts = WorktreeAddOptions::new();
707 if let Some(ref r) = reference {
708 opts.reference(Some(r));
709 }
710
711 debug!(
712 "adding worktree {} at {}",
713 worktree_name,
714 worktree_path.display()
715 );
716
717 let worktree = repo.worktree(worktree_name, worktree_path.as_path(), Some(&opts))?;
718
719 // For detached worktrees, set HEAD to point directly to a commit SHA
720 if branch_type == BranchType::Detached {
721 debug!("setting up detached HEAD for worktree {:?}", branch_name);
722
723 use std::fs;
724
725 // Get the current HEAD commit SHA
726 let head_commit = repo.head()?.peel_to_commit()?;
727 let commit_sha = head_commit.id().to_string();
728
729 // Write the commit SHA directly to the worktree's HEAD file
730 let git_dir = repo.path().join("worktrees").join(worktree_name);
731 let head_path = git_dir.join("HEAD");
732 fs::write(&head_path, format!("{}\n", commit_sha).as_bytes())?;
733
734 debug!(
735 "detached HEAD setup complete for worktree {:?} at {}",
736 branch_name, commit_sha
737 );
738 }
739
740 // For orphan branches, create an initial empty commit with no parent
741 if branch_type == BranchType::Orphan {
742 debug!(
743 "setting up orphan branch {:?} with initial empty commit",
744 branch_name
745 );
746
747 use std::fs;
748
749 // Get the common directory (bare repo path) - important when running from a worktree
750 let common_dir = repo.commondir();
751
752 // First, manually set HEAD to point to the new branch as a symbolic reference
753 // This ensures we're not trying to update an existing branch
754 let git_dir = common_dir.join("worktrees").join(worktree_name);
755 let head_path = git_dir.join("HEAD");
756 let branch_ref = format!("ref: refs/heads/{}\n", branch_name);
757 fs::write(&head_path, branch_ref.as_bytes())?;
758
759 // Remove any existing branch ref that libgit2 may have created
760 let branch_ref_path = common_dir.join("refs/heads").join(branch_name);
761 let _ = fs::remove_file(&branch_ref_path);
762
763 // Open the worktree repository
764 let worktree_repo = Repository::open(&worktree_path)?;
765
766 // Remove all files from the working directory (but keep .git)
767 for entry in fs::read_dir(&worktree_path)? {
768 let entry = entry?;
769 let path = entry.path();
770 if path.file_name() != Some(std::ffi::OsStr::new(".git")) {
771 if path.is_dir() {
772 fs::remove_dir_all(&path)?;
773 } else {
774 fs::remove_file(&path)?;
775 }
776 }
777 }
778
779 // Clear the index to start fresh
780 let mut index = worktree_repo.index()?;
781 index.clear()?;
782 index.write()?;
783
784 // Create an empty tree for the initial commit
785 let tree_id = index.write_tree()?;
786 let tree = worktree_repo.find_tree(tree_id)?;
787
788 // Create signature for the commit
789 let config = worktree_repo.config()?;
790 let sig = worktree_repo.signature().or_else(|_| {
791 // Fallback if no git config is set
792 git2::Signature::now(
793 config
794 .get_string("user.name")
795 .unwrap_or_else(|_| "git-workon".to_string())
796 .as_str(),
797 config
798 .get_string("user.email")
799 .unwrap_or_else(|_| "git-workon@localhost".to_string())
800 .as_str(),
801 )
802 })?;
803
804 // Create initial commit with no parents (orphan)
805 worktree_repo.commit(
806 Some("HEAD"),
807 &sig,
808 &sig,
809 "Initial commit",
810 &tree,
811 &[], // No parents - this makes it an orphan
812 )?;
813
814 debug!("orphan branch setup complete for {:?}", branch_name);
815 }
816
817 Ok(WorktreeDescriptor::of(worktree))
818}
819
820/// Set upstream tracking for a worktree branch
821///
822/// Configures the branch in the worktree to track a remote branch by setting
823/// `branch.*.remote` and `branch.*.merge` configuration entries.
824///
825/// This is particularly important for PR worktrees to ensure they properly track
826/// the PR's remote branch.
827pub fn set_upstream_tracking(
828 worktree: &WorktreeDescriptor,
829 remote: &str,
830 remote_ref: &str,
831) -> Result<()> {
832 let repo = Repository::open(worktree.path())?;
833 let mut config = repo.config()?;
834
835 let head = repo.head()?;
836 let branch_name = head
837 .shorthand()
838 .ok_or(WorktreeError::NoCurrentBranchTarget)?;
839
840 // Set branch.*.remote
841 let remote_key = format!("branch.{}.remote", branch_name);
842 config.set_str(&remote_key, remote)?;
843
844 // Set branch.*.merge
845 let merge_key = format!("branch.{}.merge", branch_name);
846 config.set_str(&merge_key, remote_ref)?;
847
848 debug!(
849 "Set upstream tracking: {} -> {}/{}",
850 branch_name, remote, remote_ref
851 );
852 Ok(())
853}