英文:
Pop conflicting Git stash while keeping stash index
问题
解释:
我经常大量使用Git的暂存区来跟踪我已经确定的更改,而工作目录通常是一团未经测试的解决方案、待办事项和通常非常不完整的代码。
失去了索引和工作目录之间的区别是一个重大的挫折,因为我必须重新评估所有的更改(通常一行的一半应该被暂存,另一半是一个待办事项注释)。
现在,有一种经常发生的情况,我意识到我的当前更改需要先完成其他的更改。
我非常喜欢使用暂存区,所以在这种情况下,我会使用git stash push
,然后在其他更改提交并且工作目录再次干净的时候使用git stash pop --index
。
然而,通常情况下我的存储项和新的HEAD
之间会有一些冲突(通常是非常小的冲突,这使得情况变得更加烦人)。
这会锁定--index
选项,并迫使我放弃我的缓存,并在解决冲突后手动从头开始重建它。
有没有办法在解决冲突后保留/恢复索引?
对我来说,如果冲突也在暂存区解决,或者这些文件保持与存储项中的文件完全相同,都没有关系。
如果有一种方法可以只弹出存储项而不包括索引,解决冲突后再将旧的索引应用到它上面,那将是最好的,但如果我必须解决两次冲突(分别解决索引),那也可以接受。
TL;DR:
我需要一种在弹出与当前HEAD
冲突的存储项时保留索引的方法。
示例:
这里有一个简单的Shell脚本,它创建一个新的存储库并重现了这种情况:
mkdir example && cd example || exit
git init
printf 'first line\nlast line\n' >foo
git add foo
git commit -m 'initial commit'
sed -i '2i a good line that should be staged' foo
git add foo
sed -i '3i a WIP line that should NOT be staged' foo
git stash push -m 'the stash with index'
sed -i '2i some conflicting change' foo
git commit -a -m 'a new HEAD conflicting with stash'
git stash pop --index # this doesn't work
英文:
Explanation:
I make a heavy use of Git staging area to keep track of the changes that I'm already sure of while the working directory is often a mess of untested solutions, TODOs and a code that is generally very WIP.
Loosing the distinction between the index and the working directory is a significant setback because I have to reevaluate all my changes (where often half of a line should be staged and half is a TODO comment).
Now, there is a recurring situation when I realize that for the my current changes require something else to work first.
I'm a big fan of staging so what I do in that case is to git stash push
and after the other change is committed and the working directory clean again git stash pop --index
.
However, it is common that there are some conflicts between my stash entry and the new HEAD
(usually very minor ones which is doubly annoying).
This locks off the option --index
and forces me to drop my cache and manually rebuild it from scratch after resolving the conflicts.
Is there a way to keep/restore the index after the conflicts are resolved?
It doesn't matter to me if the conflicts will also be resolved in the staging area or these files remain exactly as they were in the stash.
I would be most happy with a way to just pop the stash without index, resolve the conflicts and slap the old index back on it but if I have to resolve conflicts 2 times (separately for the index), this is also fine.
TL;DR:
I need a way to keep the index when popping stash that conflicts with the current HEAD
.
Example:
Here is a simple shell script that creates a new repository and reproduces this situation:
mkdir example && cd example || exit
git init
printf 'first line\nlast line\n' >foo
git add foo
git commit -m 'initial commit'
sed -i '2i a good line that should be staged' foo
git add foo
sed -i '3i a WIP line that should NOT be staged' foo
git stash push -m 'the stash with index'
sed -i '2i some conflicting change' foo
git commit -a -m 'a new HEAD conflicting with stash'
git stash pop --index # this doesn't work
答案1
得分: 1
你的主要问题是“工作目录经常是一堆未经测试的解决方案、待办事项和通常非常未完成的代码”。这些更改应该作为临时提交进行检查,例如:
git add ...
git commit -m "==== 未经测试的解决方案 1 ===="
git add ...
git commit -m "==== 未经测试的解决方案 2 ===="
git add ...
git commit -m "==== TODO something ===="
git add ...
git commit -m "==== 未完成部分 1 ===="
git add ...
git commit -m "==== 未完成部分 2 ===="
当更改作为正确的提交进行检查时,就不会存在对索引和工作目录之间区别的焦虑。
所以,解决了“工作目录一团糟”的问题后,让我们专注于这个问题。
现在,有一种经常发生的情况,我意识到我的当前更改需要先完成其他工作。
你应该使用分支来解决这个问题!
mkdir example2 && cd example2 || exit
git init
printf 'first line\nlast line\n' >foo
git add foo
git commit -m '初始提交'
sed -i '2i a good line that should be staged' foo
# <-------- Git 历史参考点 1
git add foo
# <-------- Git 历史参考点 2
sed -i '3i a WIP line that should NOT be staged' foo
# <-------- Git 历史参考点 3
# 所以在这一点上,你意识到添加到索引的行需要更新,但你不想包含当前的 WIP 更改(你也不想丢失它)
# 好吧,解决方案是为索引中的内容创建一个临时提交,然后检出一个新分支并在那里提交 WIP 更改。
git commit -m '==== 包含索引的暂存 ===='
git checkout -b wip_branch
git add foo
git commit -m '==== WIP 更改 ====';
git checkout main
git reset HEAD^ # 这会丢弃实际提交 '==== 包含索引的暂存 ====',但保留该提交中的更改在工作目录中,
# 因此实际上恢复了 Git 历史参考点 1。
sed -i '2i some conflicting change' foo
git commit -a -m '与 WIP 更改冲突的新 HEAD'
git rebase main wip_branch
重新基于操作会触发冲突,可以通过使用 KDiff3来解决冲突。
在这种情况下,KDiff3 没有自动匹配两行相同的情况
但你可以通过添加手动差异对齐来覆盖(在侧边栏上用橙色表示)。
在重新基于之前,历史记录如下所示
之后
通过运行以下命令解决冲突
$ git rebase main wip_branch
Auto-merging foo
CONFLICT (content): Merge conflict in foo
error: could not apply 8e05888... ==== 包含索引的暂存 ====
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply 8e05888... ==== 包含索引的暂存 ====
$ git resolve-conflict-using-kdiff3
================================================================================
1 unmerged files in total:
foo
================================================================================
Handling foo (1/1): Modified on both branches
1: 5776cea 2: bdb7168 3: 84ae2e5
Launch kdiff3 for foo? [YyNnQq123] (y):
Update foo with merge result? [YyNnQq] (y): y
interactive rebase in progress; onto b0f7593
Last command done (1 command done):
pick 8e05888 ==== 包含索引的暂存 ====
Next command to do (1 remaining command):
pick a33c2fd ==== WIP 更改 ====
(use "git rebase --edit-todo" to view and edit)
You are currently rebasing branch 'wip_branch' on 'b0f7593'.
(all conflicts fixed: run "git rebase --continue")
Untracked files:
(use "git add <file>..." to include in what will be committed)
foo.merged.orig
nothing added to commit but untracked files present (use "git add" to track)
Command(s) suggested to continue:
git rebase --skip
$ git rebase --skip
Successfully rebased and updated refs/heads/wip_branch.
$ git checkout main
Switched to branch 'main'
$ git merge --ff wip_branch
Updating b0f7593..8815707
Fast-forward
foo | 1 +
1 file changed, 1 insertion(+)
$ git branch -d wip_branch
Deleted branch wip_branch (was 8815707).
$ git reset HEAD^
Unstaged changes after reset:
M foo
$
此时,代码回到了 Git 历史参考点 3,但注入了额外的冲突更改。
这个答案是最通用的答案,对于你提供的特定示例,我可以使用 git add -p
来避免以下分支和重新基于操作。
英文:
Your main problem is "the working directory is often a mess of untested solutions, TODOs and a code that is generally very WIP."
. These changes should be checked in as temporary commits, e.g.:
git add ...
git commit -m "==== Untested solution 1 ====="
git add ...
git commit -m "==== Untested solution 2 ====="
git add ...
git commit -m "==== TODO something ====="
git add ...
git commit -m "==== WIP part 1 ====="
git add ...
git commit -m "==== WIP part 2 ====="
When changes are checked in as proper commits there cannot exist any anxiety over loosing the distinction between the index and the working directory.
So with "the working directory is a huge mess" solved, let's focus on the question.
> Now, there is a recurring situation when I realize that for the my current changes require something else to work first.
You should use branches for this!
mkdir example2 && cd example2 || exit
git init
printf 'first line\nlast line\n' >foo
git add foo
git commit -m 'initial commit'
sed -i '2i a good line that should be staged' foo
# <-------- Git history reference point 1
git add foo
# <-------- Git history reference point 2
sed -i '3i a WIP line that should NOT be staged' foo
# <-------- Git history reference point 3
# So at this point, you realize that the line added to index needs an update but you
# do not want to include the current WIP change (and neither do you want to lose it)
# Well, the solution is to create a temporary commit for the stuff in the index
# and then check out a new branch and commit the WIP change there.
git commit -m '==== the stash with index ===='
git checkout -b wip_branch
git add foo
git commit -m '==== WIP change ===='
git checkout main
git reset HEAD^ # This discards the actual commit '==== the stash with index ====' but
# keeps the changes from that commit in the working directory,
# thus in practice restore Git history reference point 1.
sed -i '2i some conflicting change' foo
git commit -a -m 'a new HEAD conflicting with the WIP change'
git rebase main wip_branch
The rebase triggers a conflict which is simple to resolve by using KDiff3.
KDiff3 did not automatically match the two lines that are the same in this instance
but you can override by adding manual diff alignment (indicated with orange on the side).
Before the rebase the history looks like
and after
Resolved by running
$ git rebase main wip_branch
Auto-merging foo
CONFLICT (content): Merge conflict in foo
error: could not apply 8e05888... ==== the stash with index ====
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply 8e05888... ==== the stash with index ====
$ git resolve-conflict-using-kdiff3
================================================================================
1 unmerged files in total:
foo
================================================================================
Handling foo (1/1): Modified on both branches
1: 5776cea 2: bdb7168 3: 84ae2e5
Launch kdiff3 for foo? [YyNnQq123] (y):
Update foo with merge result? [YyNnQq] (y): y
interactive rebase in progress; onto b0f7593
Last command done (1 command done):
pick 8e05888 ==== the stash with index ====
Next command to do (1 remaining command):
pick a33c2fd ==== WIP change ====
(use "git rebase --edit-todo" to view and edit)
You are currently rebasing branch 'wip_branch' on 'b0f7593'.
(all conflicts fixed: run "git rebase --continue")
Untracked files:
(use "git add <file>..." to include in what will be committed)
foo.merged.orig
nothing added to commit but untracked files present (use "git add" to track)
Command(s) suggested to continue:
git rebase --skip
$ git rebase --skip
Successfully rebased and updated refs/heads/wip_branch.
$ git checkout main
Switched to branch 'main'
$ git merge --ff wip_branch
Updating b0f7593..8815707
Fast-forward
foo | 1 +
1 file changed, 1 insertion(+)
$ git branch -d wip_branch
Deleted branch wip_branch (was 8815707).
$ git reset HEAD^
Unstaged changes after reset:
M foo
$
at which point the code is back to Git history reference point 3 but with the additional conflicting change injected into the history.
This answer is the most generic answer, for the particular example you provided, I could have gotten away with just using git add -p
and avoided the following branch and rebase operation.
答案2
得分: 0
看起来没有现成的方法可以解决冲突并同时保留索引。
在冲突解决期间,Git会将暂存区用于自己的目的,这实际上会擦除其中的数据。
然而,一个存储项只是存储在仓库中的几个提交。
git stash
命令是为了方便我们管理这些提交,但我们不一定非要使用它。
你可以手动地将存储项弹出,以保留索引。
关键是将存储项分两步合并到当前的HEAD
中:首先是只合并索引,然后是其他部分。
(你可以使用多个提交来跟踪哪些文件来自哪里,并确保解决冲突不会删除任何信息。)
首先,你需要将存储项的提交结构转换为合理的形式。
普通的存储项由2个或3个提交交织而成的奇怪的合并网络组成。这不仅复杂而且难以处理。
相反,你可以只有两个线性提交:第一个包含索引更改,第二个包含非索引更改。
首先,将HEAD
移动到存储第一个存储项中存储索引文件的提交。
stash@{N}
是存储项编号为N
的顶部提交,stash@{N}^2
是它的第二个父提交。(存储项始终至少有2个父提交:创建存储项的基础提交和存储索引的提交。)
你可以使用--detach
选项,因为这些提交将是临时的,不需要分支。
git switch --detach stash@{0}^2
对于第二个提交,你应该将存储项的末尾合并提交转换为普通提交,使用git merge --squash
命令。
下面的代码还会检查该合并提交是否有第三个父提交,用于存储未跟踪的文件。如果是这样,它们也会被添加。
git merge --squash stash@{0}
if git rev-parse stash@{0}^3 1>/dev/null 2>&1
then
git ls-tree -r --name-only stash@{0}^3 -z \
| xargs -0 -- git restore --source=stash@{0}^3 --
git add .
fi
git commit
此时,Git仓库应该如下所示:
A -----> 存储索引 -----> 存储非索引(HEAD)
\
\-> B
A
是将更改推送到存储的初始提交,B
是你想要应用更改的新提交。
(顺便说一下,原始存储项在这里没有画出来,但它仍然存在。它没有丢失或其他什么。)
第二步只是将简化后的存储项变基到你想要应用它的分支上。
只需要一个命令:
git rebase --onto B HEAD~2 HEAD
在这个阶段,你将需要解决之前阻止你应用存储项的冲突。
完成后,仓库应该如下所示:
A -----> B -----> 存储索引 -----> 存储非索引(HEAD)
第三步是删除提交,而不丢失任何更改或索引的内容。
非常简单:
git reset --mixed HEAD~
git reset --soft HEAD~
第四步也只是一些清理工作。
当前你处于分离的HEAD
状态,很可能你在某个分支的顶部开始了整个操作,就像一个合理的Git用户一样。
你需要切换回你的分支:
git switch 你的分支
如果不再需要存储项,你也可以删除它:
git stash drop
完整的脚本
以这种方式弹出存储项需要很多命令,并且容易出错。
一个更好的主意是编写一个可以自动执行此操作并提供一些基本的防错功能的脚本。
#!/usr/bin/env sh
set -e
git_dir="$(git rev-parse --git-dir)"
rebase_failed=0
if [ "$1" = '--continue' ]
then
shift
if [ $# -gt 0 ]
then
printf '参数太多!\n' 1>&2
exit 1
fi
if ! [ -f "$git_dir/better-unstash" ]
then
printf '没有正在进行的"better-unstash"操作!\n' 1>&2
exit 1
fi
{
read -r current_branch
read -r detached
} <"$git_dir/better-unstash"
rm -f "$git_dir/better-unstash"
if ! git -c 'core.editor=true' rebase --continue
then
rebase_failed=1
fi
else
if [ $# -eq 0 ]
then
stash='stash@{0}'
elif [ $# -eq 1 ]
then
if [ "$1" -eq "$1" ] 2>/dev/null
then
stash="stash@{$1}"
else
stash="$1"
fi
else
printf '参数太多!\n' 1>&2
exit 1
fi
if ! git diff --quiet HEAD
then
# 仍然有一些限制。
printf '工作目录中有未提交的更改!\n' 1>&2
printf '在尝试应用存储项之前,请提交或存储它们。\n' 1>&2
exit 1
fi
detached=0
current_branch="$(git rev-parse --abbrev-ref HEAD)"
if [ "$current_branch" = 'HEAD' ]
then
detached=1
current_branch="$(git rev-parse HEAD)"
fi
git switch --detach "$stash^2"
git merge --ff-only --squash "$stash"
if git rev-parse "$stash^3" 1>/dev/null 2>&1
then
git ls-tree -r --name-only "$stash^3" -z \
| xargs -0 -- git restore --source="$stash^3" --
git add .
fi
git commit --no-edit --no-verify --allow-empty
if ! git rebase --onto "$current_branch" "HEAD~2" "HEAD"
then
rebase_failed=1
fi
fi
if [ "$rebase_failed" -ne 0 ]
then
printf '请使用`%s --continue`而不是`git rebase --continue`!\n' "$0"
printf '%s\n%s\n' "$current_branch" "$detached" >"$git_dir/better-unstash"
exit 1
fi
git reset --mixed HEAD~
git reset --soft HEAD~
if [ "$detached" -eq 0 ]
then
git switch "$current_branch"
fi
printf '由于这是一个更高风险的非标准脚本,存储项被保留。\n'
这个脚本的工作方式类似于git rebase
,它会在冲突时退出,并在修复冲突后使用--continue
标志重新启动。
对于初始运行,你可以传递一个可选参数来指定要弹出的存储项。
英文:
It doesn't look like there is a out-of-the-box way to resolve conflicts and keep the index at the same time.
During conflict resolution, Git uses the staging area for its own purposes, which effectively erases the data there.
However, a stash entry is just a few commits in the repository.
The command git stash
is provided for our convenience to manage those commits but we don't have to use it.
You can instead pop the stash manually in a way that preserves the index.
The key is to merge the stash into the current HEAD
in 2 steps: first only the index and later the rest.
(You can use multiple commits to keep track of which files are from where and make sure that solving conflicts won't remove any information.)
First, you need to convert the commit structure of the stash entry into something sane.
The normal stash entry comprises of 2 or 3 commits woven into a bizarre web of merges. This is not only pointlessly complicated but also hard to work with.
Instead, you could have just two linear commits: first with indexed changes and the second with the non-index ones.
First we move HEAD
to the commit that stores the indexed files from the 1st stash entry.
stash@{N}
is the top commit of the stash entry number N
and stash@{N}^2
is its second parent. (Stash entry always has at least 2 parent commits: the base commit at which the entry was created and a commit storing stashed index.)
You can use the option --detach
because these commits will be temporary and there is no use for a branch.
git switch --detach stash@{0}^2
For the second commit, you should convert the tip of the stash entry from a merge commit into a normal commit, using git merge --squash
.
The code below additionally checks if that merge has a 3rd parent that stores untracked files. If this is the case, they are also added.
git merge --squash stash@{0}
if git rev-parse stash@{0}^3 1>/dev/null 2>&1
then
git ls-tree -r --name-only stash@{0}^3 -z \
| xargs -0 -- git restore --source=stash@{0}^3 --
git add .
fi
git commit
At this point the Git repository should look like follows:
A -----> stash index -----> stash non-index (HEAD)
\
\-> B
A
is the initial commit where the changes were pushed to stash and B
is the new commit where you want to apply the changes.
(Btw, the original stash entry is not drawn here but it still exists. It isn't lost it or anything.)
The second step is to just rebase the simplified stash entry onto the branch where you want to apply it.
It's just one command:
git rebase --onto B HEAD~2 HEAD
At this stage you will have to resolve the conflicts that blocked you from applying the stash before.
After it's all finished, the repository should look like this:
A -----> B -----> stash index -----> stash non-index (HEAD)
The third step is to remove the commits, without losing any changes or the contents of the index.
It is as simple as:
git reset --mixed HEAD~
git reset --soft HEAD~
The forth and the last step is just some cleanup.
Currently you are in a detached HEAD
state and you most likely started the whole operation at a top of some branch like a sane Git user.
You need to switch back to your branch:
git switch your_branch
You can also remove the stash entry if it's no longer needed:
git stash drop
An entire script
Popping stash in this way takes a lot of command and is quite error-prone.
A much better idea is a script that can do it automatically and also provides some rudimentary idiot-proofing.
#!/usr/bin/env sh
set -e
git_dir="$(git rev-parse --git-dir)"
rebase_failed=0
if [ "$1" = '--continue' ]
then
shift
if [ $# -gt 0 ]
then
printf 'Too many arguments!\n' 1>&2
exit 1
fi
if ! [ -f "$git_dir/better-unstash" ]
then
printf 'There is no "better-unstash" operation in progress!\n' 1>&2
exit 1
fi
{
read -r current_branch
read -r detached
} <"$git_dir/better-unstash"
rm -f "$git_dir/better-unstash"
if ! git -c 'core.editor=true' rebase --continue
then
rebase_failed=1
fi
else
if [ $# -eq 0 ]
then
stash='stash@{0}'
elif [ $# -eq 1 ]
then
if [ "$1" -eq "$1" ] 2>/dev/null
then
stash="stash@{$1}"
else
stash="$1"
fi
else
printf 'Too many arguments!\n' 1>&2
exit 1
fi
if ! git diff --quiet HEAD
then
# There are still are some limitations.
printf 'There are uncommitted changes in the working directory!\n' 1>&2
printf 'Commit or stash them before attempting unstashing with index.\n' 1>&2
exit 1
fi
detached=0
current_branch="$(git rev-parse --abbrev-ref HEAD)"
if [ "$current_branch" = 'HEAD' ]
then
detached=1
current_branch="$(git rev-parse HEAD)"
fi
git switch --detach "$stash^2"
git merge --ff-only --squash "$stash"
if git rev-parse "$stash^3" 1>/dev/null 2>&1
then
git ls-tree -r --name-only "$stash^3" -z \
| xargs -0 -- git restore --source="$stash^3" --
git add .
fi
git commit --no-edit --no-verify --allow-empty
if ! git rebase --onto "$current_branch" "HEAD~2" "HEAD"
then
rebase_failed=1
fi
fi
if [ "$rebase_failed" -ne 0 ]
then
printf 'USE `%s --continue` INSTEAD OF `git rebase --continue`!\n' "$0"
printf '%s\n%s\n' "$current_branch" "$detached" >"$git_dir/better-unstash"
exit 1
fi
git reset --mixed HEAD~
git reset --soft HEAD~
if [ "$detached" -eq 0 ]
then
git switch "$current_branch"
fi
printf 'The stash is kept because this is a higher-risk non-standard script.\n'
This script works similarly to the git rebase
in the sense that it will exit on conflicts and it needs to be restarted with a flag --continue
after they are fixed.
For the initial run, you can pass an optional argument that specifies the stash entry to pop.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论