diff --git a/cfg-update b/cfg-update
index dddb870..a0b0bea 100755
--- a/cfg-update
+++ b/cfg-update
@@ -955,6 +955,88 @@ sub make_temp_backups{ #ARGS# ("pretend|execute")
if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; }
}
+# Disposable /tmp view of one non-live merge input so accidental tool saves cannot
+# corrupt permanent backups or Portage markers (issue #65; Stage 4: issue #68).
+# Orthogonal to make_temp_backups (which stages permanent backup promotion).
+# $role is a filename tag only (e.g. "ancestor", "new"). Returns view path or "".
+sub make_merge_view_temp { #ARGS# ($src_path, $role, $basename)
+ if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; }
+ my ($src, $role, $basename) = @_;
+ my $view = "";
+ if (!defined $src || $src eq "" || !-e $src) {
+ if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; }
+ return $view;
+ }
+ my $tmpdir = $ENV{TMPDIR};
+ if (!defined $tmpdir || $tmpdir eq "") { $tmpdir = "/tmp"; }
+ $tmpdir =~ s|/+$||;
+ if (!-d $tmpdir) {
+ if ($opt_d >= 1) { print "$tab"." mkdir -p \"$tmpdir\"\n"; }
+ `mkdir -p "$tmpdir" $debug`;
+ }
+ my $safe_role = defined $role ? $role : "view";
+ $safe_role =~ s/[^A-Za-z0-9._-]/_/g;
+ if ($safe_role eq "") { $safe_role = "view"; }
+ my $safe = defined $basename ? $basename : "";
+ $safe =~ s/[^A-Za-z0-9._-]/_/g;
+ if ($safe eq "") { $safe = "file"; }
+ $view = "$tmpdir/cfg-update-$$-$safe_role-$safe";
+ if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." cp -pP \"$src\" \"$view\"\n"; }
+ `cp -pP "$src" "$view" $debug`;
+ if (!-e $view) {
+ if ($opt_d >= 1) { print "$tab"." failed to create $view; using real path\n"; }
+ $view = "";
+ }
+ if ($opt_d >= 1) {
+ print "$tab"." path_view = $view\n";
+ $tab =~ s/ //; print "$tab"."\n";
+ }
+ return $view;
+}
+
+sub cleanup_merge_view_temp { #ARGS# ($view_path)
+ if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; }
+ my ($view) = @_;
+ if (defined $view && $view ne "" && -e $view) {
+ if (($opt_v >= 1) || ($opt_d >= 1)) { print "$tab"." rm -f \"$view\"\n"; }
+ `rm -f "$view" $debug`;
+ }
+ if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; }
+}
+
+# Unlink zero or more disposable merge-view paths (convenience wrapper).
+sub cleanup_merge_view_temps {
+ my @views = @_;
+ foreach my $view (@views) {
+ cleanup_merge_view_temp($view);
+ }
+}
+
+# Launch merge tool for Stage 3 with disposable /tmp views of ancestor + marker.
+# Temporarily rebinds existing $path_backup_new / $path_new for launch_tool only, then
+# restores them so complete/cancel paths still use the real marker and ancestor.
+sub launch_tool_with_merge_view_temps { #ARGS# ("pretend|execute","mergetool")
+ if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; }
+ my ($mode, $tool) = @_;
+ my $real_backup_new = $path_backup_new;
+ my $real_new = $path_new;
+ my $view_ancestor = "";
+ my $view_new = "";
+ if ($mode =~ /execute/) {
+ $view_ancestor = make_merge_view_temp($path_backup_new, "ancestor", $cfg_basename);
+ $view_new = make_merge_view_temp($path_new, "new", $cfg_basename);
+ if ($view_ancestor ne "") { $path_backup_new = $view_ancestor; }
+ if ($view_new ne "") { $path_new = $view_new; }
+ }
+ launch_tool($mode, $tool);
+ $path_backup_new = $real_backup_new;
+ $path_new = $real_new;
+ if ($mode =~ /execute/) {
+ cleanup_merge_view_temps($view_ancestor, $view_new);
+ }
+ if ($opt_d >= 1) { $tab =~ s/ //; print "$tab"."\n"; }
+}
+
sub update_stage1{ #ARGS# ("pretend|execute")
if ($opt_d >= 1) { print "$tab"."\n"; $tab = $tab." "; }
if ($enable_stage1 !~ /^yes$|^true$|^on$/i) {
@@ -1101,7 +1183,9 @@ sub update_stage3{ #ARGS# ("pretend|execute")
if ($key =~ /2/) { &update_keep_complete($_[0]); $key="s"; }
if ($key =~ /v|y/) {
&tool_intro($merge_tool_name);
- &launch_tool($_[0],$merge_tool);
+ # Stage 3 only: pass /tmp copies of ancestor + marker so accidental
+ # saves cannot corrupt permanent backups or Portage markers (issue #65).
+ &launch_tool_with_merge_view_temps($_[0],$merge_tool);
if (-e $path_merged) {
if ($tool_saves_mergefile_when_aborted =~ "no") {
print "$tab"." Interactive merging completed... (or aborted)\n";
@@ -1810,10 +1894,14 @@ sub tool_intro{ #ARGS# ("mergetoolname")
print "$tab"." In $_[0] you select the lines that you want to keep by simply\n";
print "$tab"." clicking on the colored lines. They will appear in the merge-pane.\n";
print "$tab"." When done, click the M-button to save the result, then exit $_[0]!\n";
+ print "$tab"." In 3-way mode the ancestor and new-file panes are temporary copies;\n";
+ print "$tab"." accidental edits there are discarded. Save the merge output only.\n";
print "$tab"." When you exit $_[0], $progname will finish the update.\n";
} elsif ($_[0] =~ /^kdiff3$/) {
print "$tab"." In $_[0] you select the lines that you want to keep.\n";
print "$tab"." When done, click the save button and exit $_[0]!\n";
+ print "$tab"." In 3-way mode the ancestor and new-file panes are temporary copies;\n";
+ print "$tab"." accidental edits there are discarded. Save the merge output only.\n";
print "$tab"." When you exit $_[0], $progname will finish the update...\n";
} elsif ($_[0] =~ /^kompare$/) {
print "$tab"." In $_[0] you select the lines that you want to keep.\n";
@@ -1836,10 +1924,20 @@ sub tool_intro{ #ARGS# ("mergetoolname")
print "$tab"." In $_[0] you select the lines that you want to keep.\n";
print "$tab"." When done, save the merged result over the current configfile by\n";
print "$tab"." right-clicking on the left pane and chosing \"Save\"!\n";
+ print "$tab"." In 3-way mode the middle (ancestor) and right (new) panes are\n";
+ print "$tab"." temporary copies; accidental edits there are discarded.\n";
print "$tab"." When you exit $_[0], $progname will finish the update...\n";
} elsif ($_[0] =~ /^tkdiff$/) {
print "$tab"." In $_[0] you select the lines that you want to keep.\n";
print "$tab"." When done, save the merged result with the \"Save & Exit\" button!\n";
+ print "$tab"." In 3-way mode the ancestor and new-file panes are temporary copies;\n";
+ print "$tab"." accidental edits there are discarded. Save the merge output only.\n";
+ print "$tab"." When you exit $_[0], $progname will finish the update...\n";
+ } elsif ($_[0] =~ /^imediff$/) {
+ print "$tab"." In $_[0] you select the lines that you want to keep.\n";
+ print "$tab"." When done, the merged result is written to the *.merge output file.\n";
+ print "$tab"." In 3-way mode the ancestor and new-file inputs are temporary copies;\n";
+ print "$tab"." accidental edits there are discarded.\n";
print "$tab"." When you exit $_[0], $progname will finish the update...\n";
} else {
print "$tab"." In $_[0] you select the lines that you want to keep.\n";
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index ea62aa4..bd2790d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -176,6 +176,8 @@ No conflict markers → apply merged result. Conflict → defer to stage 3.
Launches the configured merge tool with ancestor, live, and new files. Requires a tool with 3-way support (meld, kdiff3, xxdiff, tkdiff, imediff).
+For Stage 3, the ancestor (`$path_backup_new`) and Portage marker (`$path_new`) are each copied via `make_merge_view_temp` to disposable files under `/tmp` (or `$TMPDIR`) before the tool runs, so accidental saves on non-live panes cannot corrupt permanent backups or markers. The live file and `*.merge` output paths remain real. See issue #65. Stage 4 marker-only views are tracked in issue #68.
+
### Stage 4 — Manual 2-way merge (`update_stage4`)
Merges live file and `._cfg*` update when no backup exists. Works with all supported tools.
diff --git a/test/run-tests.sh b/test/run-tests.sh
index 3c5f412..80cfebf 100755
--- a/test/run-tests.sh
+++ b/test/run-tests.sh
@@ -267,8 +267,11 @@ setup_multi_config_protect_sandbox() {
run_cfg_update() {
local extra_args=("$@")
+ # Isolate disposable Stage 3 merge-view temps under the sandbox (issue #65).
+ mkdir -p "$SANDBOX/tmp"
CFG_UPDATE_CONF="$SANDBOX/etc/cfg-update.conf" \
PATH="$SANDBOX/bin:$PATH" \
+ TMPDIR="$SANDBOX/tmp" \
perl "$CFG_UPDATE" --ebuild --testsandbox "${extra_args[@]}"
}
@@ -288,15 +291,31 @@ echo "$*" >>"$log"
outfile=""
ancestor=""
threeway="no"
+files=()
while [[ $# -gt 0 ]]; do
case "$1" in
-o) outfile="$2"; shift 2 ;;
-b) ancestor="$2"; threeway="yes"; shift 2 ;;
-m) shift ;;
- *) shift ;;
+ *) files+=("$1"); shift ;;
esac
done
echo "THREE_WAY=$threeway" >>"$log"
+if [[ -n "$ancestor" ]]; then
+ echo "ANCESTOR=$ancestor" >>"$log"
+fi
+if [[ ${#files[@]} -ge 1 ]]; then
+ echo "LIVE=${files[0]}" >>"$log"
+fi
+if [[ ${#files[@]} -ge 2 ]]; then
+ echo "NEW=${files[1]}" >>"$log"
+fi
+# Hostile mode: overwrite tool inputs to simulate accidental pane saves (issue #65).
+if [[ "${CFG_UPDATE_MOCK_TRASH_INPUTS:-}" == "1" ]]; then
+ [[ -n "$ancestor" && -f "$ancestor" ]] && echo "TRASHED-ANCESTOR" >"$ancestor"
+ [[ ${#files[@]} -ge 2 && -f "${files[1]}" ]] && echo "TRASHED-NEW" >"${files[1]}"
+ echo "TRASHED_INPUTS=yes" >>"$log"
+fi
if [[ -n "$outfile" && -f "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" ]]; then
cp "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" "$outfile"
fi
@@ -368,9 +387,20 @@ if [[ "$use_a" == "yes" ]]; then
echo "USE_A=yes" >>"$log"
fi
case "${#files[@]}" in
- 3) echo "THREE_WAY=yes" >>"$log" ;;
+ 3)
+ echo "THREE_WAY=yes" >>"$log"
+ echo "LIVE=${files[0]}" >>"$log"
+ echo "ANCESTOR=${files[1]}" >>"$log"
+ echo "NEW=${files[2]}" >>"$log"
+ ;;
2) echo "TWO_WAY=yes" >>"$log" ;;
esac
+# Hostile mode: overwrite tool inputs to simulate accidental pane saves (issue #65).
+if [[ "${CFG_UPDATE_MOCK_TRASH_INPUTS:-}" == "1" && ${#files[@]} -eq 3 ]]; then
+ [[ -f "${files[1]}" ]] && echo "TRASHED-ANCESTOR" >"${files[1]}"
+ [[ -f "${files[2]}" ]] && echo "TRASHED-NEW" >"${files[2]}"
+ echo "TRASHED_INPUTS=yes" >>"$log"
+fi
if [[ -n "$outfile" && -f "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" ]]; then
cp "${CFG_UPDATE_TEST_SANDBOX}/golden.merge" "$outfile"
fi
@@ -780,6 +810,77 @@ tier_d_execute_manual() {
"$SANDBOX/etc/test/test_auto_3way_conflict" \
"$FIXTURES/stage2-3way-merge-conflict/expected/test_auto_3way_conflict.after_replace"
+ # Stage 3: ancestor/new args are disposable /tmp views (issue #65), not real paths
+ local real_ancestor real_marker
+ real_ancestor="$SANDBOX/var/lib/cfg-update/backups${SANDBOX}/etc/test/._new-cfg_test_auto_3way_conflict"
+ real_marker="$SANDBOX/etc/test/._cfg0000_test_auto_3way_conflict"
+ assert_file_contains "stage3 kdiff3 ancestor arg is merge-view temp" \
+ "$SANDBOX/mock-kdiff3.log" "ANCESTOR=${SANDBOX}/tmp/cfg-update-"
+ assert_file_contains "stage3 kdiff3 new arg is merge-view temp" \
+ "$SANDBOX/mock-kdiff3.log" "NEW=${SANDBOX}/tmp/cfg-update-"
+ if grep -q "ANCESTOR=${real_ancestor}" "$SANDBOX/mock-kdiff3.log" 2>/dev/null; then
+ fail "stage3 kdiff3 must not pass real ancestor path"
+ else
+ pass "stage3 kdiff3 did not pass real ancestor path"
+ fi
+ if grep -q "NEW=${real_marker}" "$SANDBOX/mock-kdiff3.log" 2>/dev/null; then
+ fail "stage3 kdiff3 must not pass real marker path"
+ else
+ pass "stage3 kdiff3 did not pass real marker path"
+ fi
+ local leftover
+ leftover="$(find "$SANDBOX/tmp" -name 'cfg-update-*' 2>/dev/null | wc -l)"
+ if [[ "$leftover" -eq 0 ]]; then
+ pass "stage3 cleaned up merge-view temps"
+ else
+ fail "stage3 left $leftover merge-view temp(s) under TMPDIR"
+ fi
+
+ # Stage 3: hostile mock trashes tool inputs; real ancestor/marker must survive cancel
+ setup_sandbox stage2-3way-merge-conflict stage3_only
+ install_mock_kdiff3 # no golden → no $path_merged → cancel/finish prompt
+ sed -i "s|^MERGE_TOOL = .*|MERGE_TOOL = $SANDBOX/bin/kdiff3|" "$SANDBOX/etc/cfg-update.conf"
+ real_ancestor="$SANDBOX/var/lib/cfg-update/backups${SANDBOX}/etc/test/._new-cfg_test_auto_3way_conflict"
+ real_marker="$SANDBOX/etc/test/._cfg0000_test_auto_3way_conflict"
+ local ancestor_before marker_before
+ ancestor_before="$(md5sum "$real_ancestor" | awk '{print $1}')"
+ marker_before="$(md5sum "$real_marker" | awk '{print $1}')"
+ output="$(CFG_UPDATE_MOCK_TRASH_INPUTS=1 run_cfg_update_stdin $'y\ns\n' -u 2>&1)" || true
+ assert_file_contains "stage3 hostile mock trashed inputs" \
+ "$SANDBOX/mock-kdiff3.log" "TRASHED_INPUTS=yes"
+ local ancestor_after marker_after
+ ancestor_after="$(md5sum "$real_ancestor" | awk '{print $1}')"
+ marker_after="$(md5sum "$real_marker" | awk '{print $1}')"
+ if [[ "$ancestor_before" == "$ancestor_after" ]]; then
+ pass "stage3 hostile cancel left real ancestor intact"
+ else
+ fail "stage3 hostile cancel corrupted real ancestor"
+ fi
+ if [[ "$marker_before" == "$marker_after" ]]; then
+ pass "stage3 hostile cancel left real marker intact"
+ else
+ fail "stage3 hostile cancel corrupted real marker"
+ fi
+ assert_file_exists "stage3 hostile cancel kept cfg marker" "$real_marker"
+
+ # Stage 3: hostile mock trashes inputs; successful merge still uses real paths for complete
+ setup_sandbox stage2-3way-merge-conflict stage3_only
+ install_mock_kdiff3 \
+ "$FIXTURES/stage2-3way-merge-conflict/expected/test_auto_3way_conflict.after_replace"
+ sed -i "s|^MERGE_TOOL = .*|MERGE_TOOL = $SANDBOX/bin/kdiff3|" "$SANDBOX/etc/cfg-update.conf"
+ # Sandbox --ebuild sets tool_saves_mergefile_when_aborted=no, so confirm with [1].
+ output="$(CFG_UPDATE_MOCK_TRASH_INPUTS=1 run_cfg_update_stdin $'y\n1\n' -u 2>&1)" || true
+ assert_stage_output "stage3 hostile complete" 3 "$output"
+ assert_file_equals "stage3 hostile complete matches golden" \
+ "$SANDBOX/etc/test/test_auto_3way_conflict" \
+ "$FIXTURES/stage2-3way-merge-conflict/expected/test_auto_3way_conflict.after_replace"
+ assert_missing "stage3 hostile complete removed cfg marker" \
+ "$SANDBOX/etc/test/._cfg0000_test_auto_3way_conflict"
+ # After complete, ancestor is replaced by promoted path_temp_new (pre-merge marker),
+ # not by the trashed view copy. The important check: trash never touched real files mid-run.
+ assert_file_contains "stage3 hostile complete used merge-view temps" \
+ "$SANDBOX/mock-kdiff3.log" "ANCESTOR=${SANDBOX}/tmp/cfg-update-"
+
# Stage 3: mock imediff must receive 3-way (-a -o live ancestor new)
setup_sandbox stage2-3way-merge-conflict stage3_only
install_mock_imediff \
@@ -794,6 +895,10 @@ tier_d_execute_manual() {
assert_file_equals "stage3 mock imediff merge matches golden" \
"$SANDBOX/etc/test/test_auto_3way_conflict" \
"$FIXTURES/stage2-3way-merge-conflict/expected/test_auto_3way_conflict.after_replace"
+ assert_file_contains "stage3 imediff ancestor arg is merge-view temp" \
+ "$SANDBOX/mock-imediff.log" "ANCESTOR=${SANDBOX}/tmp/cfg-update-"
+ assert_file_contains "stage3 imediff new arg is merge-view temp" \
+ "$SANDBOX/mock-imediff.log" "NEW=${SANDBOX}/tmp/cfg-update-"
# Stage 4: mock sdiff must run 2-way merge (no -b ancestor)
setup_sandbox stage4-manual-2way stage4_only