Scite Merge On Change

lua-users home
wiki

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.

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.

Source

-- 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


RecentChanges · preferences
edit · history
Last edited October 27, 2008 3:34 am GMT (diff)