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::{
37 fmt,
38 fs::create_dir_all,
39 path::{Path, PathBuf},
40};
41
42use git2::WorktreeAddOptions;
43use git2::{Repository, Worktree};
44use log::debug;
45
46use crate::error::{Result, WorktreeError};
47use crate::workon_root;
48
49/// Type of branch to create for a new worktree
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum BranchType {
52 /// Normal branch - track existing or create from HEAD
53 #[default]
54 Normal,
55 /// Orphan branch - independent history with initial empty commit
56 Orphan,
57 /// Detached HEAD
58 Detached,
59}
60
61/// A handle to a git worktree with rich metadata access.
62///
63/// Wraps a [`git2::Worktree`] and exposes branch state, remote tracking info,
64/// commit history, and status checks used by the CLI commands.
65pub struct WorktreeDescriptor {
66 worktree: Worktree,
67 /// The worktree's private admin gitdir (`<commondir>/worktrees/<name>`), captured at
68 /// construction. Unlike `<workdir>/.git`, this path survives deletion of the working
69 /// directory, so [`branch`](Self::branch) can still resolve HEAD for a `prunable` worktree.
70 git_dir: PathBuf,
71}
72
73impl WorktreeDescriptor {
74 /// Open a worktree by name within the given repository.
75 pub fn new(repo: &Repository, name: &str) -> Result<Self> {
76 let worktree = repo.find_worktree(name)?;
77 let git_dir = worktree_admin_git_dir(repo, &worktree);
78 Ok(Self { worktree, git_dir })
79 }
80
81 /// Wrap an existing [`git2::Worktree`] using `repo` to resolve its admin gitdir.
82 pub fn of(repo: &Repository, worktree: Worktree) -> Self {
83 let git_dir = worktree_admin_git_dir(repo, &worktree);
84 Self { worktree, git_dir }
85 }
86
87 /// Returns the name of the worktree, or `None` if the name is invalid UTF-8.
88 pub fn name(&self) -> Option<&str> {
89 self.worktree.name().ok().flatten()
90 }
91
92 /// Returns the filesystem path to the worktree's working directory.
93 pub fn path(&self) -> &Path {
94 self.worktree.path()
95 }
96
97 /// Returns the branch name if the worktree is on a branch, or None if detached.
98 ///
99 /// Reads HEAD from the worktree's admin gitdir (`<commondir>/worktrees/<name>`) rather than
100 /// `<workdir>/.git`. The admin gitdir survives deletion of the working directory, so this
101 /// still resolves for a `prunable` worktree — reading `<workdir>/.git/HEAD` directly would
102 /// ENOENT and take down callers that walk every worktree (notably `prune`, whose whole job
103 /// is to clean such dead worktrees up).
104 pub fn branch(&self) -> Result<Option<String>> {
105 // Read HEAD from the admin gitdir (captured at construction), never from <workdir>/.git —
106 // the working directory may be gone (a `prunable` worktree). HEAD is always a loose file.
107 let head_content = std::fs::read_to_string(self.git_dir.join("HEAD"))?;
108
109 // HEAD contains either "ref: refs/heads/<branch>" (on a branch, born or unborn) or a
110 // direct commit SHA (detached HEAD).
111 if let Some(ref_line) = head_content.strip_prefix("ref: ") {
112 Ok(ref_line
113 .trim()
114 .strip_prefix("refs/heads/")
115 .map(str::to_string))
116 } else {
117 Ok(None)
118 }
119 }
120
121 /// Returns true if the worktree has a detached HEAD (not on a branch).
122 pub fn is_detached(&self) -> Result<bool> {
123 Ok(self.branch()?.is_none())
124 }
125
126 /// Returns true if the worktree has uncommitted changes (dirty working tree).
127 ///
128 /// This includes:
129 /// - Modified files (staged or unstaged)
130 /// - New untracked files
131 /// - Deleted files
132 pub fn is_dirty(&self) -> Result<bool> {
133 let repo = Repository::open(self.path())?;
134 let statuses = repo.statuses(None)?;
135 Ok(!statuses.is_empty())
136 }
137
138 /// Returns true if the worktree has a lock file.
139 ///
140 /// Locked worktrees are protected from pruning unless `--include-locked`
141 /// or `--force` is used.
142 pub fn is_locked(&self) -> Result<bool> {
143 Ok(!matches!(
144 self.worktree.is_locked()?,
145 git2::WorktreeLockStatus::Unlocked
146 ))
147 }
148
149 /// Returns true if the worktree's path and git metadata are intact.
150 ///
151 /// A worktree is invalid if its directory is missing or its git
152 /// metadata is broken.
153 pub fn is_valid(&self) -> bool {
154 self.worktree.validate().is_ok()
155 }
156
157 /// Returns true if the worktree has uncommitted changes to tracked files.
158 ///
159 /// Unlike `is_dirty()`, this excludes untracked files. Use this when
160 /// untracked files should not block an operation (e.g. pruning a worktree
161 /// whose remote branch is gone).
162 pub fn has_tracked_changes(&self) -> Result<bool> {
163 let repo = Repository::open(self.path())?;
164 let mut opts = git2::StatusOptions::new();
165 opts.include_untracked(false);
166 let statuses = repo.statuses(Some(&mut opts))?;
167 Ok(!statuses.is_empty())
168 }
169
170 /// Returns true if the worktree's branch has unpushed commits (ahead of upstream).
171 ///
172 /// Returns false if:
173 /// - The worktree is detached (no branch)
174 /// - The branch has no upstream configured
175 /// - The branch is up to date with upstream
176 ///
177 /// Returns true if:
178 /// - The branch has commits ahead of its upstream
179 /// - The upstream is configured but the remote reference is gone (conservative)
180 pub fn has_unpushed_commits(&self) -> Result<bool> {
181 // Get the branch name - return false if detached
182 let branch_name = match self.branch()? {
183 Some(name) => name,
184 None => return Ok(false), // Detached HEAD, no branch to check
185 };
186
187 // Open the repository (use the bare repo, not the worktree)
188 let repo = Repository::open(self.path())?;
189
190 // Find the local branch
191 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
192 Ok(b) => b,
193 Err(_) => return Ok(false), // Branch doesn't exist
194 };
195
196 // Check if upstream is configured via git config
197 let config = repo.config()?;
198 let remote_key = format!("branch.{}.remote", branch_name);
199
200 // If no upstream is configured, there can't be unpushed commits
201 let _remote = match config.get_string(&remote_key) {
202 Ok(r) => r,
203 Err(_) => return Ok(false), // No remote configured
204 };
205
206 // Get the upstream branch
207 let upstream = match branch.upstream() {
208 Ok(u) => u,
209 Err(_) => {
210 // Upstream is configured but ref is gone - conservatively assume unpushed
211 return Ok(true);
212 }
213 };
214
215 // Get the local and upstream commit OIDs
216 let local_oid = branch
217 .get()
218 .target()
219 .ok_or(WorktreeError::NoLocalBranchTarget)?;
220 let upstream_oid = upstream
221 .get()
222 .target()
223 .ok_or(WorktreeError::NoBranchTarget)?;
224
225 // Check if local is ahead of upstream
226 let (ahead, _behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
227
228 Ok(ahead > 0)
229 }
230
231 /// Returns true if the worktree's branch is behind its upstream.
232 ///
233 /// Returns false if:
234 /// - The worktree is detached (no branch)
235 /// - The branch has no upstream configured
236 /// - The branch is up to date with upstream
237 /// - The upstream is configured but the remote reference is gone
238 ///
239 /// Returns true if:
240 /// - The branch has commits behind its upstream
241 pub fn is_behind_upstream(&self) -> Result<bool> {
242 // Get the branch name - return false if detached
243 let branch_name = match self.branch()? {
244 Some(name) => name,
245 None => return Ok(false), // Detached HEAD, no branch to check
246 };
247
248 // Open the repository
249 let repo = Repository::open(self.path())?;
250
251 // Find the local branch
252 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
253 Ok(b) => b,
254 Err(_) => return Ok(false), // Branch doesn't exist
255 };
256
257 // Check if upstream is configured via git config
258 let config = repo.config()?;
259 let remote_key = format!("branch.{}.remote", branch_name);
260
261 // If no upstream is configured, can't be behind
262 let _remote = match config.get_string(&remote_key) {
263 Ok(r) => r,
264 Err(_) => return Ok(false), // No remote configured
265 };
266
267 // Get the upstream branch
268 let upstream = match branch.upstream() {
269 Ok(u) => u,
270 Err(_) => {
271 // Upstream is configured but ref is gone - can't be behind non-existent branch
272 return Ok(false);
273 }
274 };
275
276 // Get the local and upstream commit OIDs
277 let local_oid = branch
278 .get()
279 .target()
280 .ok_or(WorktreeError::NoLocalBranchTarget)?;
281 let upstream_oid = upstream
282 .get()
283 .target()
284 .ok_or(WorktreeError::NoBranchTarget)?;
285
286 // Check if local is behind upstream
287 let (_ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid)?;
288
289 Ok(behind > 0)
290 }
291
292 /// Returns true if the worktree's upstream branch reference is gone (deleted on remote).
293 ///
294 /// Returns false if:
295 /// - The worktree is detached (no branch)
296 /// - The branch has no upstream configured
297 /// - The upstream branch reference exists
298 ///
299 /// Returns true if:
300 /// - Upstream is configured (branch.{name}.remote exists in config)
301 /// - But the upstream branch reference cannot be found
302 pub fn has_gone_upstream(&self) -> Result<bool> {
303 // Get the branch name - return false if detached
304 let branch_name = match self.branch()? {
305 Some(name) => name,
306 None => return Ok(false), // Detached HEAD, no branch to check
307 };
308
309 // Open the repository
310 let repo = Repository::open(self.path())?;
311
312 // Find the local branch
313 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
314 Ok(b) => b,
315 Err(_) => return Ok(false), // Branch doesn't exist
316 };
317
318 // Check if upstream is configured via git config
319 let config = repo.config()?;
320 let remote_key = format!("branch.{}.remote", branch_name);
321
322 // If no upstream is configured, it's not "gone"
323 match config.get_string(&remote_key) {
324 Ok(_) => {
325 // Upstream is configured - check if the reference exists
326 match branch.upstream() {
327 Ok(_) => Ok(false), // Upstream exists
328 Err(_) => Ok(true), // Upstream configured but ref is gone
329 }
330 }
331 Err(_) => Ok(false), // No upstream configured
332 }
333 }
334
335 /// Returns true if the worktree's branch has been merged into the target branch.
336 ///
337 /// A branch is considered merged if its HEAD commit is reachable from the target branch,
338 /// meaning all commits in this branch exist in the target branch's history.
339 ///
340 /// Returns false if:
341 /// - The worktree is detached (no branch)
342 /// - The target branch doesn't exist
343 /// - The branch has commits not in the target branch
344 ///
345 /// Returns true if:
346 /// - All commits in this branch are reachable from the target branch
347 pub fn is_merged_into(&self, target_branch: &str) -> Result<bool> {
348 // Get the branch name - return false if detached
349 let branch_name = match self.branch()? {
350 Some(name) => name,
351 None => return Ok(false), // Detached HEAD, no branch to check
352 };
353
354 // Don't consider the target branch as merged into itself
355 if branch_name == target_branch {
356 return Ok(false);
357 }
358
359 // Open the bare repository (not the worktree) to check actual branch states
360 // The worktree's .git points to the commondir (bare repo)
361 let worktree_repo = Repository::open(self.path())?;
362 let commondir = worktree_repo.commondir();
363 let repo = Repository::open(commondir)?;
364
365 // Find the current branch
366 let current_branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
367 Ok(b) => b,
368 Err(_) => return Ok(false), // Branch doesn't exist
369 };
370
371 // Find the target branch
372 let target = match repo.find_branch(target_branch, git2::BranchType::Local) {
373 Ok(b) => b,
374 Err(_) => return Ok(false), // Target branch doesn't exist
375 };
376
377 // Get commit OIDs
378 let current_oid = current_branch
379 .get()
380 .target()
381 .ok_or(WorktreeError::NoCurrentBranchTarget)?;
382 let target_oid = target.get().target().ok_or(WorktreeError::NoBranchTarget)?;
383
384 // If they point to the same commit, the branch is merged
385 if current_oid == target_oid {
386 return Ok(true);
387 }
388
389 // Check if current branch's commit is reachable from target
390 // This means target is a descendant of (or equal to) current
391 Ok(repo.graph_descendant_of(target_oid, current_oid)?)
392 }
393
394 /// Returns the commit hash (SHA) of the worktree's current HEAD.
395 ///
396 /// Returns None if HEAD cannot be resolved (e.g., empty repository).
397 pub fn head_commit(&self) -> Result<Option<String>> {
398 let repo = Repository::open(self.path())?;
399
400 // Try to resolve HEAD to a commit and extract the OID immediately
401 let commit_oid = match repo.head() {
402 Ok(head) => match head.peel_to_commit() {
403 Ok(commit) => Some(commit.id()),
404 Err(_) => return Ok(None), // HEAD exists but can't resolve to commit
405 },
406 Err(_) => return Ok(None), // No HEAD (unborn branch)
407 };
408
409 Ok(commit_oid.map(|oid| oid.to_string()))
410 }
411
412 /// Returns the timestamp of the HEAD commit as the last activity time.
413 ///
414 /// Returns None if:
415 /// - HEAD cannot be resolved (empty/unborn repository)
416 /// - HEAD cannot be peeled to a commit
417 pub fn last_activity(&self) -> Result<Option<i64>> {
418 let repo = Repository::open(self.path())?;
419 let seconds = match repo.head() {
420 Ok(head) => match head.peel_to_commit() {
421 Ok(commit) => Some(commit.time().seconds()),
422 Err(_) => None,
423 },
424 Err(_) => None,
425 };
426 Ok(seconds)
427 }
428
429 /// Returns true if the worktree's last activity is older than `days` days.
430 ///
431 /// Returns false if:
432 /// - Last activity cannot be determined
433 /// - The worktree has recent activity within the threshold
434 pub fn is_stale(&self, days: u32) -> Result<bool> {
435 let last = match self.last_activity()? {
436 Some(ts) => ts,
437 None => return Ok(false),
438 };
439 let now = std::time::SystemTime::now()
440 .duration_since(std::time::UNIX_EPOCH)
441 .map_err(std::io::Error::other)?
442 .as_secs() as i64;
443 let threshold = i64::from(days) * 86400;
444 Ok((now - last) > threshold)
445 }
446
447 /// Returns the name of the remote that the worktree's branch tracks (e.g., "origin").
448 ///
449 /// Returns None if:
450 /// - The worktree is detached (no branch)
451 /// - The branch has no upstream configured
452 pub fn remote(&self) -> Result<Option<String>> {
453 // Get the branch name - return None if detached
454 let branch_name = match self.branch()? {
455 Some(name) => name,
456 None => return Ok(None), // Detached HEAD, no branch to check
457 };
458
459 let repo = Repository::open(self.path())?;
460 let config = repo.config()?;
461
462 // Check for branch.<name>.remote in git config
463 let remote_key = format!("branch.{}.remote", branch_name);
464 match config.get_string(&remote_key) {
465 Ok(remote) => Ok(Some(remote)),
466 Err(_) => Ok(None), // No remote configured
467 }
468 }
469
470 /// Returns the full name of the upstream remote branch (e.g., "refs/remotes/origin/main").
471 ///
472 /// Returns None if:
473 /// - The worktree is detached (no branch)
474 /// - The branch has no upstream configured
475 pub fn remote_branch(&self) -> Result<Option<String>> {
476 // Get the branch name - return None if detached
477 let branch_name = match self.branch()? {
478 Some(name) => name,
479 None => return Ok(None), // Detached HEAD, no branch to check
480 };
481
482 let repo = Repository::open(self.path())?;
483
484 // Find the local branch and get its upstream, extracting the name immediately
485 let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
486 Ok(b) => b,
487 Err(_) => return Ok(None), // Branch doesn't exist
488 };
489
490 let upstream_name = match branch.upstream() {
491 Ok(upstream) => match upstream.name() {
492 Ok(Some(name)) => Some(name.to_string()),
493 _ => None,
494 },
495 Err(_) => return Ok(None), // No upstream configured
496 };
497
498 Ok(upstream_name)
499 }
500
501 /// Returns the default URL for the remote (usually the fetch URL).
502 ///
503 /// Returns None if:
504 /// - The worktree is detached (no branch)
505 /// - The branch has no upstream configured
506 /// - The remote has no URL configured
507 pub fn remote_url(&self) -> Result<Option<String>> {
508 // Get the remote name
509 let remote_name = match self.remote()? {
510 Some(name) => name,
511 None => return Ok(None),
512 };
513
514 let repo = Repository::open(self.path())?;
515
516 // Find the remote and extract the URL immediately
517 let url = match repo.find_remote(&remote_name) {
518 Ok(remote) => remote.url().ok().map(|s| s.to_string()),
519 Err(_) => return Ok(None), // Remote doesn't exist
520 };
521
522 Ok(url)
523 }
524
525 /// Returns the fetch URL for the remote.
526 ///
527 /// Returns None if:
528 /// - The worktree is detached (no branch)
529 /// - The branch has no upstream configured
530 /// - The remote has no fetch URL configured
531 pub fn remote_fetch_url(&self) -> Result<Option<String>> {
532 // Get the remote name
533 let remote_name = match self.remote()? {
534 Some(name) => name,
535 None => return Ok(None),
536 };
537
538 let repo = Repository::open(self.path())?;
539
540 // Find the remote and extract the fetch URL immediately
541 let url = match repo.find_remote(&remote_name) {
542 Ok(remote) => remote.url().ok().map(|s| s.to_string()),
543 Err(_) => return Ok(None), // Remote doesn't exist
544 };
545
546 Ok(url)
547 }
548
549 /// Returns the push URL for the remote.
550 ///
551 /// Returns None if:
552 /// - The worktree is detached (no branch)
553 /// - The branch has no upstream configured
554 /// - The remote has no push URL configured (falls back to fetch URL)
555 pub fn remote_push_url(&self) -> Result<Option<String>> {
556 // Get the remote name
557 let remote_name = match self.remote()? {
558 Some(name) => name,
559 None => return Ok(None),
560 };
561
562 let repo = Repository::open(self.path())?;
563
564 // Find the remote and extract the push URL (or fallback to fetch URL) immediately
565 let url = match repo.find_remote(&remote_name) {
566 Ok(remote) => remote
567 .pushurl()
568 .ok()
569 .flatten()
570 .or_else(|| remote.url().ok())
571 .map(|s| s.to_string()),
572 Err(_) => return Ok(None), // Remote doesn't exist
573 };
574
575 Ok(url)
576 }
577}
578
579impl fmt::Debug for WorktreeDescriptor {
580 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
581 write!(f, "WorktreeDescriptor({:?})", self.worktree.path())
582 }
583}
584
585impl fmt::Display for WorktreeDescriptor {
586 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
587 write!(f, "{}", self.worktree.path().display())
588 }
589}
590
591/// The private admin gitdir for a linked worktree: `<commondir>/worktrees/<name>`.
592///
593/// This is where git keeps the worktree's own `HEAD`, `index`, and refs — it survives deletion
594/// of the working directory, unlike the `<workdir>/.git` gitfile. Falls back to the commondir
595/// itself if the worktree name is unreadable (an invalid worktree we can't resolve anyway).
596fn worktree_admin_git_dir(repo: &Repository, worktree: &Worktree) -> PathBuf {
597 match worktree.name() {
598 Ok(Some(name)) => repo.commondir().join("worktrees").join(name),
599 _ => repo.commondir().to_path_buf(),
600 }
601}
602
603/// Return all worktrees registered with the repository.
604pub fn get_worktrees(repo: &Repository) -> Result<Vec<WorktreeDescriptor>> {
605 repo.worktrees()?
606 .into_iter()
607 .map(|name| {
608 let name = name?.ok_or(WorktreeError::InvalidName)?;
609 WorktreeDescriptor::new(repo, name)
610 })
611 .collect()
612}
613
614/// Return the worktree that contains the current working directory.
615///
616/// Returns [`WorktreeError::NotInWorktree`] if the current directory is not
617/// inside any registered worktree.
618pub fn current_worktree(repo: &Repository) -> Result<WorktreeDescriptor> {
619 let current_dir = std::env::current_dir().map_err(std::io::Error::other)?;
620
621 let worktrees = get_worktrees(repo)?;
622 worktrees
623 .into_iter()
624 .find(|wt| current_dir.starts_with(wt.path()))
625 .ok_or_else(|| WorktreeError::NotInWorktree.into())
626}
627
628/// Find a worktree by its name or by its branch name.
629///
630/// Returns [`WorktreeError::NotFound`] if no matching worktree exists.
631pub fn find_worktree(repo: &Repository, name: &str) -> Result<WorktreeDescriptor> {
632 let worktrees = get_worktrees(repo)?;
633 worktrees
634 .into_iter()
635 .find(|wt| {
636 // Match by worktree name or branch name
637 wt.name() == Some(name) || wt.branch().ok().flatten().as_deref() == Some(name)
638 })
639 .ok_or_else(|| WorktreeError::NotFound(name.to_string()).into())
640}
641
642/// Find the worktree whose checked-out branch is `branch`.
643///
644/// Unlike [`find_worktree`] this never matches by worktree name: after an
645/// in-place checkout a stack-home worktree's name routinely diverges from its
646/// branch, and a name match would treat "a worktree once created for T" as
647/// "T is checked out" — wrong for resolution decisions.
648///
649/// Returns [`WorktreeError::NotFound`] if no worktree has `branch` checked out.
650pub fn find_worktree_by_branch(repo: &Repository, branch: &str) -> Result<WorktreeDescriptor> {
651 let worktrees = get_worktrees(repo)?;
652 worktrees
653 .into_iter()
654 .find(|wt| wt.branch().ok().flatten().as_deref() == Some(branch))
655 .ok_or_else(|| WorktreeError::NotFound(branch.to_string()).into())
656}
657
658/// Result of looking up which remote carries a branch.
659pub enum RemoteResolution {
660 /// Exactly one remote (or a clear winner by priority) found.
661 Single { remote: String, oid: git2::Oid },
662 /// Two or more equally-preferred remotes carry the branch — user must choose.
663 Ambiguous(Vec<String>),
664 /// No remote has the branch.
665 None,
666}
667
668/// Find which remote(s) carry a branch, ranked by the shared
669/// [`remote_priority`](crate::pr::remote_priority) precedence
670/// (`upstream → origin → others`).
671///
672/// Returns `Ambiguous` when two or more equally-preferred remotes both carry
673/// the branch.
674pub fn resolve_remote_tracking(repo: &Repository, branch_name: &str) -> RemoteResolution {
675 let branches = match repo.branches(Some(git2::BranchType::Remote)) {
676 Ok(b) => b,
677 Err(_) => return RemoteResolution::None,
678 };
679
680 let mut candidates: Vec<(String, git2::Oid)> = branches
681 .flatten()
682 .filter_map(|(branch, _)| {
683 let name = branch.name().ok()??;
684 let (remote, br) = name.split_once('/')?;
685 if br != branch_name {
686 return None;
687 }
688 Some((remote.to_string(), branch.get().target()?))
689 })
690 .collect();
691
692 if candidates.is_empty() {
693 return RemoteResolution::None;
694 }
695
696 candidates.sort_by_key(|(r, _)| crate::pr::remote_priority(r));
697
698 if candidates.len() >= 2
699 && crate::pr::remote_priority(&candidates[0].0)
700 == crate::pr::remote_priority(&candidates[1].0)
701 {
702 return RemoteResolution::Ambiguous(candidates.into_iter().map(|(r, _)| r).collect());
703 }
704
705 let (remote, oid) = candidates.remove(0);
706 RemoteResolution::Single { remote, oid }
707}
708
709/// Create local branch `branch` from `remote`'s tracking ref and set its upstream.
710///
711/// The single place where a local branch is materialized from a remote tracking
712/// branch — keeping creation and upstream wiring together so no caller can get
713/// one without the other. Used by [`add_worktree`] when it resolves the remote
714/// itself, and by callers that resolved an ambiguous remote (e.g. by prompting).
715pub fn create_branch_from_remote(repo: &Repository, branch: &str, remote: &str) -> Result<()> {
716 let remote_ref = repo.find_reference(&format!("refs/remotes/{}/{}", remote, branch))?;
717 let commit = remote_ref.peel_to_commit()?;
718 let mut local_branch = repo.branch(branch, &commit, false)?;
719 local_branch.set_upstream(Some(&format!("{}/{}", remote, branch)))?;
720 Ok(())
721}
722
723/// Create a new worktree for the given branch.
724///
725/// The worktree directory is placed under the workon root (see [`workon_root`]).
726/// Branch names containing `/` are supported; parent directories are created
727/// automatically and the worktree is named after the final path component.
728///
729/// When `explicit_worktree_name` is `Some`, that value is used as the worktree
730/// directory name and filesystem path instead of deriving it from `branch_name`.
731/// This allows the worktree directory and the branch to have different names.
732///
733/// # Branch types
734///
735/// - [`BranchType::Normal`] — uses an existing local branch, creates a local branch
736/// from a matching remote tracking branch (setting upstream automatically), or
737/// creates a new branch from `base_branch` (or HEAD if `base_branch` is `None`).
738/// - [`BranchType::Orphan`] — creates an independent branch with no shared history,
739/// seeded with an empty initial commit.
740/// - [`BranchType::Detached`] — creates a worktree with a detached HEAD pointing to
741/// the current HEAD commit.
742pub fn add_worktree(
743 repo: &Repository,
744 branch_name: &str,
745 explicit_worktree_name: Option<&str>,
746 branch_type: BranchType,
747 base_branch: Option<&str>,
748 lock: bool,
749) -> Result<WorktreeDescriptor> {
750 // git worktree add <branch>
751 debug!(
752 "adding worktree for branch {:?} with type: {:?}",
753 branch_name, branch_type
754 );
755
756 let reference = match branch_type {
757 BranchType::Orphan => {
758 debug!("creating orphan branch {:?}", branch_name);
759 // For orphan branches, we'll create the branch after the worktree
760 None
761 }
762 BranchType::Detached => {
763 debug!("creating detached HEAD worktree at {:?}", branch_name);
764 // For detached worktrees, we don't create or use a branch reference
765 None
766 }
767 BranchType::Normal => {
768 let branch = match repo.find_branch(branch_name, git2::BranchType::Local) {
769 Ok(b) => b,
770 Err(e) => {
771 debug!("local branch not found: {:?}", e);
772 debug!("looking for remote tracking branch for {:?}", branch_name);
773 match resolve_remote_tracking(repo, branch_name) {
774 RemoteResolution::Single {
775 remote: remote_name,
776 ..
777 } => {
778 debug!(
779 "found remote tracking branch {}/{}, creating local branch",
780 remote_name, branch_name
781 );
782 create_branch_from_remote(repo, branch_name, &remote_name)?;
783 repo.find_branch(branch_name, git2::BranchType::Local)?
784 }
785 RemoteResolution::Ambiguous(_) | RemoteResolution::None => {
786 debug!(
787 "no remote tracking branch found, creating new local branch {:?}",
788 branch_name
789 );
790
791 // Determine which commit to branch from
792 let base_commit = if let Some(base) = base_branch {
793 // Branch from specified base branch
794 debug!("branching from base branch {:?}", base);
795 // Try local branch first, then remote branch
796 let base_branch =
797 match repo.find_branch(base, git2::BranchType::Local) {
798 Ok(b) => b,
799 Err(_) => {
800 debug!("base branch not found as local, trying remote");
801 repo.find_branch(base, git2::BranchType::Remote)?
802 }
803 };
804 base_branch.into_reference().peel_to_commit()?
805 } else {
806 // Default: branch from HEAD
807 repo.head()?.peel_to_commit()?
808 };
809
810 repo.branch(branch_name, &base_commit, false)?
811 }
812 }
813 }
814 };
815
816 Some(branch.into_reference())
817 }
818 };
819
820 let root = workon_root(repo)?;
821
822 // Determine worktree name and path.
823 // When an explicit name is provided, use it directly.
824 // Otherwise, derive from branch_name: git does not support worktree names with
825 // slashes, so take the basename of the branch name as the worktree name.
826 let (worktree_name, worktree_path) = if let Some(alias) = explicit_worktree_name {
827 let name = match Path::new(alias).file_name() {
828 Some(basename) => basename.to_str().ok_or(WorktreeError::InvalidName)?,
829 None => alias,
830 };
831 (name, root.join(alias))
832 } else {
833 let name = match Path::new(&branch_name).file_name() {
834 Some(basename) => basename.to_str().ok_or(WorktreeError::InvalidName)?,
835 None => branch_name,
836 };
837 (name, root.join(branch_name))
838 };
839
840 // Create parent directories if the branch name contains slashes
841 if let Some(parent) = worktree_path.parent() {
842 create_dir_all(parent)?;
843 }
844
845 let mut opts = WorktreeAddOptions::new();
846 if let Some(ref r) = reference {
847 opts.reference(Some(r));
848 }
849 if lock {
850 opts.lock(true);
851 }
852
853 debug!(
854 "adding worktree {} at {}",
855 worktree_name,
856 worktree_path.display()
857 );
858
859 let worktree = repo.worktree(worktree_name, worktree_path.as_path(), Some(&opts))?;
860
861 // For detached worktrees, set HEAD to point directly to a commit SHA
862 if branch_type == BranchType::Detached {
863 debug!("setting up detached HEAD for worktree {:?}", branch_name);
864
865 use std::fs;
866
867 // Get the current HEAD commit SHA
868 let head_commit = repo.head()?.peel_to_commit()?;
869 let commit_sha = head_commit.id().to_string();
870
871 // Write the commit SHA directly to the worktree's HEAD file
872 let git_dir = repo.path().join("worktrees").join(worktree_name);
873 let head_path = git_dir.join("HEAD");
874 fs::write(&head_path, format!("{}\n", commit_sha).as_bytes())?;
875
876 debug!(
877 "detached HEAD setup complete for worktree {:?} at {}",
878 branch_name, commit_sha
879 );
880 }
881
882 // For orphan branches, create an initial empty commit with no parent
883 if branch_type == BranchType::Orphan {
884 debug!(
885 "setting up orphan branch {:?} with initial empty commit",
886 branch_name
887 );
888
889 use std::fs;
890
891 // Get the common directory (bare repo path) - important when running from a worktree
892 let common_dir = repo.commondir();
893
894 // First, manually set HEAD to point to the new branch as a symbolic reference
895 // This ensures we're not trying to update an existing branch
896 let git_dir = common_dir.join("worktrees").join(worktree_name);
897 let head_path = git_dir.join("HEAD");
898 let branch_ref = format!("ref: refs/heads/{}\n", branch_name);
899 fs::write(&head_path, branch_ref.as_bytes())?;
900
901 // Remove any existing branch ref that libgit2 may have created
902 let branch_ref_path = common_dir.join("refs/heads").join(branch_name);
903 let _ = fs::remove_file(&branch_ref_path);
904
905 // Open the worktree repository
906 let worktree_repo = Repository::open(&worktree_path)?;
907
908 // Remove all files from the working directory (but keep .git)
909 for entry in fs::read_dir(&worktree_path)? {
910 let entry = entry?;
911 let path = entry.path();
912 if path.file_name() != Some(std::ffi::OsStr::new(".git")) {
913 if path.is_dir() {
914 fs::remove_dir_all(&path)?;
915 } else {
916 fs::remove_file(&path)?;
917 }
918 }
919 }
920
921 // Clear the index to start fresh
922 let mut index = worktree_repo.index()?;
923 index.clear()?;
924 index.write()?;
925
926 // Create an empty tree for the initial commit
927 let tree_id = index.write_tree()?;
928 let tree = worktree_repo.find_tree(tree_id)?;
929
930 // Create signature for the commit
931 let config = worktree_repo.config()?;
932 let sig = worktree_repo.signature().or_else(|_| {
933 // Fallback if no git config is set
934 git2::Signature::now(
935 config
936 .get_string("user.name")
937 .unwrap_or_else(|_| "git-workon".to_string())
938 .as_str(),
939 config
940 .get_string("user.email")
941 .unwrap_or_else(|_| "git-workon@localhost".to_string())
942 .as_str(),
943 )
944 })?;
945
946 // Create initial commit with no parents (orphan)
947 worktree_repo.commit(
948 Some("HEAD"),
949 &sig,
950 &sig,
951 "Initial commit",
952 &tree,
953 &[], // No parents - this makes it an orphan
954 )?;
955
956 debug!("orphan branch setup complete for {:?}", branch_name);
957 }
958
959 Ok(WorktreeDescriptor::of(repo, worktree))
960}
961
962/// Set upstream tracking for a worktree branch
963///
964/// Configures the branch in the worktree to track a remote branch by setting
965/// `branch.*.remote` and `branch.*.merge` configuration entries.
966///
967/// This is particularly important for PR worktrees to ensure they properly track
968/// the PR's remote branch.
969pub fn set_upstream_tracking(
970 worktree: &WorktreeDescriptor,
971 remote: &str,
972 remote_ref: &str,
973) -> Result<()> {
974 let repo = Repository::open(worktree.path())?;
975 let mut config = repo.config()?;
976
977 let head = repo.head()?;
978 let branch_name = head
979 .shorthand()
980 .ok()
981 .ok_or(WorktreeError::NoCurrentBranchTarget)?;
982
983 // Set branch.*.remote
984 let remote_key = format!("branch.{}.remote", branch_name);
985 config.set_str(&remote_key, remote)?;
986
987 // Set branch.*.merge
988 let merge_key = format!("branch.{}.merge", branch_name);
989 config.set_str(&merge_key, remote_ref)?;
990
991 debug!(
992 "Set upstream tracking: {} -> {}/{}",
993 branch_name, remote, remote_ref
994 );
995 Ok(())
996}