Skip to content
VLSI Mentor

Tcl Part I · Module 12 · Files, Directories, and Paths

Directories: mkdir, cd, pwd, delete

The last chapter of the module covers the directory operations, which are small in number and have a few asymmetries worth knowing: creating a tree is idempotent and creating one over an existing file is not, deleting a missing path is fine and deleting a non-empty directory is not. It then makes an argument the registry asks for explicitly — that a script which changes directory is harder to reason about than one that builds paths — and earns it by running the same job both ways until one of them breaks.

Foundation12 min readTclPart IFilesDirectoriesmkdir

Part I · Module 12 · Chapter 12.13 · Directories: mkdir, cd, pwd, delete

1. Creating a tree

file mkdir creates every level it needs:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
file mkdir out/run1/logs
puts "created : [file isdirectory out/run1/logs]"
puts "and     : [file isdirectory out/run1], [file isdirectory out]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
created : 1
and     : 1, 1

One call, three directories. There is no separate "make parents" flag because that is simply what it does.

It is idempotent — calling it again on a directory that exists does nothing and raises nothing:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
file mkdir out/run1/logs
file mkdir out/run1/logs
puts "still fine : [file isdirectory out/run1/logs]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
still fine : 1

So you never need if {![file exists $d]} { file mkdir $d }. Just call it.

2. pwd and cd

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set start [pwd]
puts "start : [file tail $start]"

cd out/run1
puts "now   : [file tail [pwd]]"

cd $start
puts "back  : [file tail [pwd]]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
start : work
now   : run1
back  : work

pwd always returns an absolute path. cd with no argument goes to the home directory, which is rarely what a script wants — pass a path explicitly.

3. Deleting

file delete removes files, and it is forgiving about things that are not there:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set fp [open gone.txt w] ; puts $fp "x" ; close $fp

file delete gone.txt
puts "deleted      : [file exists gone.txt]"

file delete never-existed.txt
puts "missing path : no error"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
deleted      : 0
missing path : no error

That silence is deliberate and useful — file delete $tmp as cleanup needs no guard.

It is not forgiving about a directory with anything in it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
file mkdir full
set fp [open full/x.txt w] ; puts $fp "x" ; close $fp

file delete full
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
error deleting "full": directory not empty
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts "still there : [file isdirectory full]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
still there : 1

-force removes it and everything inside:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
file delete -force full
puts "after -force : [file isdirectory full]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
after -force : 0

4. Copying and renaming

Two more, for completeness:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set fp [open original.txt w] ; puts $fp "content" ; close $fp

file copy original.txt duplicate.txt
puts "copied   : [file exists duplicate.txt]"

file rename duplicate.txt renamed.txt
puts "renamed  : [file exists duplicate.txt] -> [file exists renamed.txt]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
copied   : 1
renamed  : 0 -> 1

Both refuse to overwrite an existing target unless given -force:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
file copy original.txt renamed.txt
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
error copying "original.txt" to "renamed.txt": file already exists

file rename -force is what makes the write-to-temporary-then-rename pattern from Chapter 12.4 §7 work.

5. Why cd makes a script harder to reason about

The registry asks for this argument, so here it is with a demonstration rather than an assertion.

A script that changes directory has to change back, and getting that right is harder than it looks:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc process_with_cd {dir} {
    cd $dir
    set files [glob -nocomplain -types f *.csv]
    if {[llength $files] == 0} {
        return "nothing to do"        ;# <- never cds back
    }
    cd ..
    return "processed [llength $files] file(s)"
}

file mkdir workdir/withdata workdir/empty
set fp [open workdir/withdata/a.csv w] ; puts $fp "x" ; close $fp

set home [pwd]
puts "start      : [file tail [pwd]]"
puts [process_with_cd workdir/withdata]
puts "after      : [file tail [pwd]]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
start      : work
processed 1 file(s)
after      : workdir

That is the path that works, and it already left us somewhere unintended. The procedure descended two levels — workdir then withdata — and climbed back only one, because cd .. is a guess about how deep we went. We are now in workdir.

The next call then fails, and the message is about something else entirely:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
process_with_cd workdir/empty
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
couldn't change working directory to "workdir/empty": no such file or directory

workdir/empty exists perfectly well — just not relative to workdir, which is where the previous call abandoned us. The error names the directory rather than the real problem, which is that the current directory is not what the caller assumed.

And the early-return path never restores anything at all, so a run that finds nothing to do leaves the script one level deeper still.

The version that builds paths has none of this:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cd $home

proc process_with_paths {dir} {
    set files [glob -nocomplain -directory $dir -types f *.csv]
    if {[llength $files] == 0} { return "nothing to do" }
    return "processed [llength $files] file(s)"
}

puts [process_with_paths workdir/withdata]
puts [process_with_paths workdir/empty]
puts "pwd unchanged : [file tail [pwd]]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
processed 1 file(s)
nothing to do
pwd unchanged : work

Two calls, both correct, in any order, from anywhere.

6. If you must cd, do it safely

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc in_directory {dir script} {
    set saved [pwd]
    cd $dir
    set code [catch {uplevel 1 $script} result]
    cd $saved
    if {$code} { return -code error $result }
    return $result
}

set home [pwd]
puts [in_directory workdir/withdata { llength [glob -nocomplain *.csv] }]
puts "pwd after    : [file tail [pwd]]"

catch {in_directory workdir/withdata { error "something failed" }} m
puts "after error  : [file tail [pwd]] ($m)"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
1
pwd after    : work
after error  : work (something failed)

The cd back happens whether the body succeeded or failed — the same shape as the open/close wrapper from Chapter 12.1 §6, and it uses Chapter 11.5's uplevel so the body runs in the caller's scope.

7. A worked example

Setting up an output tree, writing into it, and cleaning up the previous run:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc prepare_run {base name} {
    set run [file join $base $name]

    if {[file isdirectory $run]} {
        file delete -force $run          ;# a path we built, not one we were given
    }

    file mkdir [file join $run logs]
    file mkdir [file join $run reports]

    return $run
}

set run [prepare_run output run42]
puts "run dir : $run"

set fp [open [file join $run logs build.log] w]
puts $fp "started"
close $fp

set fp [open [file join $run reports summary.txt] w]
puts $fp "cycles 12000"
close $fp

foreach d [lsort [glob -nocomplain -tails -directory $run -types d *]] {
    set files [lsort [glob -nocomplain -tails -directory [file join $run $d] -types f *]]
    puts "  $d/ -> [llength $files] file(s): [join $files {, }]"
}

puts "rerun gives a clean tree: [prepare_run output run42]"
puts "  logs now : [llength [glob -nocomplain -directory [file join $run logs] *]] file(s)"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
run dir : output/run42
  logs/ -> 1 file(s): build.log
  reports/ -> 1 file(s): summary.txt
rerun gives a clean tree: output/run42
  logs now : 0 file(s)

Every path is built with file join, the script never changes directory, and the -force delete is applied to a path the procedure constructed from its own arguments.

8. What Module 12 covered

Thirteen chapters on talking to the filesystem:

  • Channels — one idea covering files, the terminal and pipes, and the obligation to close what you open
  • The canonical reading loop, and why its condition is >= 0
  • Whole-file reading, and the trailing empty element that catches everyone
  • Writing, and the missing channel argument that silently prints to your terminal instead
  • w versus a, and that w empties the file at open rather than at the first write
  • Buffering — a file at zero bytes while your script has correctly written to it
  • eof, which is retrospective, and the loop built on it that always runs once too often
  • Missing files, and why checking before opening is a race that catch does not have
  • The file command, split into predicates that are safe and accessors that raise
  • Path decomposition, and why no string-command recipe is correct
  • file normalize, for paths that survive a change of directory
  • glob, which raises rather than returning an empty list
  • Directories, and why building paths beats changing into them

A thread runs through almost all of it: the filesystem is outside your script, and it answers questions about a moment that has already passed. file exists was true a moment ago. The buffer was flushed a moment ago. The file was there when you checked. Every surprise in this module comes from the gap between asking and acting — which is why catch appears in so many of these chapters, and why the honest shape is usually "try it and handle failure" rather than "check and assume".

9. What comes next

Module 13 is Regular Expressions, and its placement is deliberate. You now have string match, string first, string range, split, scan and the file path commands — which between them handle most of what people reach for a regular expression to do, and handle it more readably. Module 13 can therefore teach regexes as the tool for the problems that genuinely need one, rather than as the first hammer.

After that, Part I closes with Module 14, Errors and Debugging — which will formalise the catch you have been using throughout this module — and Module 15, Building Tcl Programs.

10. Exercises

Exercise 1. Does file mkdir a/b/c need the parent directories to exist? What if the whole path already exists?

Exercise 2. What happens if you file mkdir a path that is already an ordinary file?

Exercise 3. What does file delete do with a path that does not exist, and with a non-empty directory?

Exercise 4. Give three rules for using file delete -force safely.

Exercise 5. Explain what goes wrong in process_with_cd when the directory is empty.

Exercise 6. Write in_directory that runs a script in a given directory and always returns to where it started.

Answers

1. No — it creates every missing level. And calling it on a path that already exists does nothing and raises nothing, so no existence check is needed.

2. It fails with can't create directory "name": file already exists. A path that is already a file cannot become a directory.

3. A missing path is silently ignored, which makes it safe as cleanup. A non-empty directory raises error deleting "name": directory not empty and is left alone — -force is required to remove a tree.

4. Never pass it a path that came from outside the script without validating it; never pass a variable that might be empty; and delete paths you constructed rather than paths you were handed.

5. Two things. The early return skips the cd .. entirely, leaving the script in the subdirectory. And even the successful path is wrong: it descended two levels and cd .. climbs only one, so it ends up in workdir rather than where it started. Both leave later relative paths resolving against the wrong directory, and the eventual error names a directory rather than the real cause.

6.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc in_directory {dir script} {
    set saved [pwd]
    cd $dir
    set code [catch {uplevel 1 $script} result]
    cd $saved
    if {$code} { return -code error $result }
    return $result
}

11. Remember

  • file mkdir creates every missing level and is idempotent — no existence check needed.
  • It fails over an existing ordinary file: can't create directory "x": file already exists.
  • file delete ignores a missing path (safe as cleanup) but refuses a non-empty directory.
  • file delete -force removes a whole tree with no undo — validate anything that came from outside, never pass an empty variable, and delete paths you built.
  • file copy and file rename refuse to overwrite unless given -force.
  • pwd is absolute; cd with no argument goes home, which a script rarely wants.
  • A script that cds has to cd back from every exit path, and forgetting one silently changes the meaning of every later relative path.
  • Prefer -directory and file join — the directory becomes a value you pass rather than hidden global state.
  • If you must cd, wrap it so the restore happens even on failure.

Where this fits

Part of the Tcl curriculum.