Skip to content

Commit a002e73

Browse files
Merge pull request #10645 from gitbutlerapp/but-worktree-list
But worktree list
2 parents 9825d5b + f646919 commit a002e73

File tree

11 files changed

+245
-10
lines changed

11 files changed

+245
-10
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/but-api/src/commands/worktree.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::error::Error;
22
use but_api_macros::api_cmd;
33
use but_settings::AppSettings;
4+
use but_worktrees::list::ListWorktreeOutcome;
45
use but_worktrees::new::NewWorktreeOutcome;
56
use gitbutler_command_context::CommandContext;
67
use gitbutler_project::ProjectId;
@@ -17,3 +18,14 @@ pub fn worktree_new(project_id: ProjectId, reference: String) -> Result<NewWorkt
1718
but_worktrees::new::worktree_new(&mut ctx, guard.read_permission(), &reference)
1819
.map_err(Into::into)
1920
}
21+
22+
#[api_cmd]
23+
#[cfg_attr(feature = "tauri", tauri::command(async))]
24+
#[instrument(err(Debug))]
25+
pub fn worktree_list(project_id: ProjectId) -> Result<ListWorktreeOutcome, Error> {
26+
let project = gitbutler_project::get(project_id)?;
27+
let guard = project.exclusive_worktree_access();
28+
let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?;
29+
30+
but_worktrees::list::worktree_list(&mut ctx, guard.read_permission()).map_err(Into::into)
31+
}

crates/but-worktrees/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ tracing.workspace = true
2525

2626
[dev-dependencies]
2727
gix-testtools.workspace = true
28+
gitbutler-branch-actions.workspace = true
2829
gitbutler-testsupport.workspace = true
2930
gitbutler-oxidize.workspace = true
3031
insta.workspace = true

crates/but-worktrees/src/db.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub fn save_worktree(ctx: &mut CommandContext, worktree: Worktree) -> Result<()>
1313
}
1414

1515
/// Retrieve a worktree by its path.
16+
#[allow(unused)]
1617
pub fn get_worktree(ctx: &mut CommandContext, path: &Path) -> Result<Option<Worktree>> {
1718
let path_str = path.to_string_lossy();
1819
let worktree = ctx.db()?.worktrees().get(&path_str)?;
@@ -23,6 +24,7 @@ pub fn get_worktree(ctx: &mut CommandContext, path: &Path) -> Result<Option<Work
2324
}
2425

2526
/// Delete a worktree from the database.
27+
#[allow(unused)]
2628
pub fn delete_worktree(ctx: &mut CommandContext, path: &Path) -> Result<()> {
2729
let path_str = path.to_string_lossy();
2830
ctx.db()?.worktrees().delete(&path_str)?;

crates/but-worktrees/src/gc.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
use crate::{Worktree, WorktreeHealthStatus, WorktreeSource};
2+
use anyhow::Result;
3+
use bstr::BStr;
4+
use but_workspace::ui::StackHeadInfo;
5+
6+
pub fn get_health(
7+
repo: &gix::Repository,
8+
worktree: &Worktree,
9+
heads: &[StackHeadInfo],
10+
) -> Result<WorktreeHealthStatus> {
11+
if !heads.iter().any(|h| match &worktree.source {
12+
WorktreeSource::Branch(b) => h.name == BStr::new(&b),
13+
}) {
14+
return Ok(WorktreeHealthStatus::WorkspaceBranchMissing);
15+
};
16+
17+
let git_worktrees = repo.worktrees()?;
18+
let Some(git_worktree) = git_worktrees
19+
.iter()
20+
.find(|w| w.base().map(|b| b == worktree.path).unwrap_or(false))
21+
else {
22+
return Ok(WorktreeHealthStatus::WorktreeMissing);
23+
};
24+
25+
if repo.try_find_reference(&worktree.reference)?.is_none() {
26+
return Ok(WorktreeHealthStatus::BranchMissing);
27+
};
28+
let worktree_repo = git_worktree.clone().into_repo()?;
29+
30+
if !worktree_repo
31+
.head()?
32+
.referent_name()
33+
.map(|n| n.as_bstr() == worktree.reference)
34+
.unwrap_or(false)
35+
{
36+
return Ok(WorktreeHealthStatus::BranchNotCheckedOut);
37+
}
38+
39+
Ok(WorktreeHealthStatus::Normal)
40+
}

crates/but-worktrees/src/lib.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ use std::path::PathBuf;
55
use serde::{Deserialize, Serialize};
66

77
/// Database operations for worktrees.
8-
pub mod db;
9-
pub mod git;
8+
pub(crate) mod db;
9+
pub(crate) mod gc;
10+
pub(crate) mod git;
11+
pub mod list;
1012
pub mod new;
1113

1214
/// The source from which a worktree was created.
@@ -26,7 +28,7 @@ pub enum WorktreeSource {
2628
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2729
#[serde(rename_all = "camelCase")]
2830
pub struct Worktree {
29-
/// The filesystem path to the worktree.
31+
/// The canonicalized filesystem path to the worktree.
3032
pub path: PathBuf,
3133
/// The git reference this worktree was created from. This is a fully
3234
/// qualified reference
@@ -36,3 +38,21 @@ pub struct Worktree {
3638
/// The source from which this worktree was created.
3739
pub source: WorktreeSource,
3840
}
41+
42+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43+
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
44+
/// This gets used as a public API in the CLI so be careful when modifying.
45+
pub enum WorktreeHealthStatus {
46+
/// The worktree is in a healthy state
47+
Normal,
48+
/// The worktree has a different branch checked out than expected
49+
/// This is not strictly an issue & could be an intened user state
50+
BranchMissing,
51+
/// The branch we expect to be checked out does not exist
52+
/// This is not strictly an issue & could be an intened user state
53+
BranchNotCheckedOut,
54+
/// The actual worktree doesn't exist - should GC
55+
WorktreeMissing,
56+
/// No cooresponding branch name in workspace - should GC
57+
WorkspaceBranchMissing,
58+
}

crates/but-worktrees/src/list.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use anyhow::Result;
2+
use but_graph::VirtualBranchesTomlMetadata;
3+
use but_workspace::{StacksFilter, stacks_v3};
4+
use gitbutler_command_context::CommandContext;
5+
use gitbutler_project::access::WorktreeReadPermission;
6+
use serde::{Deserialize, Serialize};
7+
8+
use crate::{Worktree, WorktreeHealthStatus, db::list_worktrees, gc::get_health};
9+
10+
#[derive(Debug, Clone, Serialize, Deserialize)]
11+
#[serde(rename_all = "camelCase")]
12+
/// This gets used as a public API in the CLI so be careful when modifying.
13+
pub struct ListWorktreeOutcome {
14+
pub entries: Vec<WorktreeListEntry>,
15+
}
16+
17+
#[derive(Debug, Clone, Serialize, Deserialize)]
18+
#[serde(rename_all = "camelCase")]
19+
/// This gets used as a public API in the CLI so be careful when modifying.
20+
pub struct WorktreeListEntry {
21+
pub status: WorktreeHealthStatus,
22+
pub worktree: Worktree,
23+
}
24+
25+
/// Creates a new worktree off of a branches given name.
26+
pub fn worktree_list(
27+
ctx: &mut CommandContext,
28+
_perm: &WorktreeReadPermission,
29+
) -> Result<ListWorktreeOutcome> {
30+
let repo = ctx.gix_repo_for_merging()?;
31+
let meta = VirtualBranchesTomlMetadata::from_path(
32+
ctx.project().gb_dir().join("virtual_branches.toml"),
33+
)?;
34+
let stacks = stacks_v3(&repo, &meta, StacksFilter::InWorkspace, None)?;
35+
let heads = stacks.into_iter().flat_map(|s| s.heads).collect::<Vec<_>>();
36+
37+
let entries = list_worktrees(ctx)?
38+
.into_iter()
39+
.map(|w| {
40+
Ok(WorktreeListEntry {
41+
status: get_health(&repo, &w, &heads)?,
42+
worktree: w,
43+
})
44+
})
45+
.collect::<Result<Vec<_>>>()?;
46+
47+
Ok(ListWorktreeOutcome { entries })
48+
}

crates/but-worktrees/src/new.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ pub fn worktree_new(
4343
git_worktree_add(&ctx.project().path, &path, &branch_name, head.tip)?;
4444

4545
let worktree = Worktree {
46-
path,
46+
path: path.canonicalize()?,
4747
reference: format!("refs/heads/{}", branch_name),
4848
base: head.tip,
4949
source: WorktreeSource::Branch(head.name.to_string()),

crates/but-worktrees/tests/fixtures/worktree.sh

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,15 @@ git clone remote stacked-and-parallel
2020
git config user.name "Author"
2121
git config user.email "[email protected]"
2222

23-
echo A
2423
# Initialize GitButler project
2524
$CLI init
26-
echo B
2725

2826
# Create feature-a stack (base branch)
2927
$CLI branch new feature-a
3028
echo "feature-a line 1" >> file.txt
3129
$CLI commit -m "feature-a: add line 1" feature-a
3230
echo "feature-a line 2" >> file.txt
3331
$CLI commit -m "feature-a: add line 2" feature-a
34-
echo C
3532

3633
# Create feature-b stack (stacked on feature-a)
3734
$CLI branch new feature-b --anchor feature-a
@@ -46,4 +43,4 @@ git clone remote stacked-and-parallel
4643
$CLI commit -m "feature-c: add new file" feature-c
4744
echo "feature-c line 2" >> feature-c.txt
4845
$CLI commit -m "feature-c: add line 2" feature-c
49-
)
46+
)

crates/but-worktrees/tests/worktree/main.rs

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ mod util {
2323
}
2424
}
2525

26-
mod stacked_and_parallel {
26+
mod worktree_new {
2727
use super::*;
2828
use anyhow::Context;
2929
use but_graph::VirtualBranchesTomlMetadata;
@@ -187,3 +187,104 @@ mod stacked_and_parallel {
187187
Ok(())
188188
}
189189
}
190+
191+
mod worktree_list {
192+
use super::*;
193+
use but_graph::VirtualBranchesTomlMetadata;
194+
use but_workspace::{StacksFilter, stacks_v3};
195+
use but_worktrees::{WorktreeHealthStatus, list::worktree_list, new::worktree_new};
196+
use gitbutler_branch_actions::BranchManagerExt as _;
197+
use gix::refs::transaction::PreviousValue;
198+
use util::test_ctx;
199+
200+
#[test]
201+
fn can_list_worktrees() -> anyhow::Result<()> {
202+
let test_ctx = test_ctx("stacked-and-parallel")?;
203+
let mut ctx = test_ctx.ctx;
204+
205+
let repo = ctx.gix_repo()?;
206+
207+
let mut guard = ctx.project().exclusive_worktree_access();
208+
209+
let a = worktree_new(&mut ctx, guard.read_permission(), "feature-a")?; // To stay Normal
210+
let b = worktree_new(&mut ctx, guard.read_permission(), "feature-a")?; // To be BranchMissing
211+
let c = worktree_new(&mut ctx, guard.read_permission(), "feature-a")?; // To be BranchNotCheckedOut
212+
let d = worktree_new(&mut ctx, guard.read_permission(), "feature-a")?; // To be WorktreeMissing
213+
let e = worktree_new(&mut ctx, guard.read_permission(), "feature-c")?; // To be WorkspaceBranchMissing
214+
215+
let all = &[&a, &b, &c, &d, &e];
216+
217+
// All should start normal
218+
assert!(
219+
worktree_list(&mut ctx, guard.read_permission())?
220+
.entries
221+
.iter()
222+
.all(|e| all.iter().any(|a| a.created == e.worktree)
223+
&& e.status == WorktreeHealthStatus::Normal)
224+
);
225+
226+
// remove b's branch
227+
repo.find_reference(&b.created.reference)?.delete()?;
228+
229+
// checkout a different branch on c
230+
repo.reference(
231+
"refs/heads/new-ref",
232+
c.created.base,
233+
PreviousValue::Any,
234+
"New reference :D",
235+
)?;
236+
std::process::Command::from(gix::command::prepare(gix::path::env::exe_invocation()))
237+
.current_dir(&c.created.path)
238+
.arg("switch")
239+
.arg("new-ref")
240+
.output()?;
241+
242+
// delete d's worktree
243+
std::process::Command::from(gix::command::prepare(gix::path::env::exe_invocation()))
244+
.current_dir(&ctx.project().path)
245+
.arg("worktree")
246+
.arg("remove")
247+
.arg("-f")
248+
.arg(d.created.path.as_os_str())
249+
.output()?;
250+
251+
// remove `feature-c` branch from workspace
252+
// It would be nice to invoke the `but` cli here...
253+
let meta = VirtualBranchesTomlMetadata::from_path(
254+
ctx.project().gb_dir().join("virtual_branches.toml"),
255+
)?;
256+
let stack = stacks_v3(&repo, &meta, StacksFilter::InWorkspace, None)?
257+
.into_iter()
258+
.find(|s| s.heads.iter().any(|h| h.name == b"feature-c"))
259+
.unwrap();
260+
let branch_manager = ctx.branch_manager();
261+
branch_manager.unapply(
262+
stack.id.unwrap(),
263+
guard.write_permission(),
264+
false,
265+
vec![],
266+
ctx.app_settings().feature_flags.cv3,
267+
)?;
268+
269+
assert!(
270+
worktree_list(&mut ctx, guard.read_permission())?
271+
.entries
272+
.into_iter()
273+
.all(|entry| if entry.worktree == a.created {
274+
entry.status == WorktreeHealthStatus::Normal
275+
} else if entry.worktree == b.created {
276+
entry.status == WorktreeHealthStatus::BranchMissing
277+
} else if entry.worktree == c.created {
278+
entry.status == WorktreeHealthStatus::BranchNotCheckedOut
279+
} else if entry.worktree == d.created {
280+
entry.status == WorktreeHealthStatus::WorktreeMissing
281+
} else if entry.worktree == e.created {
282+
entry.status == WorktreeHealthStatus::WorkspaceBranchMissing
283+
} else {
284+
false
285+
})
286+
);
287+
288+
Ok(())
289+
}
290+
}

0 commit comments

Comments
 (0)