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
106 changes: 64 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ 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`/`gws`)

+ `gwj` jumps to the worktree using `cd` and can only be used via the alias, no equivalent behavior using forgit as a git subcommand

- **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 +198,10 @@ forgit_cherry_pick=gcp
forgit_rebase=grb
forgit_blame=gbl
forgit_fixup=gfu
forgit_worktree_select=gwj/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 @@ -226,26 +240,30 @@ git cf
If you want to customize `git`'s behavior within forgit there is a dedicated variable for each forgit command.
These are passed to the according `git` calls.

| Command | Option |
| -------- | --------------------------------------------------------------------------- |
| `ga` | `FORGIT_ADD_GIT_OPTS` |
| `glo` | `FORGIT_LOG_GIT_OPTS` |
| `grl` | `FORGIT_REFLOG_GIT_OPTS` |
| `gd` | `FORGIT_DIFF_GIT_OPTS` |
| `grh` | `FORGIT_RESET_HEAD_GIT_OPTS` |
| `gcf` | `FORGIT_CHECKOUT_FILE_GIT_OPTS` |
| `gcb` | `FORGIT_CHECKOUT_BRANCH_GIT_OPTS`, `FORGIT_CHECKOUT_BRANCH_BRANCH_GIT_OPTS` |
| `gbd` | `FORGIT_BRANCH_DELETE_GIT_OPTS` |
| `gct` | `FORGIT_CHECKOUT_TAG_GIT_OPTS` |
| `gco` | `FORGIT_CHECKOUT_COMMIT_GIT_OPTS` |
| `grc` | `FORGIT_REVERT_COMMIT_GIT_OPTS` |
| `gss` | `FORGIT_STASH_SHOW_GIT_OPTS` |
| `gsp` | `FORGIT_STASH_PUSH_GIT_OPTS` |
| `gclean` | `FORGIT_CLEAN_GIT_OPTS` |
| `grb` | `FORGIT_REBASE_GIT_OPTS` |
| `gbl` | `FORGIT_BLAME_GIT_OPTS` |
| `gfu` | `FORGIT_FIXUP_GIT_OPTS` |
| `gcp` | `FORGIT_CHERRY_PICK_GIT_OPTS` |
| Command | Option |
| ----------- | --------------------------------------------------------------------------- |
| `ga` | `FORGIT_ADD_GIT_OPTS` |
| `glo` | `FORGIT_LOG_GIT_OPTS` |
| `grl` | `FORGIT_REFLOG_GIT_OPTS` |
| `gd` | `FORGIT_DIFF_GIT_OPTS` |
| `grh` | `FORGIT_RESET_HEAD_GIT_OPTS` |
| `gcf` | `FORGIT_CHECKOUT_FILE_GIT_OPTS` |
| `gcb` | `FORGIT_CHECKOUT_BRANCH_GIT_OPTS`, `FORGIT_CHECKOUT_BRANCH_BRANCH_GIT_OPTS` |
| `gbd` | `FORGIT_BRANCH_DELETE_GIT_OPTS` |
| `gct` | `FORGIT_CHECKOUT_TAG_GIT_OPTS` |
| `gco` | `FORGIT_CHECKOUT_COMMIT_GIT_OPTS` |
| `grc` | `FORGIT_REVERT_COMMIT_GIT_OPTS` |
| `gss` | `FORGIT_STASH_SHOW_GIT_OPTS` |
| `gsp` | `FORGIT_STASH_PUSH_GIT_OPTS` |
| `gclean` | `FORGIT_CLEAN_GIT_OPTS` |
| `grb` | `FORGIT_REBASE_GIT_OPTS` |
| `gbl` | `FORGIT_BLAME_GIT_OPTS` |
| `gfu` | `FORGIT_FIXUP_GIT_OPTS` |
| `gcp` | `FORGIT_CHERRY_PICK_GIT_OPTS` |
| `gwj`/`gws` | `FORGIT_WORKTREE_PREVIEW_GIT_OPTS` |
| `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 All @@ -254,7 +272,7 @@ Forgit will use the default configured pager from git (`core.pager`,
variables:

| Use case | Option | Fallbacks to |
| ------------ | ------------------- | --------------------------------------------- |
| -------------------- | --------------------- | --------------------------------------------- |
| common pager | `FORGIT_PAGER` | `git config core.pager` _or_ `cat` |
| pager on `git show` | `FORGIT_SHOW_PAGER` | `git config pager.show` _or_ `$FORGIT_PAGER` |
| pager on `git diff` | `FORGIT_DIFF_PAGER` | `git config pager.diff` _or_ `$FORGIT_PAGER` |
Expand All @@ -279,27 +297,31 @@ export FORGIT_FZF_DEFAULT_OPTS="

Customizing fzf options for each command individually is also supported:

| Command | Option |
|----------|-----------------------------------|
| `ga` | `FORGIT_ADD_FZF_OPTS` |
| `glo` | `FORGIT_LOG_FZF_OPTS` |
| `grl` | `FORGIT_REFLOG_FZF_OPTS` |
| `gi` | `FORGIT_IGNORE_FZF_OPTS` |
| `gd` | `FORGIT_DIFF_FZF_OPTS` |
| `grh` | `FORGIT_RESET_HEAD_FZF_OPTS` |
| `gcf` | `FORGIT_CHECKOUT_FILE_FZF_OPTS` |
| `gcb` | `FORGIT_CHECKOUT_BRANCH_FZF_OPTS` |
| `gbd` | `FORGIT_BRANCH_DELETE_FZF_OPTS` |
| `gct` | `FORGIT_CHECKOUT_TAG_FZF_OPTS` |
| `gco` | `FORGIT_CHECKOUT_COMMIT_FZF_OPTS` |
| `grc` | `FORGIT_REVERT_COMMIT_FZF_OPTS` |
| `gss` | `FORGIT_STASH_FZF_OPTS` |
| `gsp` | `FORGIT_STASH_PUSH_FZF_OPTS` |
| `gclean` | `FORGIT_CLEAN_FZF_OPTS` |
| `grb` | `FORGIT_REBASE_FZF_OPTS` |
| `gbl` | `FORGIT_BLAME_FZF_OPTS` |
| `gfu` | `FORGIT_FIXUP_FZF_OPTS` |
| `gcp` | `FORGIT_CHERRY_PICK_FZF_OPTS` |
| Command | Option |
|-------------|-----------------------------------|
| `ga` | `FORGIT_ADD_FZF_OPTS` |
| `glo` | `FORGIT_LOG_FZF_OPTS` |
| `grl` | `FORGIT_REFLOG_FZF_OPTS` |
| `gi` | `FORGIT_IGNORE_FZF_OPTS` |
| `gd` | `FORGIT_DIFF_FZF_OPTS` |
| `grh` | `FORGIT_RESET_HEAD_FZF_OPTS` |
| `gcf` | `FORGIT_CHECKOUT_FILE_FZF_OPTS` |
| `gcb` | `FORGIT_CHECKOUT_BRANCH_FZF_OPTS` |
| `gbd` | `FORGIT_BRANCH_DELETE_FZF_OPTS` |
| `gct` | `FORGIT_CHECKOUT_TAG_FZF_OPTS` |
| `gco` | `FORGIT_CHECKOUT_COMMIT_FZF_OPTS` |
| `grc` | `FORGIT_REVERT_COMMIT_FZF_OPTS` |
| `gss` | `FORGIT_STASH_FZF_OPTS` |
| `gsp` | `FORGIT_STASH_PUSH_FZF_OPTS` |
| `gclean` | `FORGIT_CLEAN_FZF_OPTS` |
| `grb` | `FORGIT_REBASE_FZF_OPTS` |
| `gbl` | `FORGIT_BLAME_FZF_OPTS` |
| `gfu` | `FORGIT_FIXUP_FZF_OPTS` |
| `gcp` | `FORGIT_CHERRY_PICK_FZF_OPTS` |
| `gwj`/`gws` | `FORGIT_WORKTREE_SELECT_FZF_OPTS` |
| `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
Loading