Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: add interactive git worktree operations #402

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ Then add the following to your shell's config file:

- **Interactive `git commit --fixup && git rebase -i --autosquash` selector** (`gfu`)

- **Interactive `git worktree list` selector** (`gwj`)
suft marked this conversation as resolved.
Show resolved Hide resolved

- **Interactive `git worktree lock <worktree>` selector** (`gwl`)

- **Interactive `git worktree remove <worktree>` selector** (`gwr`)

- **Interactive `git worktree unlock <worktree>` selector** (`gwu`)

# ⌨ Keybindings

| Key | Action |
Expand Down Expand Up @@ -188,6 +196,10 @@ forgit_cherry_pick=gcp
forgit_rebase=grb
forgit_blame=gbl
forgit_fixup=gfu
forgit_worktree_select=gws
forgit_worktree_lock=gwl
forgit_worktree_remove=gwr
forgit_worktree_unlock=gwu
suft marked this conversation as resolved.
Show resolved Hide resolved
```

## git integration
Expand Down Expand Up @@ -246,6 +258,10 @@ These are passed to the according `git` calls.
| `gbl` | `FORGIT_BLAME_GIT_OPTS` |
| `gfu` | `FORGIT_FIXUP_GIT_OPTS` |
| `gcp` | `FORGIT_CHERRY_PICK_GIT_OPTS` |
| `gwj` | `FORGIT_WORKTREE_PREVIEW_GIT_OPTS` |
suft marked this conversation as resolved.
Show resolved Hide resolved
| `gwl` | `FORGIT_WORKTREE_LOCK_GIT_OPTS`, `FORGIT_WORKTREE_PREVIEW_GIT_OPTS` |
| `gwr` | `FORGIT_WORKTREE_REMOVE_GIT_OPTS`, `FORGIT_WORKTREE_PREVIEW_GIT_OPTS` |
| `gwu` | `FORGIT_WORKTREE_PREVIEW_GIT_OPTS` |

## pagers

Expand Down Expand Up @@ -300,6 +316,10 @@ Customizing fzf options for each command individually is also supported:
| `gbl` | `FORGIT_BLAME_FZF_OPTS` |
| `gfu` | `FORGIT_FIXUP_FZF_OPTS` |
| `gcp` | `FORGIT_CHERRY_PICK_FZF_OPTS` |
| `gws` | `FORGIT_WORKTREE_SELECT_FZF_OPTS` |
suft marked this conversation as resolved.
Show resolved Hide resolved
| `gwl` | `FORGIT_WORKTREE_LOCK_FZF_OPTS` |
| `gwr` | `FORGIT_WORKTREE_REMOVE_FZF_OPTS` |
| `gwu` | `FORGIT_WORKTREE_UNLOCK_FZF_OPTS` |

Complete loading order of fzf options is:

Expand Down
162 changes: 162 additions & 0 deletions bin/git-forgit
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ $FORGIT_FZF_DEFAULT_OPTS

_forgit_warn() { printf "%b[Warn]%b %s\n" '\e[0;33m' '\e[0m' "$@" >&2; }
_forgit_info() { printf "%b[Info]%b %s\n" '\e[0;32m' '\e[0m' "$@" >&2; }
_forgit_inside_git_dir() { git rev-parse --is-inside-git-dir >/dev/null; }
_forgit_inside_work_tree() { git rev-parse --is-inside-work-tree >/dev/null; }
# tac is not available on OSX, tail -r is not available on Linux, so we use either of them
_forgit_reverse_lines() { tac 2> /dev/null || tail -r; }
Expand Down Expand Up @@ -1002,6 +1003,162 @@ _forgit_ignore_clean() {
[[ -d "$FORGIT_GI_REPO_LOCAL" ]] && rm -rf "$FORGIT_GI_REPO_LOCAL"
}

_forgit_filter_existing_paths() {
while read -r path; do
[[ -d "$path" ]] && echo "$path"
done
}

_forgit_worktree_preview() {
local sha
# trailing space in grep to avoid matching worktrees with a common path
sha=$(git worktree list | grep "$1 " | awk '{print $2}')
if [[ "$sha" == "(bare)" ]]; then
printf "%b(bare)%b %s\n" '\e[0;33m' '\e[0m' 'No history for git dir'
return
fi
# the trailing '--' ensures that this works for branches that have a name
# that is identical to a file
git log "$sha" "${_forgit_log_preview_options[@]}" --
Copy link
Contributor Author

@suft suft Nov 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • I should probably add preview options FORGIT_WORKTREE_PREVIEW_GIT_OPTS (like Add *_PREVIEW_GIT_OPTS variables #396)
  • I've noticed this can run a bit slow (on some computers) if you have a repo with a long history because it attempts to get the entire history for the branch checked out in that worktree
    • It would be good to see what others think when they test this out
  • Would be quicker if I limit the log entries in the worktree preview options

}

_forgit_worktree_select() {
_forgit_inside_work_tree || _forgit_inside_git_dir || return 1
sandr01d marked this conversation as resolved.
Show resolved Hide resolved
local worktree_list count tree opts
worktree_list=$(git worktree list | grep -vE "prunable$" | awk '{print $1}' | _forgit_filter_existing_paths)

count=$(echo "$worktree_list" | wc -l)
[[ $count -eq 1 ]] && return 1

opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index
--preview=\"$FORGIT worktree_preview {1}\"
$FORGIT_WORKTREE_SELECT_FZF_OPTS
"

tree=$(echo "$worktree_list" | FZF_DEFAULT_OPTS="$opts" fzf)
[[ -z "$tree" ]] && return 1
echo "$tree"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be cleaner to cd into the directory here instead of doing so in forgit.plugin.zsh and forgit.plugin.fish.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Using cd directly in the function only changes the current directory in the script child process, not in the terminal process where the script is called
  • Any preference between cd and pushd?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is tricky, because the actual change of the directory is currently implemented in the shell plugin. Some users (e.g. me) do not use forgit with the plugin but in the form of a subcommand of git. Calling git forgit worktree_jump won't work at the moment, though. IMO we should find a way to make this work correctly in both usecases, or otherwise rename the function (because right now it actually does not jump).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could rename the function to worktree_select - gws
(but also offer the gwj alias which combines cd and worktree_select)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that there is no way to manipulate the directory of the parent process, I think this is the best compromise we can achieve. @carlfriedrich WDYT?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems resonable to me. We should add an explaining line on this to the documentation, though.

}

_forgit_git_worktree_lock() {
_forgit_worktree_lock_git_opts=()
_forgit_parse_array _forgit_worktree_lock_git_opts "$FORGIT_WORKTREE_LOCK_GIT_OPTS"
git worktree lock "${_forgit_worktree_lock_git_opts[@]}" "$@"
}

_forgit_worktree_lock() {
_forgit_inside_work_tree || _forgit_inside_git_dir || return 1
sandr01d marked this conversation as resolved.
Show resolved Hide resolved

local tree opts reason
if [[ $# -ne 0 ]]; then
if [[ "$1" == "--reason" ]]; then
if [[ -z "$2" ]]; then
_forgit_warn "Option \`--reason' requires a value"
return 1
fi
reason="$2"
shift 2
fi

if [[ $# -eq 0 ]] || _forgit_contains_non_flags "$@"; then
git worktree lock "$@" --reason "$reason"
return $?
fi
fi

opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index
--preview=\"$FORGIT worktree_preview {1}\"
$FORGIT_WORKTREE_LOCK_FZF_OPTS
"

tree=$(git worktree list | grep -vE "\(bare\)|locked" | awk '{print $1}' | FZF_DEFAULT_OPTS="$opts" fzf)
[[ -z "$tree" ]] && return 1

if [[ -z "$reason" ]]; then
_forgit_git_worktree_lock "$tree"
else
_forgit_git_worktree_lock "$tree" --reason "$reason"
fi
Comment on lines +1054 to +1085
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could simplify this by not parsing the arguments and instead simply propagating them. This also allows us to stay up-to-date without any changes in case git introduces new arguments in the future.

Suggested change
local tree opts reason
if [[ $# -ne 0 ]]; then
if [[ "$1" == "--reason" ]]; then
if [[ -z "$2" ]]; then
_forgit_warn "Option \`--reason' requires a value"
return 1
fi
reason="$2"
shift 2
fi
if [[ $# -eq 0 ]] || _forgit_contains_non_flags "$@"; then
git worktree lock "$@" --reason "$reason"
return $?
fi
fi
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index
--preview=\"$FORGIT worktree_preview {1}\"
$FORGIT_WORKTREE_LOCK_FZF_OPTS
"
tree=$(git worktree list | grep -vE "\(bare\)|locked" | awk '{print $1}' | FZF_DEFAULT_OPTS="$opts" fzf)
[[ -z "$tree" ]] && return 1
if [[ -z "$reason" ]]; then
_forgit_git_worktree_lock "$tree"
else
_forgit_git_worktree_lock "$tree" --reason "$reason"
fi
local tree opts
if [[ $# -ne 0 ]] && _forgit_contains_non_flags "$@"; then
git worktree lock "$@"
return $?
fi
opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index
--preview=\"$FORGIT worktree_preview {1}\"
$FORGIT_WORKTREE_LOCK_FZF_OPTS
"
tree=$(git worktree list | grep -vE "\(bare\)|locked" | awk '{print $1}' | FZF_DEFAULT_OPTS="$opts" fzf)
[[ -z "$tree" ]] && return 1
_forgit_git_worktree_lock "$tree" "$@"

}

_forgit_git_worktree_remove() {
_forgit_worktree_remove_git_opts=()
_forgit_parse_array _forgit_worktree_remove_git_opts "$FORGIT_WORKTREE_REMOVE_GIT_OPTS"
git worktree remove "${_forgit_worktree_remove_git_opts[@]}" "$@"
}

_forgit_worktree_remove() {
_forgit_inside_work_tree || _forgit_inside_git_dir || return 1
local worktree_list tree opts force_flags

force_flags=()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, I would prefer simply propagating the arguments as we do everywhere else.


while (("$#")); do
case "$1" in
-f | --force)
force_flags+=("$1")
shift
;;
-*) shift ;;
*)
tree="$1"
shift
;;
esac
done

if [[ -n "$tree" ]]; then
_forgit_git_worktree_remove "${force_flags[@]}" "$tree"
worktree_remove_status=$?
return $worktree_remove_status
fi

worktree_list=$(git worktree list | grep -v "(bare)")

count=$(echo "$worktree_list" | wc -l)
[[ $count -eq 1 ]] && return 1

opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index
--preview=\"$FORGIT worktree_preview {1}\"
$FORGIT_WORKTREE_REMOVE_FZF_OPTS
"

tree=$(echo "$worktree_list" | awk '{print $1}' | FZF_DEFAULT_OPTS="$opts" fzf)
[[ -z "$tree" ]] && return 1
_forgit_git_worktree_remove "${force_flags[@]}" "$tree"
}

_forgit_worktree_unlock() {
_forgit_inside_work_tree || _forgit_inside_git_dir || return 1
if [[ $# -ne 0 ]]; then
git worktree unlock "$@"
worktree_unlock_status=$?
return $worktree_unlock_status
fi
local worktree_list tree opts

worktree_list=$(git worktree list | grep -v "(bare)" | grep -E "locked$")
count=$(echo "$worktree_list" | wc -l)
[[ $count -eq 0 ]] && return 1

opts="
$FORGIT_FZF_DEFAULT_OPTS
+s +m --tiebreak=index
--preview=\"$FORGIT worktree_preview {1}\"
$FORGIT_WORKTREE_UNLOCK_FZF_OPTS
"

tree=$(echo "$worktree_list" | awk '{print $1}' | FZF_DEFAULT_OPTS="$opts" fzf)
[[ -z "$tree" ]] && return 1
git worktree unlock "$tree"
suft marked this conversation as resolved.
Show resolved Hide resolved
}

public_commands=(
"add"
"blame"
Expand All @@ -1023,12 +1180,17 @@ public_commands=(
"revert_commit"
"stash_show"
"stash_push"
"worktree_select"
"worktree_lock"
"worktree_remove"
"worktree_unlock"
)

private_commands=(
"add_preview"
"blame_preview"
"branch_preview"
"worktree_preview"
"checkout_commit_preview"
"checkout_file_preview"
"cherry_pick_from_branch_preview"
Expand Down
16 changes: 16 additions & 0 deletions completions/_git-forgit
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ _git-stash-show() {
_alternative "files:filename:($(git stash list | sed -n -e 's/:.*//p'))"
}

_git-worktrees() {
_alternative "worktrees:worktree:($(git worktree list --porcelain | awk '/worktree/ {print $2}'))"
}
Comment on lines +21 to +23
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This always suggests me refs/heads/worktrees which is not a worktree. Also consider using -z together with --porcelain. From the git man page:

--porcelain
With list, output in an easy-to-parse format for scripts. This format will remain stable
across Git versions and regardless of user configuration. It is recommended to combine this
with -z. See below for
-z
Terminate each line with a NUL rather than a newline when --porcelain is specified with
list. This makes it possible to parse the output when a worktree path contains a newline
character.

The same applies to the bash completions.


# The completions for git already define a _git-diff completion function, but
# it provides the wrong results when called from _git-forgit because it heavily
# depends on the context it's been called from (usage of $curcontext and
Expand Down Expand Up @@ -77,6 +81,10 @@ _git-forgit() {
'revert_commit:git revert commit selector'
'stash_show:git stash viewer'
'stash_push:git stash push selector'
'worktree_select:git worktree selector'
'worktree_lock:git worktree lock selector'
'worktree_remove:git worktree remove selector'
'worktree_unlock:git worktree unlock selector'
)
_describe -t commands 'git forgit' subcommands
;;
Expand All @@ -97,6 +105,10 @@ _git-forgit() {
reset_head) _git-staged ;;
revert_commit) __git_recent_commits ;;
stash_show) _git-stash-show ;;
worktree_select) _git-worktrees ;;
worktree_lock) _git-worktrees ;;
worktree_remove) _git-worktrees ;;
worktree_unlock) _git-worktrees ;;
esac
}

Expand All @@ -122,6 +134,10 @@ compdef _git-rebase forgit::rebase
compdef _git-staged forgit::reset::head
compdef __git_recent_commits forgit::revert::commit
compdef _git-stash-show forgit::stash::show
compdef _git-worktrees forgit::worktree::select
compdef _git-worktrees forgit::worktree::lock
compdef _git-worktrees forgit::worktree::remove
compdef _git-worktrees forgit::worktree::unlock

# this is the case of calling the command and pressing tab
# the very first time of a shell session, we have to manually
Expand Down
21 changes: 21 additions & 0 deletions completions/git-forgit.bash
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ _git_stash_show()
__gitcomp_nl "$(__git stash list | sed -n -e 's/:.*//p')"
}

_git_worktrees()
{
__gitcomp_nl "$(git worktree list --porcelain | awk '/worktree/ {print $2}')"
}

# Completion for git-forgit
# This includes git aliases, e.g. "alias.cb=forgit checkout_branch" will
# correctly complete available branches on "git cb".
Expand Down Expand Up @@ -77,6 +82,10 @@ _git_forgit()
revert_commit
stash_show
stash_push
worktree_select
worktree_lock
worktree_remove
worktree_unlock
"

case ${cword} in
Expand All @@ -102,6 +111,10 @@ _git_forgit()
reset_head) _git_reset ;;
revert_commit) _git_revert ;;
stash_show) _git_stash_show ;;
worktree_select) _git_worktrees ;;
worktree_lock) _git_worktrees ;;
worktree_remove) _git_worktrees ;;
worktree_unlock) _git_worktrees ;;
esac
;;
*)
Expand Down Expand Up @@ -136,6 +149,10 @@ then
__git_complete forgit::reset::head _git_reset
__git_complete forgit::revert::commit _git_revert
__git_complete forgit::stash::show _git_stash_show
__git_complete forgit::worktree::select _git_worktrees
__git_complete forgit::worktree::lock _git_worktrees
__git_complete forgit::worktree::remove _git_worktrees
__git_complete forgit::worktree::unlock _git_worktrees

# Completion for forgit plugin shell aliases
if [[ -z "$FORGIT_NO_ALIASES" ]]; then
Expand All @@ -155,5 +172,9 @@ then
__git_complete "${forgit_reset_head}" _git_reset
__git_complete "${forgit_revert_commit}" _git_revert
__git_complete "${forgit_stash_show}" _git_stash_show
__git_complete "${forgit_worktree_select}" _git_worktrees
__git_complete "${forgit_worktree_lock}" _git_worktrees
__git_complete "${forgit_worktree_remove}" _git_worktrees
__git_complete "${forgit_worktree_unlock}" _git_worktrees
fi
fi
10 changes: 9 additions & 1 deletion completions/git-forgit.fish
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
function __fish_forgit_needs_subcommand
for subcmd in add blame branch_delete checkout_branch checkout_commit checkout_file checkout_tag \
cherry_pick cherry_pick_from_branch clean diff fixup ignore log reflog rebase reset_head \
revert_commit stash_show stash_push
revert_commit stash_show stash_push worktree_select worktree_lock worktree_remove worktree_unlock
if contains -- $subcmd (commandline -opc)
return 1
end
Expand Down Expand Up @@ -42,6 +42,10 @@ complete -c git-forgit -n __fish_forgit_needs_subcommand -a reset_head -d 'git r
complete -c git-forgit -n __fish_forgit_needs_subcommand -a revert_commit -d 'git revert commit selector'
complete -c git-forgit -n __fish_forgit_needs_subcommand -a stash_show -d 'git stash viewer'
complete -c git-forgit -n __fish_forgit_needs_subcommand -a stash_push -d 'git stash push selector'
complete -c git-forgit -n __fish_forgit_needs_subcommand -a worktree_select -d 'git worktree selector'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get error messages that look like this when trying to execute one of the commands in fish when inside a worktree:

forgit: 'worktree_lock' is not a valid forgit command.

The following commands are supported:
	add
	blame
	branch_delete
	checkout_branch
	checkout_commit
	checkout_file
	checkout_tag
	cherry_pick
	cherry_pick_from_branch
	clean
	diff
	fixup
	ignore
	log
	rebase
	reset_head
	revert_commit
	stash_show
	stash_push

@cjappl is our fish expert in case you need some help with this.

complete -c git-forgit -n __fish_forgit_needs_subcommand -a worktree_lock -d 'git worktree lock selector'
complete -c git-forgit -n __fish_forgit_needs_subcommand -a worktree_remove -d 'git worktree remove selector'
complete -c git-forgit -n __fish_forgit_needs_subcommand -a worktree_unlock -d 'git worktree unlock selector'

complete -c git-forgit -n '__fish_seen_subcommand_from add' -a "(complete -C 'git add ')"
complete -c git-forgit -n '__fish_seen_subcommand_from branch_delete' -a "(__fish_git_local_branches)"
Expand All @@ -59,3 +63,7 @@ complete -c git-forgit -n '__fish_seen_subcommand_from reset_head' -a "(__fish_g
complete -c git-forgit -n '__fish_seen_subcommand_from revert_commit' -a "(__fish_git_commits)"
complete -c git-forgit -n '__fish_seen_subcommand_from stash_show' -a "(__fish_git_complete_stashes)"
complete -c git-forgit -n '__fish_seen_subcommand_from stash_push' -a "(__fish_git_files modified deleted modified-staged-deleted)"
complete -c git-forgit -n '__fish_seen_subcommand_from worktree_select' -a "(__fish_git_complete_worktrees)"
complete -c git-forgit -n '__fish_seen_subcommand_from worktree_lock' -a "(complete -C 'git worktree lock ')"
complete -c git-forgit -n '__fish_seen_subcommand_from worktree_remove' -a "(complete -C 'git worktree remove ')"
complete -c git-forgit -n '__fish_seen_subcommand_from worktree_unlock' -a "(complete -C 'git worktree unlock ')"
5 changes: 5 additions & 0 deletions conf.d/forgit.plugin.fish
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,9 @@ if test -z "$FORGIT_NO_ALIASES"
abbr -a -- (string collect $forgit_revert_commit; or string collect "grc") git-forgit revert_commit
abbr -a -- (string collect $forgit_blame; or string collect "gbl") git-forgit blame
abbr -a -- (string collect $forgit_checkout_tag; or string collect "gct") git-forgit checkout_tag
abbr -a -- (string collect $forgit_worktree_select; or string collect "gwj") git-forgit worktree_select
abbr -a -- (string collect $forgit_worktree_jump; or string collect "gwj") 'set tree (git-forgit worktree_select); test -n "$tree"; and cd "$tree"'
abbr -a -- (string collect $forgit_worktree_lock; or string collect "gwl") git-forgit worktree_lock
abbr -a -- (string collect $forgit_worktree_remove; or string collect "gwr") git-forgit worktree_remove
abbr -a -- (string collect $forgit_worktree_unlock; or string collect "gwu") git-forgit worktree_unlock
end
Loading