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)