client/src/Detailer.m3


 Copyright 1996-2003 John D. Polstra.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 * 3. All advertising materials mentioning features or use of this software
 *    must display the following acknowledgment:
 *      This product includes software developed by John D. Polstra.
 * 4. The name of the author may not be used to endorse or promote products
 *    derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
 * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
 * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
 * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
 * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 

MODULE Detailer;

IMPORT
  Attic, CVProto, ErrMsg, FileAttr, FileRd, FileStatus, Fixup, Fmt,
  GzipRd, GzipWr, Logger, MD5, MD5Digest, OSError, Pathname, RCSDelta,
  RCSDeltaTbl, RCSError, RCSFile, RCSKeyword, RCSRevNum, RCSTag, Rd,
  Reaper, RsyncBlock, RsyncFile, StatusFile, StreamRd, StreamWr,
  SupFileRec, SupFileRecSeq, SupMisc, SyncFixupQueue, Text, Thread,
  TokScan, Wr;

EXCEPTION Error(TEXT);

REVEAL
  T = Public BRANDED OBJECT
    proto: CVProto.T;
    wireRd: StreamRd.T;		(* Raw reader. *)
    rd: StreamRd.T;		(* Currently active reader. *)
    wireWr: StreamWr.T;		(* Raw writer. *)
    wr: StreamWr.T;		(* Currently active writer. *)
    collections: SupFileRecSeq.T;
    fixups: SyncFixupQueue.T;
    compLevel: [0..9];
    reaper: Reaper.T;
    stats: Stats;
    logger: Logger.T;
    statusFile: StatusFile.T;
  OVERRIDES
    apply := Apply;
    init := Init;
  END;

PROCEDURE Apply(self: T): REFANY =
  VAR
    ts: TokScan.T;
    collection, release: TEXT;
  BEGIN
    TRY
      IF self.stats # NIL THEN
	self.stats.start();
      END;
      TRY
	FOR i := 0 TO self.collections.size()-1 DO
	  WITH sfr = self.collections.get(i) DO
	    IF NOT SupFileRec.Option.Skip IN sfr.options THEN
	      ts := self.proto.getCmd(self.rd);
	      ts.getFolded("COLL");
	      collection := ts.getToken("collection");
	      release := ts.getToken("release");
	      SupFileRec.Check(sfr, collection, release);
	      sfr.scanTime := ts.getTime("scan time");

	      DetailCollection(self, sfr);
	    END;
	  END;
	END;
	ts := self.proto.getCmd(self.rd);
	ts.getLiteral(".");
	Trace(self, "");
	self.proto.putCmd(self.wr, ".");
	Wr.Flush(self.wr);

	SendFixups(self);
	Trace(self, "");
      FINALLY
	IF self.reaper # NIL THEN
	  Reaper.Dying(self.reaper);
	END;
	IF self.stats # NIL THEN
	  self.stats.finish();
	END;
      END;
      RETURN NEW(SupMisc.ThreadStatus,
	status := SupMisc.ExitCode.Success, message := NIL);
    EXCEPT
    | Error(msg) =>
	RETURN NEW(SupMisc.ThreadStatus,
	  status := SupMisc.ExitCode.Failure,
	  message := "Detailer failed: " & msg);
    | Rd.EndOfFile =>
	RETURN NEW(SupMisc.ThreadStatus,
	  status := SupMisc.ExitCode.TransientFailure,
	  message := "Detailer failed: Premature EOF from server");
    | Rd.Failure(list) =>
	RETURN NEW(SupMisc.ThreadStatus,
	  status := SupMisc.ExitCode.TransientFailure,
	  message := "Detailer failed: Network read failure: "
	    & ErrMsg.StrError(list));
    | Thread.Alerted =>
	RETURN NEW(SupMisc.ThreadStatus,
	  status := SupMisc.ExitCode.Failure,
	  message := "Detailer failed: Interrupted");
    | TokScan.Error(msg) =>
	RETURN NEW(SupMisc.ThreadStatus,
	  status := SupMisc.ExitCode.Failure,
	  message := "Detailer failed: Protocol error: " & msg);
    | Wr.Failure(list) =>
	RETURN NEW(SupMisc.ThreadStatus,
	  status := SupMisc.ExitCode.TransientFailure,
	  message := "Detailer failed: Network write failure: "
	    & ErrMsg.StrError(list));
    END;
  END Apply;

PROCEDURE DetailCollection(self: T; sfr: SupFileRec.T)
  RAISES {Error, Rd.EndOfFile, Rd.Failure, Thread.Alerted, TokScan.Error,
	  Wr.Failure} =
  VAR
    ts: TokScan.T;
    compress: BOOLEAN;
    cmd: TEXT;
    name: TEXT;
    linkTo: TEXT;
    attrText: TEXT;
    attr: FileAttr.T;
    cmdCh: CHAR;
    startingBytesIn, startingWireBytesIn: LONGREAL;
    startingBytesOut, startingWireBytesOut: LONGREAL;
  BEGIN
    self.proto.putCmd(self.wr, "COLL", sfr.collection, sfr.release);
    Wr.Flush(self.wr);

    compress := SupFileRec.Option.Compress IN sfr.options;

    IF compress THEN
      TRY
	IF self.stats = NIL THEN
	  self.rd := NEW(GzipRd.T).init(self.wireRd, closeChild := FALSE);
	ELSE
	  (* Use reduced read sizes in order to reduce the granularity of
	     the statistics. *)
	  self.rd := NEW(GzipRd.T).init(self.wireRd, maxChildRead := 512,
	    closeChild := FALSE);
	END;
      EXCEPT OSError.E(list) =>
	RAISE Error("Cannot create Gzip reader: " & ErrMsg.StrError(list));
      END;
    END;
    TRY
      IF compress THEN
	TRY
	  self.wr := NEW(GzipWr.T).init(self.wireWr, level := self.compLevel,
	    closeChild := FALSE);
	EXCEPT OSError.E(list) =>
	  RAISE Error("Cannot create Gzip writer: " & ErrMsg.StrError(list));
	END;
      END;
      TRY
	TRY
	  self.statusFile := StatusFile.Open(sfr);
	  TRY
	    LOOP
	      IF Thread.TestAlert() THEN
		RAISE Thread.Alerted;
	      END;
	      startingBytesIn := StreamRd.ByteCount(self.rd);
	      startingBytesOut := StreamWr.ByteCount(self.wr);
	      startingWireBytesIn := StreamRd.ByteCount(self.wireRd);
	      startingWireBytesOut := StreamWr.ByteCount(self.wireWr);
	      ts := self.proto.getCmd(self.rd);
	      cmdCh := ts.getChar("command");
	      cmd := Text.FromChar(cmdCh);
	      CASE cmdCh OF
	      | '.' =>
		  EXIT;
	      | 'D' =>  (* Delete file. *)
		  name := ts.getToken("file name");
		  ts.getEnd("End of \"" & cmd & "\" command");
		  Trace(self, name);
		  self.proto.putCmd(self.wr, "D", name);
	      | 'H', 'h' =>  (* Make hard link. *)
		  name := ts.getToken("file name");
		  linkTo := ts.getToken("hard link target");
		  ts.getEnd("End of \"" & cmd & "\" command");
		  Trace(self, name);
		  self.proto.putCmd(self.wr, cmd, name, linkTo);
	      | 'I', 'i', 'j' =>  (* Directory operations. *)
		  name := ts.getToken("directory name");
		  ts.getEnd("End of \"" & cmd & "\" command");
		  self.proto.putCmd(self.wr, cmd, name);
	      | 'J' =>  (* Directory operations. *)
		  name := ts.getToken("directory name");
		  attrText := ts.getToken("attributes");
		  ts.getEnd("End of \"" & cmd & "\" command");
		  self.proto.putCmd(self.wr, cmd, name, attrText);
	      | 'T', 't' =>  (* Add file in CVS mode. *)
		  name := ts.getToken("file name");
		  IF self.proto.v.hasFileAttrs THEN
		    attr := DecodeAttr(self, ts.getToken("attributes"));
		  ELSE
		    attr := NEW(FileAttr.T).init(FileAttr.FileType.File,
		      modTime := ts.getTime("modification time"));
		  END;
		  ts.getEnd("End of \"" & cmd & "\" command");
		  Trace(self, name);
		  CheckFileAttr(self, sfr, name, attr, inAttic := cmdCh = 't');
	      | 'U' =>  (* Add or update file. *)
		  name := ts.getToken("file name");
		  ts.getEnd("End of \"" & cmd & "\" command");
		  Trace(self, name);
		  SendDetails(self, sfr, name);
	      | '!' =>	(* Warning from server. *)
		  Warn(self, "Server warning: " & ts.getRest());
	      ELSE
		RAISE TokScan.Error("Invalid command \"" & cmd & "\"");
	      END;

	      Wr.Flush(self.wr);
	      IF self.stats # NIL THEN
		LOCK self.stats DO
		  INC(self.stats.numRequests);
		  self.stats.bytesIn := self.stats.bytesIn +
		    StreamRd.ByteCount(self.rd) - startingBytesIn;
		  self.stats.wireBytesIn := self.stats.wireBytesIn +
		    StreamRd.ByteCount(self.wireRd) - startingWireBytesIn;
		  self.stats.bytesOut := self.stats.bytesOut +
		    StreamWr.ByteCount(self.wr) - startingBytesOut;
		  self.stats.wireBytesOut := self.stats.wireBytesOut +
		    StreamWr.ByteCount(self.wireWr) - startingWireBytesOut;
		END;
		self.stats.update();
	      END;
	    END;
	  FINALLY
	    self.statusFile.close();
	  END;
	EXCEPT FileStatus.Error(msg) =>
	  RAISE Error(msg);
	END;

	self.proto.putCmd(self.wr, ".");
	Wr.Flush(self.wr);

	IF compress THEN
	  Wr.Close(self.wr);
	  IF NOT Rd.EOF(self.rd) THEN
	    RAISE TokScan.Error(
	      "Expected EOF from compressed stream, didn't get it");
	  END;
	  Rd.Close(self.rd);
	END;
      FINALLY
	IF compress THEN
	  GzipWr.Cleanup(self.wr);
	  self.wr := self.wireWr;
	END;
      END;
    FINALLY
      IF compress THEN
	GzipRd.Cleanup(self.rd);
	self.rd := self.wireRd;
      END;
    END;
  END DetailCollection;

PROCEDURE DecodeAttr(self: T; t: TEXT): FileAttr.T
  RAISES {TokScan.Error} =
  BEGIN
    LOOP
      TRY
	RETURN FileAttr.Decode(t);
      EXCEPT
      | FileAttr.UnknownGroup(name) =>
	  Warn(self, "Unknown group name \"" & name
	    & "\" received from server");
      | FileAttr.UnknownOwner(name) =>
	  Warn(self, "Unknown user name \"" & name
	    & "\" received from server");
      END;
    END;
  END DecodeAttr;

PROCEDURE CheckFileAttr(self: T;
                        sfr: SupFileRec.T;
			name: Pathname.T;
			serverAttr: FileAttr.T;
			inAttic: BOOLEAN)
  RAISES {FileStatus.Error, Thread.Alerted, Wr.Failure} =
  VAR
    path := SupMisc.CatPath(sfr.clientPrefix, name);
    clientAttr: FileAttr.T := NIL;
    upToDate := FALSE;
    cmd: TEXT;
    attrText: TEXT;
  BEGIN
    (* This should never get called in checkout mode, but we'll be
       careful anyway. *)
    IF NOT SupFileRec.Option.CheckoutMode IN sfr.options THEN
      (* Check for an already up-to-date version of the file. *)
      IF inAttic THEN path := SupMisc.AtticName(path) END;
      TRY
	clientAttr := FileAttr.FromPathname(path, follow := FALSE);
	upToDate := FileAttr.Equal(clientAttr, serverAttr);
      EXCEPT OSError.E => (* File doesn't exist. *) END;
    END;

    IF upToDate THEN  (* Just make sure the list file gets updated. *)
      IF inAttic THEN cmd := "l" ELSE cmd := "L" END;
      (* We send the client's version of the attributes rather than the
	 server's, and we don't cull attributes that have been negotiated
	 away.  The attributes are going to go directly into our list
	 file, and so we want them to be as complete as possible. *)
      IF self.proto.v.hasFileAttrs THEN
	attrText := FileAttr.Encode(clientAttr);
      ELSE
	attrText := TokScan.EncodeTime(FileAttr.GetModTime(clientAttr));
      END;
      self.proto.putCmd(self.wr, cmd, name, attrText);
    ELSE  (* Detail the file. *)
      SendDetails(self, sfr, name, attr := clientAttr);
    END;
  END CheckFileAttr;

PROCEDURE SendDetails(self: T;
                      sfr: SupFileRec.T;
		      name: Pathname.T;
		      attr: FileAttr.T := NIL)
  RAISES {FileStatus.Error, Thread.Alerted, Wr.Failure} =
  VAR
    path: Pathname.T;
  BEGIN
    IF SupFileRec.Option.CheckoutMode IN sfr.options THEN
      SendCheckoutDetails(self, sfr, name);
    ELSE
      (* Determine whether it is a file or a node. *)
      IF attr = NIL THEN  (* We don't have the attributes yet. *)
	path := SupMisc.CatPath(sfr.clientPrefix, name);
	TRY
	  attr := Attic.FileAttrFromPathname(path, follow := FALSE);
	EXCEPT OSError.E => (* Doesn't exist. *) END;
      END;

      IF attr = NIL THEN  (* The file doesn't exist here, so add it. *)
	self.proto.putCmd(self.wr, "A", name);
      ELSIF attr.fileType = FileAttr.FileType.File THEN  (* Regular file. *)
	IF SupMisc.IsRCS(name)
	AND NOT SupFileRec.Option.NoRCS IN sfr.options THEN
	  SendRCSDetails(self, sfr, name);
	ELSE
	  SendRegularDetails(self, sfr, name);
	END;
      ELSE  (* Some kind of node. *)
	IF self.proto.v.hasFileAttrs THEN
	  self.proto.putCmd(self.wr, "N", name);
	END;
      END;
    END;
  END SendDetails;

PROCEDURE SendRCSDetails(self: T;
                         sfr: SupFileRec.T;
			 name: Pathname.T)
  RAISES {Thread.Alerted, Wr.Failure} =
  VAR
    pathname := SupMisc.CatPath(sfr.clientPrefix, name);
    rf: RCSFile.T;
  BEGIN
    TRY
      rf := Attic.RCSFileOpenReadonly(pathname);
    EXCEPT
    | OSError.E =>
	(* The RCS file doesn't exist on the client.  Just have the server
	   send a whole new file. *)
	self.proto.putCmd(self.wr, "A", name);
	RETURN;
    | RCSError.E =>
	(* The file is not a valid RCS file.  Treat it as a regular file. *)
	SendRegularDetails(self, sfr, name);
	RETURN;
    END;
    TRY
      self.proto.putCmd(self.wr, "V", name);
      SendAdmin(self, rf);
      SendDeltas(self.wr, rf);
      self.proto.putCmd(self.wr, ".");
    FINALLY
      TRY
	RCSFile.Close(rf);
      EXCEPT OSError.E(list) =>
	Err(self, "Cannot close \"" & pathname & "\": " &
	  ErrMsg.StrError(list));
      END;
    END;
  END SendRCSDetails;

PROCEDURE SendAdmin(self: T; rf: RCSFile.T)
  RAISES {Thread.Alerted, Wr.Failure} =
  VAR
    iter: RCSFile.TagIterator;
    tag: RCSTag.T;
  BEGIN
    IF rf.branch = NIL THEN
      self.proto.putCmd(self.wr, "b");
    ELSE
      self.proto.putCmd(self.wr, "B", rf.branch);
    END;

    self.proto.putCmd(self.wr, "E", RCSKeyword.EncodeExpand(rf.expand));

    self.proto.putCmd(self.wr, "T");
    iter := RCSFile.IterateTags(rf);
    WHILE iter.next(tag) DO
      Wr.PutText(self.wr, tag.name & " " & tag.revNum & "\n");
    END;

    self.proto.putCmd(self.wr, ".");
  END SendAdmin;

PROCEDURE SendDeltas(wr: Wr.T; rf: RCSFile.T)
  RAISES {Thread.Alerted, Wr.Failure} =
  VAR
    iter: RCSDeltaTbl.Iterator;
    rev: RCSRevNum.T;
    delta: RCSDelta.T;
  BEGIN
    SupMisc.PutCmd(wr, "D");
    iter := RCSFile.IterateByNumber(rf);
    WHILE iter.next(rev, delta) DO
      Wr.PutText(wr, rev & " " & delta.date & "\n");
    END;
    SupMisc.PutCmd(wr, ".");
  END SendDeltas;
***************************************************************************

PROCEDURE SendRegularDetails(self: T;
                             sfr: SupFileRec.T;
			     name: Pathname.T)
  RAISES {Thread.Alerted, Wr.Failure} =
  BEGIN
    IF NOT SupFileRec.Option.NoRsync IN sfr.options
    AND NOT sfr.noRsync.test(name) THEN
      SendRsyncDetails(self, sfr, name);
    ELSE
      SendSimpleDetails(self, sfr, name);
    END;
  END SendRegularDetails;

PROCEDURE SendRsyncDetails(self: T;
                            sfr: SupFileRec.T;
			    name: Pathname.T)
  RAISES {Thread.Alerted, Wr.Failure} =
  VAR
    pathname := SupMisc.CatPath(sfr.clientPrefix, name);
    rsf: RsyncFile.T;
    bi: RsyncFile.BlockIterator;
    block: RsyncBlock.T;
  BEGIN
    TRY
      rsf := RsyncFile.Open(pathname);
    EXCEPT OSError.E =>  (* The file doesn't exist on the client. *)
      self.proto.putCmd(self.wr, "A", name);
      RETURN;
    END;
    TRY
      self.proto.putCmd(self.wr, "r",
	name,
	Fmt.Unsigned(FileAttr.GetSize(rsf.attr), 10),
	Fmt.Unsigned(rsf.blockSize, 10));
      bi := RsyncFile.IterateBlocks(rsf);
      WHILE bi.next(block) DO
	Wr.PutText(self.wr, Fmt.Unsigned(block.rsum, 16)
	  & " " & MD5Digest.ToText(block.md5)
	  & "\n");
      END;
      self.proto.putCmd(self.wr, ".");
    FINALLY
      TRY
	RsyncFile.Close(rsf);
      EXCEPT OSError.E(l) =>
	Err(self, "Cannot close \"" & pathname & "\": "
	  & ErrMsg.StrError(l));
      END;
    END;
  END SendRsyncDetails;

PROCEDURE SendSimpleDetails(self: T;
                            sfr: SupFileRec.T;
			    name: Pathname.T)
  RAISES {Thread.Alerted, Wr.Failure} =
  VAR
    pathname := SupMisc.CatPath(sfr.clientPrefix, name);
    rd: Rd.T;
    buf: ARRAY [0..8191] OF CHAR;
    md5: MD5.T;
    size: CARDINAL := 0;
    n: CARDINAL;
    cksum: TEXT;
  BEGIN
    TRY
      rd := FileRd.Open(pathname);
    EXCEPT OSError.E =>  (* The file doesn't exist on the client. *)
      self.proto.putCmd(self.wr, "A", name);
      RETURN;
    END;
    TRY
      TRY
	md5 := MD5.New();
	TRY
	  LOOP
	    n := Rd.GetSub(rd, buf);
	    IF n = 0 THEN EXIT END;
	    md5.update(SUBARRAY(buf, 0, n));
	    INC(size, n);
	  END;
	FINALLY
	  cksum := md5.finish();
	END;
	self.proto.putCmd(self.wr, "R",
	  name,
	  Fmt.Unsigned(size, 10),
	  cksum);
      FINALLY
	Rd.Close(rd);
      END;
    EXCEPT Rd.Failure(list) =>
      Err(self, "Read failure from \"" & pathname & "\": "
	& ErrMsg.StrError(list));
    END;
  END SendSimpleDetails;
*************************************************************************** Checkout mode. ***************************************************************************

PROCEDURE SendCheckoutDetails(self: T;
                              sfr: SupFileRec.T;
                              name: Pathname.T)
  RAISES {FileStatus.Error, Thread.Alerted, Wr.Failure} =
  VAR
    checkoutName := SupMisc.CheckoutName(name);
    pathname := SupMisc.CatPath(sfr.clientPrefix, checkoutName);
    fs: FileStatus.T;
    attr: FileAttr.T;
    cksum: TEXT;
  BEGIN
    TRY
      attr := FileAttr.FromPathname(pathname, follow := FALSE);
    EXCEPT OSError.E =>
      (* We don't have the file, so the only option at this point is to
	 tell the server to send it.  The server may figure out that the
	 file is dead, in which case it will tell us. *)
      self.proto.putCmd(self.wr, "C",
	name,
	sfr.checkoutTag,
	sfr.checkoutDate);
      RETURN;
    END;

    (* At this point, we know we have some version of the file on
       the client.  Get the information we have recorded about the
       file from a previous update, if any. *)
    fs := self.statusFile.get(name);

    (* If our recorded information doesn't match the file that the
       client has, then ignore the recorded information. *)
    IF fs # NIL THEN
      IF fs.type # FileStatus.Type.CheckoutLive
      OR NOT FileAttr.Equal(attr, fs.clientAttr) THEN
	fs := NIL;
      END;
    END;

    (* In earlier versions of this program, we did not record
       the dates associated with revision numbers.  To make the
       transition from old versions to new versions work properly,
       we have to handle the possibility that the revision date
       is not recorded.  We also have to deal with servers that
       are not prepared to receive revision dates. *)

    IF fs # NIL
    AND (NOT Text.Equal(fs.revDate, ".") OR NOT self.proto.v.sendsRevDates) THEN
      (* Our recorded information is up-to-date, and either we know the
	 revision date or the server doesn't support sending it anyway. *)
      IF self.proto.v.sendsRevDates THEN  (* Server accepts revDates. *)
	self.proto.putCmd(self.wr, "U",
	  name,
	  sfr.checkoutTag,
	  sfr.checkoutDate,
	  fs.revNum,
	  fs.revDate);
      ELSE  (* Server does not accept revDates. *)
	self.proto.putCmd(self.wr, "U",
	  name,
	  sfr.checkoutTag,
	  sfr.checkoutDate,
	  fs.revNum);
      END;
      RETURN;
    END;

    (* We don't have complete and/or accurate recorded information
       about what version of the file we have.  Compute the file's
       checksum as an aid toward identifying which version it is. *)

    TRY
      cksum := MD5.FileSignature(pathname);
    EXCEPT OSError.E(list) =>
      Err(self, "Cannot calculate checksum for \"" & pathname & "\": "
	& ErrMsg.StrError(list));
      RETURN;
    END;

    IF fs = NIL THEN
    (* We have no accurate information about the client's file.  The
       server will have to guess its revision number based on the
       checksum. *)
      self.proto.putCmd(self.wr, "S",
	name,
	sfr.checkoutTag,
	sfr.checkoutDate,
	cksum);
    ELSE
      (* Our information is accurate, but we don't trust it because
	 we hadn't recorded the date associated with the revision.
	 If somebody replaced the server's RCS file with a completely
	 different one (it happens!), then our revision number will
	 only mislead the server.  Send the revision number, but
	 require the server to verify the checksum too. *)
      <* ASSERT Text.Equal(fs.revDate, ".") *>
      <* ASSERT self.proto.v.sendsRevDates *>
      self.proto.putCmd(self.wr, "s",
	name,
	sfr.checkoutTag,
	sfr.checkoutDate,
	fs.revNum,
	cksum);
    END;
  END SendCheckoutDetails;
***************************************************************************

PROCEDURE SendFixups(self: T)
  RAISES {Error, Thread.Alerted, Wr.Failure} =
  VAR
    fixup: Fixup.T := NIL;
    compress: BOOLEAN;
  BEGIN
    FOR i := 0 TO self.collections.size()-1 DO
      WITH sfr = self.collections.get(i) DO
	IF NOT SupFileRec.Option.Skip IN sfr.options THEN
	  self.proto.putCmd(self.wr, "COLL", sfr.collection, sfr.release);

	  compress := SupFileRec.Option.Compress IN sfr.options;
	  IF compress THEN
	    Wr.Flush(self.wr);
	    TRY
	      self.wr := NEW(GzipWr.T).init(self.wireWr,
		level := self.compLevel, closeChild := FALSE);
	    EXCEPT OSError.E(list) =>
	      RAISE
		Error("Cannot create Gzip writer: " & ErrMsg.StrError(list));
	    END;
	  END;

	  LOOP  (* Process all the fixups that apply to this collection. *)
	    IF fixup = NIL THEN  (* Get the next fixup. *)
	      TRY
		fixup := self.fixups.get();
	      EXCEPT SyncFixupQueue.EndOfFile => EXIT END;
	    END;
	    IF fixup.sfr # sfr THEN EXIT END;

	    Trace(self, fixup.name);
	    IF SupFileRec.Option.CheckoutMode IN sfr.options THEN
	      self.proto.putCmd(self.wr, "Y",
		fixup.name,
		sfr.checkoutTag,
		sfr.checkoutDate);
	    ELSE
	      self.proto.putCmd(self.wr, "X", fixup.name);
	    END;
	    fixup := NIL;
	  END;

	  self.proto.putCmd(self.wr, ".");  (* Close collection. *)
	  IF compress THEN
	    Wr.Close(self.wr);
	    self.wr := self.wireWr;
	  END;
	END;
      END;
    END;
    <* ASSERT fixup = NIL *>
    self.proto.putCmd(self.wr, ".");  (* Close fixups. *)
    Wr.Flush(self.wr);
  END SendFixups;
***************************************************************************

REVEAL
  Stats = StatsPublic BRANDED OBJECT
  OVERRIDES
    init := StatsInit;
    start := StatsDoNothing;
    update := StatsDoNothing;
    finish := StatsDoNothing;
  END;

PROCEDURE StatsInit(self: Stats): Stats =
  BEGIN
    RETURN self;
  END StatsInit;

PROCEDURE StatsDoNothing(<*UNUSED*> self: Stats) =
  BEGIN
  END StatsDoNothing;
***************************************************************************

PROCEDURE Init(self: T;
               proto: CVProto.T;
	       rd: StreamRd.T;
	       wr: StreamWr.T;
	       collections: SupFileRecSeq.T;
	       fixups: SyncFixupQueue.T;
	       compLevel: [-1..9] := -1;
	       reaper: Reaper.T := NIL;
	       stats: Stats := NIL;
	       logger: Logger.T := NIL): T =
  BEGIN
    self.proto := proto;
    self.wireRd := rd;
    self.rd := rd;
    self.wireWr := wr;
    self.wr := wr;
    self.collections := collections;
    self.fixups := fixups;
    IF compLevel = -1 THEN compLevel := SupMisc.DefaultCompression END;
    self.compLevel := compLevel;
    self.reaper := reaper;
    self.stats := stats;
    self.logger := logger;
    RETURN self;
  END Init;

PROCEDURE Err(self: T; msg: TEXT) =
  BEGIN
    IF self.logger # NIL THEN
      Logger.Put(self.logger, Logger.Priority.Err, msg);
    END;
  END Err;

PROCEDURE Warn(self: T; msg: TEXT) =
  BEGIN
    IF self.logger # NIL THEN
      Logger.Put(self.logger, Logger.Priority.Warning, msg);
    END;
  END Warn;

PROCEDURE Trace(self: T; msg: TEXT) =
  BEGIN
    IF self.logger # NIL THEN
      Logger.Put(self.logger, Logger.Priority.Info, msg);
    END;
  END Trace;

BEGIN
END Detailer.

interface FileStatus is in:


interface SupFileRecSeq is in: