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