1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
//! WorkingTree - a borrowed handle for worktree-specific git operations.
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use dashmap::mapref::entry::Entry;
use crate::shell_exec::Cmd;
use dunce::canonicalize;
use super::{GitError, LineDiff, Repository};
/// Parse `git submodule status` output and detect whether any submodule is initialized.
///
/// Status lines start with a one-character state marker:
/// - `-` = not initialized
/// - ` ` / `+` / `U` = initialized variants
fn has_initialized_submodules_from_status(status: &str) -> bool {
status.lines().any(|line| match line.chars().next() {
Some('-') | None => false,
Some(_) => true,
})
}
/// Get a short display name for a path, used in logging context.
pub fn path_to_logging_context(path: &Path) -> String {
if path.to_str() == Some(".") {
".".to_string()
} else {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or(".")
.to_string()
}
}
/// A borrowed handle for running git commands in a specific worktree.
///
/// This type borrows a [`Repository`] and holds a path to a specific worktree.
/// All worktree-specific operations (like `branch`, `is_dirty`) are on this type.
///
/// For an owned equivalent that can be cloned across threads, see [`super::super::BranchRef`].
///
/// # Examples
///
/// ```no_run
/// use worktrunk::git::Repository;
///
/// let repo = Repository::current()?;
/// let wt = repo.current_worktree();
///
/// // Worktree-specific operations
/// let _ = wt.is_dirty();
/// let _ = wt.branch();
///
/// // View at a different worktree
/// let _other = repo.worktree_at("/path/to/other/worktree");
/// # Ok::<(), anyhow::Error>(())
/// ```
#[derive(Debug)]
#[must_use]
pub struct WorkingTree<'a> {
pub(super) repo: &'a Repository,
pub(super) path: PathBuf,
}
impl<'a> WorkingTree<'a> {
/// Get a reference to the repository this worktree belongs to.
pub fn repo(&self) -> &Repository {
self.repo
}
/// Get the path this WorkingTree was created with.
///
/// Returns the canonicalized form when the input passed to `worktree_at()` /
/// `base_path()` for `current_worktree()` exists on disk; otherwise returns
/// the raw input. So on macOS, a temp path like `/tmp/foo` may surface here
/// (and to hook template variables) as `/private/tmp/foo`.
///
/// For the canonical git-determined root, use [`root()`](Self::root) instead.
pub fn path(&self) -> &Path {
&self.path
}
/// Run a git command in this worktree and return stdout.
pub fn run_command(&self, args: &[&str]) -> anyhow::Result<String> {
let output = self.run_command_output(args)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.replace('\r', "\n");
let stdout = String::from_utf8_lossy(&output.stdout);
let error_msg = [stderr.trim(), stdout.trim()]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n");
bail!("{}", error_msg);
}
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
Ok(stdout)
}
/// Run a git command in this worktree and return the raw Output.
///
/// Use this when you need to check exit codes directly (e.g., for commands
/// where non-zero exit is not an error condition).
pub fn run_command_output(&self, args: &[&str]) -> anyhow::Result<std::process::Output> {
Cmd::new("git")
.args(args.iter().copied())
.current_dir(&self.path)
.context(path_to_logging_context(&self.path))
.run()
.with_context(|| format!("Failed to execute: git {}", args.join(" ")))
}
// =========================================================================
// Worktree-specific methods
// =========================================================================
/// Populate the `root`/`git_dir`/`branch` caches with a single batched
/// `git rev-parse` invocation and return whether this path is inside a
/// work tree.
///
/// Folds four rev-parse selectors that would otherwise fire as separate
/// forks during alias/hook dispatch (`--is-inside-work-tree` from
/// [`Repository::project_config_path`], plus one each for [`Self::root`],
/// [`Self::git_dir`], and [`Self::branch`]) into one. `HEAD` is last in
/// the argument list because `rev-parse` aborts processing once it hits
/// an unresolvable ref — on an unborn branch the preceding selectors'
/// stdout still lands, so we can cache `root`/`git_dir` even though the
/// command exits non-zero. The `branch` cache is only populated when
/// the whole batch succeeded so [`Self::branch`]'s `symbolic-ref`
/// fallback still runs for genuine unborn-HEAD paths.
pub fn prewarm_info(&self) -> anyhow::Result<bool> {
let output = self.run_command_output(&[
"rev-parse",
"--is-inside-work-tree",
"--show-toplevel",
"--git-dir",
"--symbolic-full-name",
"HEAD",
])?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout.lines();
let is_inside = lines.next().is_some_and(|s| s.trim() == "true");
// `root` and `git_dir` are safe to cache whenever their lines landed,
// because any failure in the batch is from `HEAD` — which is last.
if is_inside && let Some(root_raw) = lines.next() {
let root = PathBuf::from(root_raw.trim());
let root = canonicalize(&root).unwrap_or_else(|_| self.path.clone());
self.repo
.cache
.worktree_roots
.entry(self.path.clone())
.or_insert(root);
if let Some(git_dir_raw) = lines.next() {
let path = PathBuf::from(git_dir_raw.trim());
let absolute = if path.is_relative() {
self.path.join(&path)
} else {
path
};
if let Ok(resolved) = canonicalize(&absolute) {
self.repo
.cache
.git_dirs
.entry(self.path.clone())
.or_insert(resolved);
}
// Only trust the `HEAD` line when the batch as a whole
// succeeded. On unborn branches it reads "HEAD" as a fallback
// literal, which would be indistinguishable from detached
// HEAD without the exit status.
if output.status.success()
&& let Some(head_raw) = lines.next()
{
let branch = head_raw
.trim()
.strip_prefix("refs/heads/")
.map(str::to_owned);
self.repo
.cache
.current_branches
.entry(self.path.clone())
.or_insert(branch);
}
}
}
Ok(is_inside)
}
/// Get the branch checked out in this worktree, or None if in detached HEAD state.
///
/// Result is cached in the repository's shared cache (keyed by worktree path).
/// Errors (e.g., permission denied, corrupted `.git`) are propagated, not swallowed.
pub fn branch(&self) -> anyhow::Result<Option<String>> {
match self.repo.cache.current_branches.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => {
// rev-parse --symbolic-full-name returns "refs/heads/<branch>" on a branch,
// or "HEAD" when detached. Fails on unborn branches (no commits yet),
// so fall back to symbolic-ref which works in all cases except detached HEAD.
let result = match self.run_command(&["rev-parse", "--symbolic-full-name", "HEAD"])
{
Ok(stdout) => stdout.trim().strip_prefix("refs/heads/").map(str::to_owned),
Err(_) => self
.run_command(&["symbolic-ref", "--short", "HEAD"])
.ok()
.map(|s| s.trim().to_owned()),
};
Ok(e.insert(result).clone())
}
}
}
/// Return cached `git status --porcelain` output for this worktree.
///
/// Keyed by worktree path in the shared `RepoCache`, so parallel tasks that
/// each want porcelain (e.g., working-tree diff + conflict detection during
/// `wt list`) share a single subprocess. Uses `--no-optional-locks` to avoid
/// index-lock contention with the `git write-tree` run by
/// `WorkingTreeConflictsTask` in parallel.
pub fn status_porcelain_cached(&self) -> anyhow::Result<String> {
match self.repo.cache.status_porcelain.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => {
let stdout = self.run_command(&["--no-optional-locks", "status", "--porcelain"])?;
Ok(e.insert(stdout).clone())
}
}
}
/// Check if the working tree has uncommitted changes.
///
/// Note: This does NOT detect files hidden via `git update-index --assume-unchanged`
/// or `--skip-worktree`. We intentionally skip that check because:
/// 1. Detecting hidden files requires `git ls-files -v` which lists ALL tracked files
/// 2. On large repos (70k+ files), this adds noticeable latency to every clean check
/// 3. Users who use skip-worktree are power users who understand the implications
/// 4. A warning wouldn't prevent data loss anyway — it's informational only
pub fn is_dirty(&self) -> anyhow::Result<bool> {
let stdout = self.run_command(&["status", "--porcelain"])?;
Ok(!stdout.trim().is_empty())
}
/// Get the root directory of this worktree (top-level of the working tree).
///
/// Returns the canonicalized absolute path to the top-level directory.
/// This could be the main worktree or a linked worktree.
/// Result is cached in the repository's shared cache (keyed by worktree path).
pub fn root(&self) -> anyhow::Result<PathBuf> {
match self.repo.cache.worktree_roots.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => {
let root = self
.run_command(&["rev-parse", "--show-toplevel"])
.ok()
.map(|s| PathBuf::from(s.trim()))
.and_then(|p| canonicalize(&p).ok())
.unwrap_or_else(|| self.path.clone());
Ok(e.insert(root).clone())
}
}
}
/// Get the git directory (may be different from common-dir in worktrees).
///
/// Always returns a canonicalized absolute path, resolving symlinks.
/// This ensures consistent comparison with `git_common_dir()`.
/// Result is cached in the repository's shared cache (keyed by worktree path).
pub fn git_dir(&self) -> anyhow::Result<PathBuf> {
match self.repo.cache.git_dirs.entry(self.path.clone()) {
Entry::Occupied(e) => Ok(e.get().clone()),
Entry::Vacant(e) => {
let stdout = self.run_command(&["rev-parse", "--git-dir"])?;
let path = PathBuf::from(stdout.trim());
// Always canonicalize to resolve symlinks (e.g., /var -> /private/var on macOS)
let absolute_path = if path.is_relative() {
self.path.join(&path)
} else {
path
};
let resolved =
canonicalize(&absolute_path).context("Failed to resolve git directory")?;
Ok(e.insert(resolved).clone())
}
}
}
/// Check if a rebase is in progress.
pub fn is_rebasing(&self) -> anyhow::Result<bool> {
let git_dir = self.git_dir()?;
Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
}
/// Check if a merge is in progress.
pub fn is_merging(&self) -> anyhow::Result<bool> {
let git_dir = self.git_dir()?;
Ok(git_dir.join("MERGE_HEAD").exists())
}
/// Check if this is a linked worktree (vs the main worktree).
///
/// Returns `true` for linked worktrees (created via `git worktree add`),
/// `false` for the main worktree (original clone location).
///
/// Implementation: compares `git_dir` vs `common_dir`. In linked worktrees,
/// the `.git` file points to `.git/worktrees/NAME`, so they differ. In the
/// main worktree, both point to the same `.git` directory.
///
/// For bare repos, all worktrees are "linked" (returns `true`).
pub fn is_linked(&self) -> anyhow::Result<bool> {
let git_dir = self.git_dir()?;
let common_dir = self.repo.git_common_dir();
Ok(git_dir != common_dir)
}
/// Ensure this worktree is clean (no uncommitted changes).
///
/// Returns an error if there are uncommitted changes.
/// - `action` describes what was blocked (e.g., "remove worktree").
/// - `branch` identifies which branch for multi-worktree operations.
/// - `force_hint` when true, the error hint mentions `--force` as an alternative.
pub fn ensure_clean(
&self,
action: &str,
branch: Option<&str>,
force_hint: bool,
) -> anyhow::Result<()> {
if self.is_dirty()? {
return Err(GitError::UncommittedChanges {
action: Some(action.into()),
branch: branch.map(String::from),
force_hint,
}
.into());
}
Ok(())
}
/// Get line diff statistics for working tree changes (unstaged + staged).
pub fn working_tree_diff_stats(&self) -> anyhow::Result<LineDiff> {
let stdout = self.run_command(&["diff", "--shortstat", "HEAD"])?;
Ok(LineDiff::from_shortstat(&stdout))
}
/// Determine whether there are staged changes in the index.
///
/// Returns `Ok(true)` when staged changes are present, `Ok(false)` otherwise.
///
/// Note: The index is per-worktree in git, so this checks this specific
/// worktree's staging area.
pub fn has_staged_changes(&self) -> anyhow::Result<bool> {
// Exit code 0 = no diff (no staged changes), exit code 1 = diff exists (has staged changes)
// run_command returns Ok on exit 0, Err on non-zero
// So: Err means has changes
Ok(self
.run_command(&["diff", "--cached", "--quiet", "--exit-code"])
.is_err())
}
/// Check whether this worktree has initialized submodules.
///
/// Uses `git submodule status --recursive` and parses its stable single-character
/// status prefix instead of relying on human-readable git error messages.
pub fn has_initialized_submodules(&self) -> anyhow::Result<bool> {
let output = self.run_command(&["submodule", "status", "--recursive"])?;
Ok(has_initialized_submodules_from_status(&output))
}
/// Create a safety backup of current working tree state without affecting the working tree.
///
/// This creates a backup commit containing all changes (staged, unstaged, and untracked files)
/// and stores it in a custom ref (`refs/wt-backup/<branch>`). This creates a reflog entry
/// for recovery without polluting the stash list. The working tree remains unchanged.
///
/// Users can find safety backups with: `git reflog show refs/wt-backup/<branch>`
///
/// Returns the short SHA of the backup commit.
///
/// # Example
/// ```no_run
/// use worktrunk::git::Repository;
///
/// let repo = Repository::current()?;
/// let wt = repo.current_worktree();
/// let sha = wt.create_safety_backup("feature → main (squash)")?;
/// println!("Backup created: {}", sha);
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn create_safety_backup(&self, message: &str) -> anyhow::Result<String> {
// Create a backup commit using git stash create (without storing it in the stash list)
let backup_sha = self
.run_command(&["stash", "create", "--include-untracked"])?
.trim()
.to_string();
// Validate that we got a SHA back
if backup_sha.is_empty() {
return Err(GitError::Other {
message: "git stash create returned empty SHA - no changes to backup".into(),
}
.into());
}
// Get current branch name to use in the ref name
let stdout = self.run_command(&["rev-parse", "--symbolic-full-name", "HEAD"])?;
let branch = stdout
.trim()
.strip_prefix("refs/heads/")
.unwrap_or("HEAD")
.to_string();
// Sanitize branch name for use in ref path (replace / with -)
let safe_branch = branch.replace('/', "-");
// Update a custom ref to point to this commit
// --create-reflog ensures the reflog is created for this custom ref
// This creates a reflog entry but doesn't add to the stash list
let ref_name = format!("refs/wt-backup/{}", safe_branch);
self.run_command(&[
"update-ref",
"--create-reflog",
"-m",
message,
&ref_name,
&backup_sha,
])
.context("Failed to create backup ref")?;
Ok(backup_sha[..7].to_string())
}
}
#[cfg(test)]
mod tests {
use super::has_initialized_submodules_from_status;
#[test]
fn submodule_status_empty_is_not_initialized() {
assert!(!has_initialized_submodules_from_status(""));
}
#[test]
fn submodule_status_dash_is_not_initialized() {
assert!(!has_initialized_submodules_from_status(
"-9c8b8ff2fe89b8f1c5b8e17cb60f0d0df47f71e0 submod"
));
}
#[test]
fn submodule_status_space_is_initialized() {
assert!(has_initialized_submodules_from_status(
" 9c8b8ff2fe89b8f1c5b8e17cb60f0d0df47f71e0 submod (heads/main)"
));
}
#[test]
fn submodule_status_plus_is_initialized() {
assert!(has_initialized_submodules_from_status(
"+9c8b8ff2fe89b8f1c5b8e17cb60f0d0df47f71e0 submod (heads/main)"
));
}
}