Scite Merge On Change |
|
|
Checks if the file being edited has been changed on disk, and if so, tries to perform a three-way merge to apply the changes made to the file to the text in the editor. |
|
Checks if the file being edited has been changed on disk, and if so, tries to perform a three-way merge to apply the changes made to the file to the text in the editor. If the merge creates any conflicts, bookmarks will be set for the lines they occur on. |
|
This probably isn't very portable; it has a shellEscape function that probably won't work in Windows, and uses io.popen to invoke two other programs, stat for detecting file modifications, and merge to perform the the merge. |
|
The Unix version uses stat, diff, and diff3 to detect and merge changes. |
|
Tries to preserve the caret position and text selection by searching the merged file for the line you were on, or the text you had selected, and if that fails, will move the caret based on how much the number of lines in the file has changed, or select nothing. |
|
I couldn't find an equivalent of stat in Windows, so the Windows version uses md5sum to detect changes instead; you'll need Windows ports of md5sum, diff, and diff3 [GnuWin32], and their bin directory needs to be in your PATH environment variable so that the script can execute them. |
|
{{{!Lua -- Holds information about files that are open. local buffers = {} |
|
{{{!Lua -- Will be replaced by a function for escaping shell strings, once we know know how local shellString = nil |
|
-- Returns the escaped character, or false if it doesn't need to be escaped. local function shellEscapeCharacter(c) return c:find("[^/%.%-%a%d]") and "\\"..c end |
|
-- Will be replaced by a function for generating a string for a file that will change when that file changes. local fileState = nil |
|
-- Escapes a string to be passed to the shell local function shellString(filename) return filename == "" and "\"\"" or filename:gsub(".", shellEscapeCharacter) end |
|
local shell = os.getenv("SHELL") if shell then shell = shell:match("([^\\/]+)$") end |
|
-- Returns a string representing the state of the file; to detect changes. local function fileState(filename) local stream = io.popen(("stat -Lc %%y %s"):format(shellString(filename))) if stream then local result = stream:read("*line") io.close(stream) return result or "" |
|
if not shell then if not os.getenv("WinDir?") then error("$SHELL is undefined, and this doesn't seem to be Windows.") |
|
return "" |
|
-- Assume the shell is cmd local function shellEscapeCharacter(c) -- Escape character doesn't work in quoted strings, and spaces can't be escaped? Tragic! -- So I quote the spaces and don't quote the rest of the text. Not pretty, but it seems to work. return (c == " " and "\" \"") or (c:find("[^\\/%.%-%a%d]") and "^"..c) end shellString = function(filename) return filename == "" and "\"\"" or filename:gsub(".", shellEscapeCharacter) end fileState = function(filename) -- Use md5sum; slower than checking date, but I don't know of a -- good way to do that. local stream = io.popen(("md5sum -- %s"):format(shellString(filename))) if stream then local result = stream:read("*line") stream:close() return result end return end elseif shell == "sh" or shell == "bash" then local function shellEscapeCharacter(c) return c:find("[^/%.%-%a%d]") and "\\"..c end shellString = function(filename) return filename == "" and "\"\"" or filename:gsub(".", shellEscapeCharacter) end fileState = function(filename) local stream = io.popen(("stat -Lc %%y -- %s"):format(shellString(filename))) if stream then local result = stream:read("*line") io.close(stream) return result or "" end return "" end else error("Don't know how to safely escape strings for shell '"..shell.."'.") |
|
-- Holds information about files that are open. local buffers = {} |
|
-- current is what is currently in the edit buffer -- new is what the file on disk currently looks like. local function mergeData(orig, current, new) local result = current orig, current, new = dataToFile(orig), dataToFile(current), dataToFile(new) local stream = io.popen(("merge -p -L Local -L Original -L External %s %s %s") :format(shellString(current), shellString(orig), shellString(new))) |
|
-- new is what the file on disk currently looks like local function mergeData(orig, new) local current = editor:GetText?() current, orig, new = dataToFile(current), dataToFile(orig), dataToFile(new) -- We use diff3 to merge the files together, and -- then we use diff to discover the changes needed to transform -- the text in the buffer into the merged file. -- Then we manually apply those changes, rather than dumping the -- merged file into the buffer, so that folds, bookmarks, and selections -- are (more or less) preserved. local stream = io.popen(("diff3 -mE --strip-trailing-cr -L Local -L Original -L Disk %s %s %s | diff %s -") :format(shellString(current), shellString(orig), shellString(new), shellString(current))) |
|
result = stream:read("*all") io.close(stream) end os.remove(orig) os.remove(current) os.remove(new) return result end -- Try to figure out where a line was moved to, takes the text that was on -- and where it was last seen. This is rather dumb, but I declare it good enough. local function findMovedLine(text, y) if editor:GetLine?(y) == text then return y end for offset = 1,editor.LineCount? do if editor:GetLine?(y-offset) == text then return y-offset end |
|
local conflicts = {} local eol = "\n" |
|
if editor:GetLine?(y+offset) == text then return y+offset end end -- Not found. return y end -- Try to find your selected text. function findSelection(text, pos) if text == "" then return editor.CurrentPos?, editor.CurrentPos? end local a, b = editor:findtext(text, SCFIND_MATCHCASE, 0) if not a then return editor.CurrentPos?, editor.CurrentPos? end local p, e = a+1, math.abs(pos-a) while true do local _a, _b = editor:findtext(text, SCFIND_MATCHCASE, p) |
|
if editor.EOLMode == 0 then eol = "\r\n" elseif editor.EOLMode == 1 then eol = "\r" end local p = 1 local line = stream:read("*line") |
|
if not _a or _a < p then return a, b |
|
editor:BeginUndoAction?() while line do local action, pos = line:match("^%d[,%d]-([acd])(%d+)") if action then p = tonumber(pos) if action == "d" then -- Position of deleted text is kind of inconsistant in my opinion, but -- considering non-existent things don't usually have positions, -- I suppose I should be greatful. p = p + 1 end end local cmd, txt = line:match("^(.).(.*)$") if cmd == "<" then local a = editor.Anchor editor.TargetStart? = editor:PositionFromLine?(p-1) editor.TargetEnd? = editor.TargetStart?+editor:LineLength?(p-1) if a >= editor.TargetStart? then if a >= editor.TargetEnd? then a = a - (editor.TargetEnd?-editor.TargetStart?) else a = editor.TargetStart? end end editor:ReplaceTarget?("") editor.Anchor = a elseif cmd == ">" then local a = editor.Anchor local pos = editor:PositionFromLine?(p-1) editor:InsertText?(pos, txt..eol) if a >= pos then a = a + txt:len() + eol:len() end editor.Anchor = a if txt == "=======" then table.insert(conflicts, p) editor:MarkerAdd?(p-1, 1) -- And a bookmark for this conflict. end p = p + 1 end line = stream:read("*line") |
|
local _e = math.abs(pos-_a) |
|
editor:EndUndoAction?() |
|
if _e < e then -- This is closer to where we expected it. a, b, e = _a, _b, _e |
|
if #conflicts > 0 then print("Merge conflicts on lines: "..table.concat(conflicts, ", ").."\n") |
|
p = _a+1 |
|
stream:close() |
|
os.remove(current) os.remove(orig) os.remove(new) |
|
local pos = editor.CurrentPos? local y = editor:LineFromPosition?(pos) local x = pos-editor:PositionFromLine?(y) local line = editor:GetLine?(y) local selection_pos = editor.SelectionStart? local selection = editor:GetSelText?() y = y / editor.LineCount? editor:SetText?(mergeData(buffer.data, editor:GetText?(), data)) -- Try to restore the caret position and selection to something reasonable. y = findMovedLine(line, math.floor(.5+y*editor.LineCount?)) x = math.min(editor:LineLength?(y), x) editor.CurrentPos? = editor:PositionFromLine?(y)+x -- Offset the selection position by the same amount we moved the cursor, since -- the selection is probably near the cursor. editor.SelectionStart?, editor.SelectionEnd? = findSelection(selection, selection_pos+editor.CurrentPos?-pos) |
|
mergeData(buffer.data, data) |
|
-- I'd rather only check when SciTE regains focus after the user returns to it -- after using another program, but this will have to do. register("OnUpdateUI", onFocus) |
|
-- Don't do this on Windows, because it makes the command prompt flash over the screen, -- which is annoying. if shell then -- I'd rather only check when SciTE regains focus after the user returns to it -- after using another program, but this will have to do. register("OnUpdateUI", onFocus) end _G.moc_checkFile = function() recheckFile(props["FilePath?"]) end if scite_Command then -- Add shortcut using extman. scite_Command("Merge External Changes|moc_checkFile") else -- Add shortcut manually. local i = 1 while props["command.name."..i..".*"] ~= "" and -- Search for unused index, props["command.name."..i..".*"] ~= "Merge External Changes" do -- Or our old index if SciTE reloaded this script. i = i + 1 end props["command.name."..i..".*"] = "Merge External Changes" props["command."..i..".*"] = "moc_checkFile" props["command.subsystem."..i..".*"] = "3" props["command.mode."..i..".*"]="savebefore:no" end |
Useful for saving yourself from deleting recent changes if you have the same file opened multiple times, or when updating a repository when its files are already open.
The Unix version uses stat, diff, and diff3 to detect and merge changes.
I couldn't find an equivalent of stat in Windows, so the Windows version uses md5sum to detect changes instead; you'll need Windows ports of md5sum, diff, and diff3 [GnuWin32], and their bin directory needs to be in your PATH environment variable so that the script can execute them.
-- Will be replaced by a function for escaping shell strings, once we know know how local shellString = nil -- Will be replaced by a function for generating a string for a file that will change when that file changes. local fileState = nil local shell = os.getenv("SHELL") if shell then shell = shell:match("([^\\/]+)$") end if not shell then if not os.getenv("WinDir") then error("$SHELL is undefined, and this doesn't seem to be Windows.") end -- Assume the shell is cmd local function shellEscapeCharacter(c) -- Escape character doesn't work in quoted strings, and spaces can't be escaped? Tragic! -- So I quote the spaces and don't quote the rest of the text. Not pretty, but it seems to work. return (c == " " and "\" \"") or (c:find("[^\\/%.%-%a%d]") and "^"..c) end shellString = function(filename) return filename == "" and "\"\"" or filename:gsub(".", shellEscapeCharacter) end fileState = function(filename) -- Use md5sum; slower than checking date, but I don't know of a -- good way to do that. local stream = io.popen(("md5sum -- %s"):format(shellString(filename))) if stream then local result = stream:read("*line") stream:close() return result end return end elseif shell == "sh" or shell == "bash" then local function shellEscapeCharacter(c) return c:find("[^/%.%-%a%d]") and "\\"..c end shellString = function(filename) return filename == "" and "\"\"" or filename:gsub(".", shellEscapeCharacter) end fileState = function(filename) local stream = io.popen(("stat -Lc %%y -- %s"):format(shellString(filename))) if stream then local result = stream:read("*line") io.close(stream) return result or "" end return "" end else error("Don't know how to safely escape strings for shell '"..shell.."'.") end -- Holds information about files that are open. local buffers = {} -- Returns a string containing the contents of a file. local function fileData(filename) local stream = io.open(filename) if stream then local result = stream:read("*all") io.close(stream) return result or "" end return "" end -- Returns the last known state of a file, or sets up a new state if the file wasn't known. local function getBuffer(file) local buffer = buffers[file] if not buffer then buffer = {} buffers[file] = buffer buffer.state = fileState(file) buffer.data = fileData(file) end return buffer end -- Returns the name of a temporary file containing the passed string. local function dataToFile(data) local file = os.tmpname() local stream = io.open(file, "w") stream:write(data) stream:close() return file end -- Merges some strings, and returns the result. -- orig is the state of the file before editing occured -- new is what the file on disk currently looks like local function mergeData(orig, new) local current = editor:GetText() current, orig, new = dataToFile(current), dataToFile(orig), dataToFile(new) -- We use diff3 to merge the files together, and -- then we use diff to discover the changes needed to transform -- the text in the buffer into the merged file. -- Then we manually apply those changes, rather than dumping the -- merged file into the buffer, so that folds, bookmarks, and selections -- are (more or less) preserved. local stream = io.popen(("diff3 -mE --strip-trailing-cr -L Local -L Original -L Disk %s %s %s | diff %s -") :format(shellString(current), shellString(orig), shellString(new), shellString(current))) if stream then local conflicts = {} local eol = "\n" if editor.EOLMode == 0 then eol = "\r\n" elseif editor.EOLMode == 1 then eol = "\r" end local p = 1 local line = stream:read("*line") editor:BeginUndoAction() while line do local action, pos = line:match("^%d[,%d]-([acd])(%d+)") if action then p = tonumber(pos) if action == "d" then -- Position of deleted text is kind of inconsistant in my opinion, but -- considering non-existent things don't usually have positions, -- I suppose I should be greatful. p = p + 1 end end local cmd, txt = line:match("^(.).(.*)$") if cmd == "<" then local a = editor.Anchor editor.TargetStart = editor:PositionFromLine(p-1) editor.TargetEnd = editor.TargetStart+editor:LineLength(p-1) if a >= editor.TargetStart then if a >= editor.TargetEnd then a = a - (editor.TargetEnd-editor.TargetStart) else a = editor.TargetStart end end editor:ReplaceTarget("") editor.Anchor = a elseif cmd == ">" then local a = editor.Anchor local pos = editor:PositionFromLine(p-1) editor:InsertText(pos, txt..eol) if a >= pos then a = a + txt:len() + eol:len() end editor.Anchor = a if txt == "=======" then table.insert(conflicts, p) editor:MarkerAdd(p-1, 1) -- And a bookmark for this conflict. end p = p + 1 end line = stream:read("*line") end editor:EndUndoAction() if #conflicts > 0 then print("Merge conflicts on lines: "..table.concat(conflicts, ", ").."\n") end stream:close() end os.remove(current) os.remove(orig) os.remove(new) end -- Check if a file has been modified, and merge it if needed. local function recheckFile(file) -- The file being checked damn well better be the file in the editor. assert(file == props["FilePath"]) local buffer = getBuffer(file) local state = fileState(file) if state ~= buffer.state then local data= fileData(file) if data ~= buffer.data then mergeData(buffer.data, data) end buffer.state = state buffer.data = data end end local function onSwitch(file) recheckFile(file) end local function onClose(file) buffers[file] = nil end local function onOpen(file) onClose(file) -- Forget everything we know about the file. getBuffer(file) -- This will recreate the state information for the file. end local function onBeforeSave(file) recheckFile(file) end local function onSave(file) -- Pretend the file was just opened. onOpen(file) end local function onFocus() recheckFile(props["FilePath"]) end local function register(name, func) if _G["scite_"..name] then -- Use extman's register function if it exists. _G["scite_"..name](func) else local orig = _G[name] if orig then -- If there is already a function, replace it with a new one that will call both -- ours and the original. _G[name] = function(...) return func(...) or orig(...) end else -- If the function doesn't exist, use our own. _G[name] = func end end end register("OnOpen", onOpen) register("OnBeforeSave", onBeforeSave) register("OnSave", onSave) register("OnClose", onClose) register("OnSwitchFile", onSwitch) -- Don't do this on Windows, because it makes the command prompt flash over the screen, -- which is annoying. if shell then -- I'd rather only check when SciTE regains focus after the user returns to it -- after using another program, but this will have to do. register("OnUpdateUI", onFocus) end _G.moc_checkFile = function() recheckFile(props["FilePath"]) end if scite_Command then -- Add shortcut using extman. scite_Command("Merge External Changes|moc_checkFile") else -- Add shortcut manually. local i = 1 while props["command.name."..i..".*"] ~= "" and -- Search for unused index, props["command.name."..i..".*"] ~= "Merge External Changes" do -- Or our old index if SciTE reloaded this script. i = i + 1 end props["command.name."..i..".*"] = "Merge External Changes" props["command."..i..".*"] = "moc_checkFile" props["command.subsystem."..i..".*"] = "3" props["command.mode."..i..".*"]="savebefore:no" end